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:
parent
6e354ba041
commit
809235e962
41 changed files with 2840 additions and 285 deletions
169
README.md
169
README.md
|
|
@ -47,3 +47,172 @@ Silos/
|
|||
├── backend/ # FastAPI
|
||||
└── docker-compose.yml
|
||||
```
|
||||
|
||||
## Feature Progress
|
||||
|
||||
### Phase-wise Confluence-inspired platform roadmap
|
||||
|
||||
The platform is being delivered in focused phases so each collaboration and
|
||||
content-management capability is usable before the next layer is added.
|
||||
|
||||
#### Phase 1 — Page hierarchy and lifecycle foundation (complete)
|
||||
|
||||
- Added `parent_page_id` to document creation, updates, responses, and frontend
|
||||
document records.
|
||||
- Added parent validation so a page cannot parent itself, attach to an
|
||||
inaccessible page, or attach directly to a space as a page parent.
|
||||
- Added child-page creation from the current page.
|
||||
- Added hierarchical sidebar navigation with expand/collapse behavior.
|
||||
- Added a three-dot page actions menu with child-page creation, duplication,
|
||||
archive, permanent delete, and close actions.
|
||||
- Archived pages are excluded from normal dashboard and space navigation.
|
||||
- Existing space membership, sharing, and explicit Edit → Update → Publish
|
||||
behavior remain intact.
|
||||
|
||||
Validation completed:
|
||||
|
||||
- `cd frontend && npm run build`
|
||||
- `python -m compileall backend\app`
|
||||
|
||||
#### Phase 2 — Templates and page creation flows (complete)
|
||||
|
||||
- Added reusable Silo page templates for blank pages, meeting notes, project
|
||||
plans, and how-to guides.
|
||||
- Added page-template creation to the global Create menu.
|
||||
- Updated the Templates flyout to use the same shared page-template definitions.
|
||||
- New pages created inside a space preserve the space relationship.
|
||||
- New pages created from a page context preserve the parent-child relationship.
|
||||
- Added a Confluence-inspired inline `+` action beside each Silo page in the
|
||||
sidebar. It creates a child page under that page and opens it immediately.
|
||||
|
||||
Validation completed:
|
||||
|
||||
- `cd frontend && npm run build`
|
||||
- `python -m compileall backend\app`
|
||||
|
||||
#### Phase 3 — Sidebar page management and richer creation flows (complete)
|
||||
|
||||
- Replicated the Confluence-inspired sidebar hierarchy treatment with
|
||||
disclosure arrows for parent pages and bullet markers for leaf/child pages.
|
||||
- Added inline child-page `+` controls beside page names.
|
||||
- Added per-page three-dot menus with Edit, Rename, Star, Copy link, Make a
|
||||
copy, Move, Archive, and Delete actions.
|
||||
- Added inline page renaming with Enter/save behavior.
|
||||
- Added a Move page dialog with top-level placement and parent-page selection.
|
||||
- Prevented moving a page below itself or one of its descendants.
|
||||
- Added shared styling for the sidebar page-action menu.
|
||||
|
||||
Validation completed:
|
||||
|
||||
- `cd frontend && npm run build`
|
||||
- `python -m compileall backend\app`
|
||||
|
||||
#### Phase 4 — Collaboration foundation (complete)
|
||||
|
||||
- Added persisted comments for accessible documents.
|
||||
- Added threaded-comment parent validation at the API boundary.
|
||||
- Added author-only comment deletion.
|
||||
- Added a page-level Comments panel with loading, empty, error, create, and
|
||||
delete states.
|
||||
- Reused existing document sharing and editor/viewer permissions for comment
|
||||
access.
|
||||
- Added a comments entry point beside the page Edit/Update/Publish controls.
|
||||
- Added MongoDB indexes for document comment retrieval.
|
||||
|
||||
Validation completed:
|
||||
|
||||
- `cd frontend && npm run build`
|
||||
- `python -m compileall backend\app`
|
||||
|
||||
#### Phase 5 — User-authored templates and categories (complete)
|
||||
|
||||
- Added owner-scoped persisted templates in MongoDB.
|
||||
- Added template categories, descriptions, emoji labels, and starter content.
|
||||
- Added template CRUD endpoints under `/api/v1/templates`.
|
||||
- Added template indexes and authenticated ownership checks.
|
||||
- Added a Templates flyout flow for creating, filtering, using, and deleting
|
||||
user templates.
|
||||
- Custom templates create new Silo pages while preserving their starter
|
||||
content and draft state.
|
||||
|
||||
Validation completed:
|
||||
|
||||
- `cd frontend && npm run build`
|
||||
- `python -m compileall backend\app`
|
||||
|
||||
#### Phase 6 — Template sharing and placement selection (complete)
|
||||
|
||||
- Added owner-controlled template sharing by email or username.
|
||||
- Shared templates are included in the authenticated template library.
|
||||
- Added a template sharing dialog with explicit dismissal.
|
||||
- Added placement selection for pages created from custom templates:
|
||||
top-level, selected space, or selected parent page.
|
||||
- Preserved the existing hierarchy and document-access validation path for
|
||||
placement targets.
|
||||
|
||||
Validation completed:
|
||||
|
||||
- `cd frontend && npm run build`
|
||||
- `python -m compileall backend\app`
|
||||
|
||||
#### Phase 7 — Collaboration expansion (complete)
|
||||
|
||||
- Added `@username` mention parsing against registered users.
|
||||
- Added persisted mention notifications with unread/read state.
|
||||
- Added a workspace notification bell and notification dropdown.
|
||||
- Added persisted page activity entries for comment creation and
|
||||
comment resolution/reopening.
|
||||
- Added an Activity view alongside the Comments panel.
|
||||
- Added nested threaded replies using the existing `parent_id` model.
|
||||
- Added resolve/reopen controls with access-checked API mutations.
|
||||
- Preserved the existing WebSocket collaboration presence and live content
|
||||
channel, including peer updates when users join or leave.
|
||||
|
||||
Validation completed:
|
||||
|
||||
- `cd frontend && npm run build`
|
||||
- `python -m compileall backend\app`
|
||||
- FastAPI application import and route registration
|
||||
|
||||
#### Phase 8 — History, publishing, and operational polish (complete)
|
||||
|
||||
- Added persisted document version snapshots before content/title updates.
|
||||
- Added version history listing and access-controlled restore.
|
||||
- Added HTML and plain-text page export endpoints and page controls.
|
||||
- Added keyboard save (`Ctrl/Cmd+S`) and Escape dismissal for history.
|
||||
- Added accessible names, live editor status, history labels, and notification
|
||||
controls.
|
||||
- Preserved the existing MongoDB text index and access-aware search behavior.
|
||||
- Added three Playwright browser regression specs covering history, export, and
|
||||
keyboard/accessibility journeys.
|
||||
- Added `npm run test:e2e` and `runtime-validation-report.md`.
|
||||
|
||||
Validation completed:
|
||||
|
||||
- `cd frontend && npm run build`
|
||||
- `python -m compileall backend\app`
|
||||
- FastAPI application import and route registration
|
||||
- `cd frontend && npm run test:e2e` (3 tests skipped without test credentials)
|
||||
|
||||
### Dashboard onboarding
|
||||
|
||||
- Returning users see recent documents ordered by their last visited activity.
|
||||
- New users see a getting-started panel instead of blank Recent and Activity sections.
|
||||
- New users can browse templates, create a page, create a whiteboard, or create a space.
|
||||
|
||||
### Pages and whiteboards
|
||||
|
||||
- Pages and whiteboards open in read-only mode after login or re-login.
|
||||
- Users must select **Edit** before changing content or titles.
|
||||
- **Update** explicitly saves the current draft and returns the document to read-only mode.
|
||||
- **Publish** saves the page or whiteboard and marks it as published.
|
||||
- Page editing includes active Bold, Italic, Underline, and strikethrough states; paragraph/heading/quote/code styles; ordered and unordered lists; left/center/right alignment; an expanded text and highlight colour palette; grouped searchable emoji picker; styled tables; tabs; info panels; mentions; links; image/video URL insertion; and image file upload.
|
||||
- Formatting selections are preserved when a toolbar control or popover is clicked. Link, media, colour, emoji, insert, and slash-command windows close when the user clicks elsewhere or presses Escape.
|
||||
- Whiteboards retain their existing drawing, selection, table, text, sticky-note, pan, zoom, undo, and redo tools while preventing mutations until Edit is selected.
|
||||
|
||||
The frontend build is verified with:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
```
|
||||
|
|
|
|||
|
|
@ -18,6 +18,10 @@ class Settings(BaseSettings):
|
|||
refresh_token_expire_days: int = 7
|
||||
|
||||
cors_origins: str = "http://localhost:5173,http://127.0.0.1:5173"
|
||||
frontend_url: str = "http://localhost:5173"
|
||||
google_client_id: str = ""
|
||||
google_client_secret: str = ""
|
||||
google_redirect_uri: str = "http://localhost:8000/api/v1/auth/google/callback"
|
||||
|
||||
@property
|
||||
def is_sqlite(self) -> bool:
|
||||
|
|
|
|||
|
|
@ -9,8 +9,11 @@ from sqlalchemy.exc import OperationalError
|
|||
from app.config import settings
|
||||
from app.database import Base, engine
|
||||
from app.mongo import close_mongo, connect_mongo
|
||||
from app.routers import auth, collab, documents, health
|
||||
from app.routers import auth, collab, documents, health, templates
|
||||
from app.services.collaboration_service import ensure_collaboration_indexes
|
||||
from app.services.document_service import ensure_indexes
|
||||
from app.services.template_service import ensure_template_indexes
|
||||
from app.services.history_service import ensure_history_indexes
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -28,6 +31,9 @@ async def lifespan(_: FastAPI):
|
|||
from app.mongo import get_mongo_db
|
||||
|
||||
await ensure_indexes(get_mongo_db())
|
||||
await ensure_collaboration_indexes(get_mongo_db())
|
||||
await ensure_template_indexes(get_mongo_db())
|
||||
await ensure_history_indexes(get_mongo_db())
|
||||
logger.info("MongoDB connected and indexes ready")
|
||||
except Exception:
|
||||
logger.warning("MongoDB unavailable — will connect when available")
|
||||
|
|
@ -49,6 +55,7 @@ app.add_middleware(
|
|||
app.include_router(health.router, prefix="/api/v1")
|
||||
app.include_router(auth.router, prefix="/api/v1")
|
||||
app.include_router(documents.router, prefix="/api/v1")
|
||||
app.include_router(templates.router, prefix="/api/v1")
|
||||
app.include_router(collab.router, prefix="/api/v1")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
from datetime import timedelta
|
||||
from urllib.parse import urlencode
|
||||
import secrets
|
||||
|
||||
import httpx
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
|
@ -20,6 +25,7 @@ from app.schemas.user import (
|
|||
UserCreate,
|
||||
UserResponse,
|
||||
UserUpdate,
|
||||
ChangePasswordRequest,
|
||||
)
|
||||
from app.services.user_service import (
|
||||
authenticate_user,
|
||||
|
|
@ -30,7 +36,14 @@ from app.services.user_service import (
|
|||
get_valid_reset_token,
|
||||
update_user,
|
||||
)
|
||||
from app.utils.security import create_access_token, create_refresh_token, hash_password
|
||||
from app.utils.security import (
|
||||
create_access_token,
|
||||
create_oauth_state,
|
||||
create_refresh_token,
|
||||
hash_password,
|
||||
verify_oauth_state,
|
||||
verify_password,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
|
@ -114,3 +127,90 @@ def reset_password(data: ResetPasswordRequest, db: Session = Depends(get_db)) ->
|
|||
db.commit()
|
||||
|
||||
return MessageResponse(message="Password reset successfully. You can now log in.")
|
||||
|
||||
|
||||
@router.post("/change-password", response_model=MessageResponse)
|
||||
def change_password(
|
||||
data: ChangePasswordRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> MessageResponse:
|
||||
if not verify_password(data.current_password, current_user.hashed_password):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Current password is incorrect")
|
||||
current_user.hashed_password = hash_password(data.new_password)
|
||||
db.commit()
|
||||
return MessageResponse(message="Password updated successfully.")
|
||||
|
||||
|
||||
@router.get("/google/login")
|
||||
def google_login() -> RedirectResponse:
|
||||
if not settings.google_client_id or not settings.google_client_secret:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Google sign-in is not configured. Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET.",
|
||||
)
|
||||
state = create_oauth_state()
|
||||
query = urlencode({
|
||||
"client_id": settings.google_client_id,
|
||||
"redirect_uri": settings.google_redirect_uri,
|
||||
"response_type": "code",
|
||||
"scope": "openid email profile",
|
||||
"state": state,
|
||||
"access_type": "offline",
|
||||
"prompt": "select_account",
|
||||
})
|
||||
return RedirectResponse(f"https://accounts.google.com/o/oauth2/v2/auth?{query}")
|
||||
|
||||
|
||||
@router.get("/google/callback")
|
||||
async def google_callback(code: str, state: str | None = None) -> RedirectResponse:
|
||||
if not settings.google_client_id or not settings.google_client_secret:
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Google sign-in is not configured")
|
||||
if not code or not state or not verify_oauth_state(state):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Google authorization was incomplete")
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
token_response = await client.post(
|
||||
"https://oauth2.googleapis.com/token",
|
||||
data={
|
||||
"code": code,
|
||||
"client_id": settings.google_client_id,
|
||||
"client_secret": settings.google_client_secret,
|
||||
"redirect_uri": settings.google_redirect_uri,
|
||||
"grant_type": "authorization_code",
|
||||
},
|
||||
)
|
||||
if token_response.status_code != 200:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Google authorization failed")
|
||||
token_data = token_response.json()
|
||||
profile_response = await client.get(
|
||||
"https://openidconnect.googleapis.com/v1/userinfo",
|
||||
headers={"Authorization": f"Bearer {token_data['access_token']}"},
|
||||
)
|
||||
profile = profile_response.json()
|
||||
email = str(profile.get("email", "")).lower()
|
||||
if not email or not profile.get("email_verified"):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Google account email is not verified")
|
||||
from app.services.user_service import get_user_by_email
|
||||
|
||||
db = next(get_db())
|
||||
try:
|
||||
user = get_user_by_email(db, email)
|
||||
if user is None:
|
||||
base_username = email.split("@")[0][:90]
|
||||
username = base_username
|
||||
suffix = 1
|
||||
while get_user_by_username(db, username):
|
||||
username = f"{base_username}{suffix}"
|
||||
suffix += 1
|
||||
user = create_user(db, UserCreate(
|
||||
email=email,
|
||||
username=username,
|
||||
password=secrets.token_urlsafe(32),
|
||||
full_name=str(profile.get("name") or username),
|
||||
))
|
||||
access_token = create_access_token(user.email)
|
||||
refresh_token = create_refresh_token(user.email)
|
||||
finally:
|
||||
db.close()
|
||||
query = urlencode({"access_token": access_token, "refresh_token": refresh_token})
|
||||
return RedirectResponse(f"{settings.frontend_url}/auth/google/callback?{query}")
|
||||
|
|
|
|||
|
|
@ -1,11 +1,28 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from motor.motor_asyncio import AsyncIOMotorDatabase
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user, get_mongo_db_dep
|
||||
from app.models.user import User
|
||||
from app.schemas.document import DocumentCreate, DocumentResponse, DocumentUpdate, ShareRequest
|
||||
from app.schemas.document import DocumentCreate, DocumentResponse, DocumentUpdate, DocumentVersionResponse, ShareRequest
|
||||
from app.schemas.collaboration import (
|
||||
ActivityResponse,
|
||||
CommentCreate,
|
||||
CommentResolveRequest,
|
||||
CommentResponse,
|
||||
NotificationResponse,
|
||||
)
|
||||
from app.services.collaboration_service import (
|
||||
create_comment,
|
||||
delete_comment,
|
||||
list_activity,
|
||||
list_comments,
|
||||
list_notifications,
|
||||
mark_notifications_read,
|
||||
mention_names,
|
||||
resolve_comment,
|
||||
)
|
||||
from app.services.document_service import (
|
||||
add_member,
|
||||
create_document,
|
||||
|
|
@ -18,14 +35,169 @@ from app.services.document_service import (
|
|||
update_document,
|
||||
)
|
||||
from app.services.user_service import get_user_by_email_or_username
|
||||
from app.services.history_service import list_versions, restore_version
|
||||
from html import escape
|
||||
|
||||
router = APIRouter(prefix="/documents", tags=["documents"])
|
||||
|
||||
|
||||
@router.get("/{doc_id}/comments", response_model=list[CommentResponse])
|
||||
async def get_comments(
|
||||
doc_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
|
||||
) -> list[CommentResponse]:
|
||||
comments = await list_comments(db, doc_id, current_user.id)
|
||||
if comments is None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Document not found or access denied")
|
||||
return [CommentResponse(**comment) for comment in comments]
|
||||
|
||||
|
||||
@router.post("/{doc_id}/comments", response_model=CommentResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def post_comment(
|
||||
doc_id: str,
|
||||
data: CommentCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
|
||||
sql: Session = Depends(get_db),
|
||||
) -> CommentResponse:
|
||||
mentioned_users = []
|
||||
for name in mention_names(data.body):
|
||||
mentioned = get_user_by_email_or_username(sql, name.lower())
|
||||
if mentioned is not None and mentioned.id != current_user.id:
|
||||
mentioned_users.append({"id": mentioned.id})
|
||||
comment = await create_comment(
|
||||
db,
|
||||
doc_id,
|
||||
current_user.id,
|
||||
current_user.full_name or current_user.username,
|
||||
data.body,
|
||||
data.parent_id,
|
||||
mentioned_users,
|
||||
)
|
||||
if comment is None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Document not found or parent comment is invalid")
|
||||
return CommentResponse(**comment)
|
||||
|
||||
|
||||
@router.patch("/{doc_id}/comments/{comment_id}/resolve", response_model=CommentResponse)
|
||||
async def update_comment_resolution(
|
||||
doc_id: str,
|
||||
comment_id: str,
|
||||
data: CommentResolveRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
|
||||
) -> CommentResponse:
|
||||
comment = await resolve_comment(
|
||||
db, doc_id, comment_id, current_user.id, data.resolved,
|
||||
current_user.full_name or current_user.username,
|
||||
)
|
||||
if comment is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Comment not found or access denied")
|
||||
return CommentResponse(**comment)
|
||||
|
||||
|
||||
@router.get("/{doc_id}/activity", response_model=list[ActivityResponse])
|
||||
async def get_activity(
|
||||
doc_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
|
||||
) -> list[ActivityResponse]:
|
||||
entries = await list_activity(db, doc_id, current_user.id)
|
||||
if entries is None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Document not found or access denied")
|
||||
return [ActivityResponse(**entry) for entry in entries]
|
||||
|
||||
|
||||
@router.get("/notifications", response_model=list[NotificationResponse])
|
||||
async def get_notifications(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
|
||||
) -> list[NotificationResponse]:
|
||||
return [NotificationResponse(**item) for item in await list_notifications(db, current_user.id)]
|
||||
|
||||
|
||||
@router.post("/notifications/read", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def read_notifications(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
|
||||
) -> None:
|
||||
await mark_notifications_read(db, current_user.id)
|
||||
|
||||
|
||||
@router.get("/{doc_id}/history", response_model=list[DocumentVersionResponse])
|
||||
async def get_history(
|
||||
doc_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
|
||||
) -> list[DocumentVersionResponse]:
|
||||
versions = await list_versions(db, doc_id, current_user.id)
|
||||
if versions is None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Document not found or access denied")
|
||||
return [DocumentVersionResponse(**version) for version in versions]
|
||||
|
||||
|
||||
@router.post("/{doc_id}/history/{version_id}/restore", response_model=DocumentResponse)
|
||||
async def restore_history(
|
||||
doc_id: str,
|
||||
version_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
|
||||
) -> DocumentResponse:
|
||||
document = await restore_version(
|
||||
db,
|
||||
doc_id,
|
||||
version_id,
|
||||
current_user.id,
|
||||
current_user.full_name or current_user.username,
|
||||
)
|
||||
if document is None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Version not found or restore access denied")
|
||||
from app.services.document_service import _serialize
|
||||
|
||||
return DocumentResponse(**_serialize(document))
|
||||
|
||||
|
||||
@router.get("/{doc_id}/export")
|
||||
async def export_document(
|
||||
doc_id: str,
|
||||
format: str = Query("html", pattern="^(html|text)$"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
|
||||
) -> Response:
|
||||
document = await get_document(db, doc_id, current_user.id)
|
||||
if document is None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Document not found or access denied")
|
||||
body_html = str(document["content"].get("bodyHtml", document["content"].get("body", "")))
|
||||
if format == "text":
|
||||
import re
|
||||
|
||||
return Response(re.sub(r"<[^>]+>", " ", body_html).strip(), media_type="text/plain")
|
||||
html = (
|
||||
"<!doctype html><html><head><meta charset='utf-8'>"
|
||||
f"<title>{escape(document['title'])}</title></head><body>"
|
||||
f"<h1>{escape(document['title'])}</h1>{body_html}</body></html>"
|
||||
)
|
||||
return Response(html, media_type="text/html")
|
||||
|
||||
|
||||
@router.delete("/{doc_id}/comments/{comment_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def remove_comment(
|
||||
doc_id: str,
|
||||
comment_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
|
||||
) -> None:
|
||||
deleted = await delete_comment(db, comment_id, current_user.id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Comment not found or you are not its author")
|
||||
|
||||
|
||||
@router.get("", response_model=list[DocumentResponse])
|
||||
async def list_docs(
|
||||
type: str | None = Query(None, description="Filter by document type"),
|
||||
stead_id: str | None = Query(None, description="Filter by parent stead"),
|
||||
parent_page_id: str | None = Query(None, description="Filter by parent page"),
|
||||
starred: bool | None = Query(None, description="Filter starred projects"),
|
||||
sort: str = Query("updated_at", description="Sort field: updated_at or last_visited_at"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
|
|
@ -37,6 +209,7 @@ async def list_docs(
|
|||
current_user.id,
|
||||
doc_type=type,
|
||||
stead_id=stead_id,
|
||||
parent_page_id=parent_page_id,
|
||||
starred=starred,
|
||||
sort=sort_field,
|
||||
)
|
||||
|
|
@ -72,7 +245,10 @@ async def create_doc(
|
|||
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
|
||||
) -> DocumentResponse:
|
||||
created_by = current_user.full_name or current_user.username
|
||||
try:
|
||||
doc = await create_document(db, current_user.id, created_by, data)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
return DocumentResponse(**doc)
|
||||
|
||||
|
||||
|
|
|
|||
63
backend/app/routers/templates.py
Normal file
63
backend/app/routers/templates.py
Normal 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)
|
||||
44
backend/app/schemas/collaboration.py
Normal file
44
backend/app/schemas/collaboration.py
Normal 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
|
||||
|
|
@ -21,12 +21,15 @@ class DocumentCreate(BaseModel):
|
|||
content: dict[str, Any] = Field(default_factory=dict)
|
||||
template_id: str | None = None
|
||||
stead_id: str | None = None
|
||||
parent_page_id: str | None = None
|
||||
|
||||
|
||||
class DocumentUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
content: dict[str, Any] | None = None
|
||||
starred: bool | None = None
|
||||
stead_id: str | None = None
|
||||
parent_page_id: str | None = None
|
||||
|
||||
|
||||
class ShareRequest(BaseModel):
|
||||
|
|
@ -40,6 +43,7 @@ class DocumentResponse(BaseModel):
|
|||
title: str
|
||||
template_id: str | None = None
|
||||
stead_id: str | None = None
|
||||
parent_page_id: str | None = None
|
||||
content: dict[str, Any]
|
||||
created_by: str
|
||||
created_at: datetime
|
||||
|
|
@ -49,3 +53,13 @@ class DocumentResponse(BaseModel):
|
|||
owner_id: int | None = None
|
||||
visibility: Visibility = "private"
|
||||
members: list[DocumentMember] = Field(default_factory=list)
|
||||
|
||||
|
||||
class DocumentVersionResponse(BaseModel):
|
||||
id: str
|
||||
document_id: str
|
||||
title: str
|
||||
content: dict[str, Any]
|
||||
created_by: str
|
||||
created_at: datetime
|
||||
reason: str
|
||||
|
|
|
|||
24
backend/app/schemas/template.py
Normal file
24
backend/app/schemas/template.py
Normal 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
|
||||
|
|
@ -23,6 +23,11 @@ class UserUpdate(BaseModel):
|
|||
occupation: str | None = Field(default=None, max_length=150)
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
current_password: str = Field(min_length=1, max_length=128)
|
||||
new_password: str = Field(min_length=8, max_length=128)
|
||||
|
||||
|
||||
class UserResponse(UserBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
|
|
|||
168
backend/app/services/collaboration_service.py
Normal file
168
backend/app/services/collaboration_service.py
Normal 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)))
|
||||
|
|
@ -30,6 +30,7 @@ def _serialize(doc: dict[str, Any]) -> dict[str, Any]:
|
|||
"title": doc["title"],
|
||||
"template_id": doc.get("template_id"),
|
||||
"stead_id": doc.get("stead_id"),
|
||||
"parent_page_id": doc.get("parent_page_id"),
|
||||
"content": doc.get("content", {}),
|
||||
"created_by": doc["created_by"],
|
||||
"created_at": doc["created_at"],
|
||||
|
|
@ -61,6 +62,10 @@ async def create_document(
|
|||
created_by: str,
|
||||
data: DocumentCreate,
|
||||
) -> dict[str, Any]:
|
||||
if data.parent_page_id:
|
||||
parent = await db[COLLECTION].find_one({"_id": data.parent_page_id, **access_query(owner_id)})
|
||||
if parent is None or parent.get("type") == "stead":
|
||||
raise ValueError("Parent page is not accessible or is not a page")
|
||||
now = datetime.now(UTC)
|
||||
doc_id = str(uuid.uuid4())
|
||||
doc = {
|
||||
|
|
@ -70,6 +75,7 @@ async def create_document(
|
|||
"title": data.title,
|
||||
"template_id": data.template_id,
|
||||
"stead_id": data.stead_id,
|
||||
"parent_page_id": data.parent_page_id,
|
||||
"content": data.content,
|
||||
"created_by": created_by,
|
||||
"created_at": now,
|
||||
|
|
@ -97,6 +103,7 @@ async def list_documents(
|
|||
user_id: int,
|
||||
doc_type: CreateType | None = None,
|
||||
stead_id: str | None = None,
|
||||
parent_page_id: str | None = None,
|
||||
starred: bool | None = None,
|
||||
sort: SortField = "updated_at",
|
||||
) -> list[dict[str, Any]]:
|
||||
|
|
@ -106,6 +113,8 @@ async def list_documents(
|
|||
extras["type"] = doc_type
|
||||
if stead_id:
|
||||
extras["stead_id"] = stead_id
|
||||
if parent_page_id:
|
||||
extras["parent_page_id"] = parent_page_id
|
||||
if starred is not None:
|
||||
extras["starred"] = starred
|
||||
if extras:
|
||||
|
|
@ -156,6 +165,23 @@ async def update_document(
|
|||
return None
|
||||
if not can_edit(existing, user_id):
|
||||
return None
|
||||
if data.content is not None or data.title is not None:
|
||||
from app.services.history_service import create_version
|
||||
|
||||
await create_version(
|
||||
db,
|
||||
existing,
|
||||
user_id,
|
||||
existing.get("created_by", "Collaborator"),
|
||||
"Before update",
|
||||
)
|
||||
if "parent_page_id" in data.model_fields_set:
|
||||
if data.parent_page_id == doc_id:
|
||||
return None
|
||||
if data.parent_page_id is not None:
|
||||
parent = await db[COLLECTION].find_one({"_id": data.parent_page_id, **access_query(user_id)})
|
||||
if parent is None or parent.get("type") == "stead":
|
||||
return None
|
||||
|
||||
updates: dict[str, Any] = {"updated_at": datetime.now(UTC)}
|
||||
if data.title is not None:
|
||||
|
|
@ -164,6 +190,10 @@ async def update_document(
|
|||
updates["content"] = data.content
|
||||
if data.starred is not None:
|
||||
updates["starred"] = data.starred
|
||||
if data.stead_id is not None:
|
||||
updates["stead_id"] = data.stead_id
|
||||
if "parent_page_id" in data.model_fields_set:
|
||||
updates["parent_page_id"] = data.parent_page_id
|
||||
|
||||
result = await db[COLLECTION].find_one_and_update(
|
||||
{"_id": doc_id},
|
||||
|
|
|
|||
81
backend/app/services/history_service.py
Normal file
81
backend/app/services/history_service.py
Normal 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
|
||||
77
backend/app/services/template_service.py
Normal file
77
backend/app/services/template_service.py
Normal 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
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import bcrypt
|
||||
import secrets
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -31,6 +32,20 @@ def create_refresh_token(subject: str) -> str:
|
|||
return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def create_oauth_state() -> str:
|
||||
expire = datetime.now(UTC) + timedelta(minutes=10)
|
||||
payload = {"nonce": secrets.token_urlsafe(16), "exp": expire, "type": "oauth_state"}
|
||||
return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def verify_oauth_state(state: str) -> bool:
|
||||
try:
|
||||
payload = decode_token(state)
|
||||
return payload.get("type") == "oauth_state"
|
||||
except JWTError:
|
||||
return False
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict[str, Any]:
|
||||
return jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM])
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ pydantic-settings==2.8.1
|
|||
python-jose[cryptography]==3.4.0
|
||||
bcrypt==4.2.1
|
||||
python-multipart==0.0.20
|
||||
httpx==0.28.1
|
||||
motor==3.7.0
|
||||
alembic==1.14.1
|
||||
email-validator==2.2.0
|
||||
|
|
|
|||
13
frontend/e2e/helpers.ts
Normal file
13
frontend/e2e/helpers.ts
Normal 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
|
||||
}
|
||||
11
frontend/e2e/phase8-accessibility.spec.ts
Normal file
11
frontend/e2e/phase8-accessibility.spec.ts
Normal 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(() => {})
|
||||
})
|
||||
10
frontend/e2e/phase8-export.spec.ts
Normal file
10
frontend/e2e/phase8-export.spec.ts
Normal 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)
|
||||
})
|
||||
16
frontend/e2e/phase8-history.spec.ts
Normal file
16
frontend/e2e/phase8-history.spec.ts
Normal 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)
|
||||
})
|
||||
46
frontend/package-lock.json
generated
46
frontend/package-lock.json
generated
|
|
@ -15,6 +15,7 @@
|
|||
"react-router-dom": "^7.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.64.1",
|
||||
"@tailwindcss/vite": "^4.0.9",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
|
|
@ -815,6 +816,22 @@
|
|||
"node": "^22.20 || ^24.12 || >=25"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.64.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz",
|
||||
"integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.64.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
|
|
@ -2214,6 +2231,35 @@
|
|||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.64.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
|
||||
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.64.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.64.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
|
||||
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.28",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@
|
|||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"framer-motion": "^12.4.7",
|
||||
|
|
@ -22,6 +23,7 @@
|
|||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"tailwindcss": "^4.0.9",
|
||||
"typescript": "~5.7.2",
|
||||
"vite": "^6.2.0"
|
||||
"vite": "^6.2.0",
|
||||
"@playwright/test": "^1.64.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
12
frontend/playwright.config.ts
Normal file
12
frontend/playwright.config.ts
Normal 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']],
|
||||
})
|
||||
|
|
@ -9,7 +9,7 @@ import {
|
|||
LayoutGrid,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import { createOptions, shedTemplates } from '../../data/createData'
|
||||
import { createOptions, shedTemplates, siloTemplates } from '../../data/createData'
|
||||
import {
|
||||
createDocument,
|
||||
getDefaultShedContent,
|
||||
|
|
@ -24,9 +24,10 @@ const iconMap: Record<string, LucideIcon> = {
|
|||
LayoutGrid,
|
||||
}
|
||||
|
||||
export default function CreateDropdown({ steadId }: { steadId?: string }) {
|
||||
export default function CreateDropdown({ steadId, parentPageId }: { steadId?: string; parentPageId?: string }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [shedOpen, setShedOpen] = useState(false)
|
||||
const [pageOpen, setPageOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const navigate = useNavigate()
|
||||
|
||||
|
|
@ -35,6 +36,7 @@ export default function CreateDropdown({ steadId }: { steadId?: string }) {
|
|||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false)
|
||||
setShedOpen(false)
|
||||
setPageOpen(false)
|
||||
}
|
||||
}
|
||||
if (open) document.addEventListener('mousedown', handleClickOutside)
|
||||
|
|
@ -56,11 +58,28 @@ export default function CreateDropdown({ steadId }: { steadId?: string }) {
|
|||
undefined,
|
||||
author,
|
||||
steadId,
|
||||
type === 'silo' ? parentPageId : undefined,
|
||||
)
|
||||
setOpen(false)
|
||||
setPageOpen(false)
|
||||
navigate(`/workspace/${type}/${doc.id}`)
|
||||
}
|
||||
|
||||
async function handleCreatePageTemplate(template: (typeof siloTemplates)[number]) {
|
||||
const doc = await createDocument(
|
||||
'silo',
|
||||
template.label,
|
||||
{ body: '', bodyHtml: template.html, status: 'draft' },
|
||||
undefined,
|
||||
author,
|
||||
steadId,
|
||||
parentPageId,
|
||||
)
|
||||
setOpen(false)
|
||||
setPageOpen(false)
|
||||
navigate(`/workspace/silo/${doc.id}`)
|
||||
}
|
||||
|
||||
function handleCreateStead() {
|
||||
setOpen(false)
|
||||
navigate('/workspace/stead/new')
|
||||
|
|
@ -88,6 +107,7 @@ export default function CreateDropdown({ steadId }: { steadId?: string }) {
|
|||
onClick={() => {
|
||||
setOpen(!open)
|
||||
setShedOpen(false)
|
||||
setPageOpen(false)
|
||||
}}
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-full bg-silos-brown px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-silos-brown-dark"
|
||||
>
|
||||
|
|
@ -106,6 +126,7 @@ export default function CreateDropdown({ steadId }: { steadId?: string }) {
|
|||
{createOptions.map((option) => {
|
||||
const Icon = iconMap[option.icon]
|
||||
const isShed = option.id === 'shed'
|
||||
const isPage = option.id === 'silo'
|
||||
|
||||
return (
|
||||
<div key={option.id} className="relative">
|
||||
|
|
@ -113,6 +134,10 @@ export default function CreateDropdown({ steadId }: { steadId?: string }) {
|
|||
onClick={() => {
|
||||
if (isShed) {
|
||||
setShedOpen(!shedOpen)
|
||||
setPageOpen(false)
|
||||
} else if (isPage) {
|
||||
setPageOpen(!pageOpen)
|
||||
setShedOpen(false)
|
||||
} else if (option.id === 'stead') {
|
||||
handleCreateStead()
|
||||
} else if (option.id !== 'shed') {
|
||||
|
|
@ -136,11 +161,9 @@ export default function CreateDropdown({ steadId }: { steadId?: string }) {
|
|||
</p>
|
||||
<p className="mt-0.5 text-xs text-silos-muted">{option.description}</p>
|
||||
</div>
|
||||
{isShed && (
|
||||
{(isShed || isPage) && (
|
||||
<ChevronRight
|
||||
className={`mt-1 h-4 w-4 shrink-0 text-silos-muted transition-transform ${
|
||||
shedOpen ? 'rotate-90' : ''
|
||||
}`}
|
||||
className={`mt-1 h-4 w-4 shrink-0 text-silos-muted transition-transform ${(isShed ? shedOpen : pageOpen) ? 'rotate-90' : ''}`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
|
@ -165,6 +188,23 @@ export default function CreateDropdown({ steadId }: { steadId?: string }) {
|
|||
))}
|
||||
</div>
|
||||
)}
|
||||
{isPage && pageOpen && (
|
||||
<div className="ml-12 mr-2 mb-2 space-y-0.5 rounded-lg border border-silos-cream-dark bg-silos-cream/30 p-1.5">
|
||||
{siloTemplates.map((template) => (
|
||||
<button
|
||||
key={template.id}
|
||||
onClick={() => void handleCreatePageTemplate(template)}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-sm transition-colors hover:bg-white"
|
||||
>
|
||||
<span>{template.emoji}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-silos-text">{template.label}</p>
|
||||
<p className="truncate text-[10px] text-silos-muted">{template.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import RecentActivities from './RecentActivities'
|
|||
import ActivityStatus from './ActivityStatus'
|
||||
import DiscoverPanel from './DiscoverPanel'
|
||||
import RecentProjectsGrid from './RecentProjectsGrid'
|
||||
import GettingStartedPanel from './GettingStartedPanel'
|
||||
import { getStoredUser } from '../../lib/api'
|
||||
import type { DocumentRecord } from '../../lib/documents'
|
||||
|
||||
|
|
@ -33,6 +34,7 @@ interface DashboardFeedProps {
|
|||
loading?: boolean
|
||||
error?: string | null
|
||||
onOpenRecent?: () => void
|
||||
onOpenTemplates: () => void
|
||||
}
|
||||
|
||||
export default function DashboardFeed({
|
||||
|
|
@ -40,6 +42,7 @@ export default function DashboardFeed({
|
|||
loading,
|
||||
error,
|
||||
onOpenRecent,
|
||||
onOpenTemplates,
|
||||
}: DashboardFeedProps) {
|
||||
const [activeTab, setActiveTab] = useState<'feed' | 'discover'>('feed')
|
||||
const user = getStoredUser()
|
||||
|
|
@ -71,7 +74,9 @@ export default function DashboardFeed({
|
|||
{/* Recent pages grid */}
|
||||
<div className="mt-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-silos-text">Recent</h2>
|
||||
<h2 className="text-sm font-semibold text-silos-text">
|
||||
{recent.length > 0 ? 'Recent' : 'Get started'}
|
||||
</h2>
|
||||
{recent.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -82,7 +87,11 @@ export default function DashboardFeed({
|
|||
</button>
|
||||
)}
|
||||
</div>
|
||||
{recent.length === 0 && !loading ? (
|
||||
<GettingStartedPanel onOpenTemplates={onOpenTemplates} />
|
||||
) : (
|
||||
<RecentProjectsGrid documents={recent} loading={loading} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
|
|
@ -104,11 +113,37 @@ export default function DashboardFeed({
|
|||
|
||||
{activeTab === 'feed' ? (
|
||||
<div className="mt-6 grid gap-6 lg:grid-cols-5">
|
||||
{!loading && recent.length === 0 ? (
|
||||
<div className="lg:col-span-3">
|
||||
<div className="rounded-2xl border border-silos-cream-dark bg-white p-6">
|
||||
<h3 className="font-semibold text-silos-text">What you can build in Silos</h3>
|
||||
<p className="mt-1 text-sm text-silos-muted">
|
||||
Keep your team aligned with focused spaces for writing, collaboration, and
|
||||
visual thinking.
|
||||
</p>
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-3">
|
||||
{[
|
||||
['Pages', 'Capture decisions and keep knowledge easy to find.'],
|
||||
['Live docs', 'Work together on notes, plans, and reviews.'],
|
||||
['Whiteboards', 'Turn early ideas into clear visual plans.'],
|
||||
].map(([title, description]) => (
|
||||
<div key={title} className="rounded-xl bg-silos-cream/50 p-3">
|
||||
<p className="text-sm font-medium text-silos-text">{title}</p>
|
||||
<p className="mt-1 text-xs leading-5 text-silos-muted">{description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="lg:col-span-3">
|
||||
<RecentActivities documents={recent} loading={loading} onViewAll={onOpenRecent} />
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
{!(!loading && recent.length === 0) && (
|
||||
<ActivityStatus documentCount={recent.length} />
|
||||
)}
|
||||
<DiscoverPanel />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -78,6 +78,10 @@ export default function DashboardLayout() {
|
|||
setActiveNav('recent')
|
||||
setFlyout('recent')
|
||||
}}
|
||||
onOpenTemplates={() => {
|
||||
setActiveNav('templates')
|
||||
setFlyout('templates')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
144
frontend/src/components/dashboard/GettingStartedPanel.tsx
Normal file
144
frontend/src/components/dashboard/GettingStartedPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,37 +1,51 @@
|
|||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import { shedTemplates } from '../../data/createData'
|
||||
import { Plus, Trash2, X } from 'lucide-react'
|
||||
import { shedTemplates, siloTemplates } from '../../data/createData'
|
||||
import {
|
||||
createDocument,
|
||||
getDefaultShedContent,
|
||||
getDefaultTitle,
|
||||
listDocuments,
|
||||
type DocumentRecord,
|
||||
} from '../../lib/documents'
|
||||
import { getStoredUser } from '../../lib/api'
|
||||
import { templatesApi, type ApiTemplate } from '../../lib/api'
|
||||
|
||||
interface TemplatesFlyoutProps {
|
||||
sidebarCollapsed: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const siloStarters = [
|
||||
{ id: 'blank', label: 'Blank page', description: 'Empty Silo for your own content', html: '' },
|
||||
{
|
||||
id: 'meeting',
|
||||
label: 'Meeting notes',
|
||||
description: 'Silo starter for meetings',
|
||||
html: '<h2>Meeting notes</h2><p>Date:</p><ul><li>Attendees</li><li>Agenda</li><li>Actions</li></ul>',
|
||||
},
|
||||
{
|
||||
id: 'plan',
|
||||
label: 'Project plan',
|
||||
description: 'Silo starter for a project plan',
|
||||
html: '<h2>Project plan</h2><p>Goals</p><ul><li>Milestone 1</li><li>Milestone 2</li></ul>',
|
||||
},
|
||||
]
|
||||
|
||||
export default function TemplatesFlyout({ sidebarCollapsed, onClose }: TemplatesFlyoutProps) {
|
||||
const navigate = useNavigate()
|
||||
const author = getStoredUser()?.full_name ?? 'You'
|
||||
const [customTemplates, setCustomTemplates] = useState<ApiTemplate[]>([])
|
||||
const [category, setCategory] = useState('All')
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [templateCategory, setTemplateCategory] = useState('General')
|
||||
const [emoji, setEmoji] = useState('📄')
|
||||
const [bodyHtml, setBodyHtml] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [placement, setPlacement] = useState('top')
|
||||
const [placements, setPlacements] = useState<DocumentRecord[]>([])
|
||||
const [shareTemplateId, setShareTemplateId] = useState<string | null>(null)
|
||||
const [shareIdentifier, setShareIdentifier] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
void templatesApi.list().then(setCustomTemplates).catch(() => setError('Could not load your templates.'))
|
||||
void listDocuments().then((documents) => setPlacements(documents.filter((document) => document.type === 'stead' || document.type === 'silo')))
|
||||
}, [])
|
||||
|
||||
const categories = useMemo(
|
||||
() => ['All', ...Array.from(new Set(customTemplates.map((template) => template.category)))],
|
||||
[customTemplates],
|
||||
)
|
||||
const visibleTemplates = category === 'All'
|
||||
? customTemplates
|
||||
: customTemplates.filter((template) => template.category === category)
|
||||
|
||||
async function createShed(templateId: (typeof shedTemplates)[number]['id']) {
|
||||
if (templateId === 'templates') return
|
||||
|
|
@ -58,6 +72,62 @@ export default function TemplatesFlyout({ sidebarCollapsed, onClose }: Templates
|
|||
navigate(`/workspace/silo/${doc.id}`)
|
||||
}
|
||||
|
||||
async function createCustomTemplate(template: ApiTemplate) {
|
||||
const doc = await createDocument(
|
||||
'silo',
|
||||
template.name,
|
||||
{ ...template.content, status: 'draft' },
|
||||
undefined,
|
||||
author,
|
||||
placement.startsWith('space:') ? placement.slice(6) : undefined,
|
||||
placement.startsWith('page:') ? placement.slice(5) : undefined,
|
||||
)
|
||||
onClose()
|
||||
navigate(`/workspace/silo/${doc.id}`)
|
||||
}
|
||||
|
||||
async function shareTemplate(templateId: string) {
|
||||
if (!shareIdentifier.trim()) return
|
||||
try {
|
||||
const updated = await templatesApi.share(templateId, shareIdentifier.trim())
|
||||
setCustomTemplates((current) => current.map((template) => template.id === updated.id ? updated : template))
|
||||
setShareTemplateId(null)
|
||||
setShareIdentifier('')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Could not share template.')
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTemplate(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
if (!name.trim()) return
|
||||
try {
|
||||
const template = await templatesApi.create({
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
category: templateCategory.trim() || 'General',
|
||||
emoji: emoji || '📄',
|
||||
content: { body: '', bodyHtml, status: 'draft' },
|
||||
})
|
||||
setCustomTemplates((current) => [template, ...current])
|
||||
setName('')
|
||||
setDescription('')
|
||||
setBodyHtml('')
|
||||
setShowCreate(false)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Could not save template.')
|
||||
}
|
||||
}
|
||||
|
||||
async function removeTemplate(id: string) {
|
||||
try {
|
||||
await templatesApi.delete(id)
|
||||
setCustomTemplates((current) => current.filter((template) => template.id !== id))
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Could not delete template.')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 top-12 z-40 bg-black/10" onClick={onClose} />
|
||||
|
|
@ -68,11 +138,54 @@ export default function TemplatesFlyout({ sidebarCollapsed, onClose }: Templates
|
|||
>
|
||||
<div className="flex items-center justify-between border-b border-silos-cream-dark px-4 py-3">
|
||||
<h2 className="text-lg font-semibold text-silos-text">Templates</h2>
|
||||
<button type="button" onClick={onClose} className="rounded-lg p-1.5 hover:bg-silos-cream">
|
||||
<X className="h-4 w-4 text-silos-muted" />
|
||||
</button>
|
||||
<div className="flex items-center gap-1">
|
||||
<button type="button" onClick={() => setShowCreate((current) => !current)} className="rounded-lg p-1.5 text-silos-muted hover:bg-silos-cream" aria-label="Create template"><Plus className="h-4 w-4" /></button>
|
||||
<button type="button" onClick={onClose} className="rounded-lg p-1.5 hover:bg-silos-cream" aria-label="Close templates"><X className="h-4 w-4 text-silos-muted" /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-3">
|
||||
{showCreate && (
|
||||
<form onSubmit={(event) => void saveTemplate(event)} className="mb-4 rounded-xl border border-silos-cream-dark bg-silos-cream/40 p-3">
|
||||
<p className="mb-2 text-sm font-semibold text-silos-text">New page template</p>
|
||||
<div className="flex gap-2">
|
||||
<input value={emoji} onChange={(event) => setEmoji(event.target.value)} className="w-12 rounded-lg border border-silos-cream-dark bg-white px-2 py-2 text-center" aria-label="Template emoji" />
|
||||
<input value={name} onChange={(event) => setName(event.target.value)} placeholder="Template name" required className="min-w-0 flex-1 rounded-lg border border-silos-cream-dark bg-white px-3 py-2 text-sm" />
|
||||
</div>
|
||||
<input value={description} onChange={(event) => setDescription(event.target.value)} placeholder="Description" className="mt-2 w-full rounded-lg border border-silos-cream-dark bg-white px-3 py-2 text-sm" />
|
||||
<input value={templateCategory} onChange={(event) => setTemplateCategory(event.target.value)} placeholder="Category, e.g. Product" className="mt-2 w-full rounded-lg border border-silos-cream-dark bg-white px-3 py-2 text-sm" />
|
||||
<textarea value={bodyHtml} onChange={(event) => setBodyHtml(event.target.value)} placeholder="Starter content (HTML is supported)" rows={3} className="mt-2 w-full resize-none rounded-lg border border-silos-cream-dark bg-white px-3 py-2 text-sm" />
|
||||
<button type="submit" className="mt-2 w-full rounded-lg bg-silos-brown px-3 py-2 text-sm font-medium text-white">Save template</button>
|
||||
</form>
|
||||
)}
|
||||
{customTemplates.length > 0 && (
|
||||
<>
|
||||
<p className="px-1 pb-2 text-xs font-semibold uppercase text-silos-muted">Your templates</p>
|
||||
<div className="mb-3 flex gap-1 overflow-x-auto pb-1">
|
||||
{categories.map((item) => <button key={item} type="button" onClick={() => setCategory(item)} className={`whitespace-nowrap rounded-full px-2.5 py-1 text-xs ${category === item ? 'bg-silos-brown text-white' : 'bg-silos-cream text-silos-muted'}`}>{item}</button>)}
|
||||
</div>
|
||||
{visibleTemplates.map((template) => (
|
||||
<div key={template.id} className="group mb-1 flex items-start gap-2 rounded-lg px-3 py-3 hover:bg-silos-cream">
|
||||
<button type="button" onClick={() => void createCustomTemplate(template)} className="flex min-w-0 flex-1 items-start gap-3 text-left">
|
||||
<span className="text-lg">{template.emoji}</span>
|
||||
<span className="min-w-0"><span className="block text-sm font-medium text-silos-text">{template.name}</span><span className="block text-xs text-silos-muted">{template.description || template.category}</span></span>
|
||||
</button>
|
||||
<button type="button" onClick={() => setShareTemplateId(template.id)} className="rounded p-1 text-xs text-silos-muted opacity-0 hover:bg-silos-cream hover:text-silos-brown group-hover:opacity-100" aria-label={`Share ${template.name}`}>Share</button>
|
||||
<button type="button" onClick={() => void removeTemplate(template.id)} className="rounded p-1 text-silos-muted opacity-0 hover:bg-red-50 hover:text-red-700 group-hover:opacity-100" aria-label={`Delete ${template.name}`}><Trash2 className="h-3.5 w-3.5" /></button>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{customTemplates.length > 0 && (
|
||||
<label className="mb-4 block rounded-lg border border-silos-cream-dark bg-silos-cream/30 p-3 text-xs text-silos-muted">
|
||||
Create new pages in
|
||||
<select value={placement} onChange={(event) => setPlacement(event.target.value)} className="mt-1 w-full rounded-lg border border-silos-cream-dark bg-white px-2 py-2 text-sm text-silos-text">
|
||||
<option value="top">Top level</option>
|
||||
{placements.filter((item) => item.type === 'stead').map((item) => <option key={`space-${item.id}`} value={`space:${item.id}`}>Space: {item.title}</option>)}
|
||||
{placements.filter((item) => item.type === 'silo').map((item) => <option key={`page-${item.id}`} value={`page:${item.id}`}>Child of: {item.title}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{error && <p className="mb-3 rounded-lg bg-red-50 px-3 py-2 text-xs text-red-700">{error}</p>}
|
||||
<p className="px-1 pb-2 text-xs font-semibold uppercase text-silos-muted">Shed — Live docs</p>
|
||||
{shedTemplates.map((t) => (
|
||||
<button
|
||||
|
|
@ -90,7 +203,7 @@ export default function TemplatesFlyout({ sidebarCollapsed, onClose }: Templates
|
|||
</button>
|
||||
))}
|
||||
<p className="mt-4 px-1 pb-2 text-xs font-semibold uppercase text-silos-muted">Silo — Pages</p>
|
||||
{siloStarters.map((t) => (
|
||||
{siloTemplates.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
|
|
@ -103,6 +216,15 @@ export default function TemplatesFlyout({ sidebarCollapsed, onClose }: Templates
|
|||
))}
|
||||
</div>
|
||||
</aside>
|
||||
{shareTemplateId && (
|
||||
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/20 p-4">
|
||||
<form onSubmit={(event) => { event.preventDefault(); void shareTemplate(shareTemplateId) }} className="w-full max-w-sm rounded-xl bg-white p-4 shadow-2xl">
|
||||
<div className="mb-3 flex items-center justify-between"><h2 className="font-semibold text-silos-text">Share template</h2><button type="button" onClick={() => setShareTemplateId(null)} aria-label="Close"><X className="h-4 w-4" /></button></div>
|
||||
<input autoFocus value={shareIdentifier} onChange={(event) => setShareIdentifier(event.target.value)} placeholder="Email or username" className="w-full rounded-lg border border-silos-cream-dark px-3 py-2 text-sm" />
|
||||
<button type="submit" className="mt-3 w-full rounded-lg bg-silos-brown px-3 py-2 text-sm font-medium text-white">Share</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,38 +1,56 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState, type KeyboardEvent, type MouseEvent } from 'react'
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
Underline,
|
||||
AlignCenter,
|
||||
AlignLeft,
|
||||
Image,
|
||||
Link,
|
||||
AlignRight,
|
||||
AtSign,
|
||||
Smile,
|
||||
Table,
|
||||
Plus,
|
||||
Bold,
|
||||
Check,
|
||||
Code2,
|
||||
Columns2,
|
||||
FileUp,
|
||||
Image,
|
||||
Italic,
|
||||
Link,
|
||||
List,
|
||||
Heading1,
|
||||
ListOrdered,
|
||||
Palette,
|
||||
Plus,
|
||||
Smile,
|
||||
Strikethrough,
|
||||
Table,
|
||||
Underline,
|
||||
Video,
|
||||
} from 'lucide-react'
|
||||
|
||||
const toolbarGroups = [
|
||||
[
|
||||
{ icon: Heading1, label: 'Heading', cmd: 'formatBlock', value: 'h2' },
|
||||
{ icon: Bold, label: 'Bold', cmd: 'bold' },
|
||||
{ icon: Italic, label: 'Italic', cmd: 'italic' },
|
||||
{ icon: Underline, label: 'Underline', cmd: 'underline' },
|
||||
],
|
||||
[
|
||||
{ icon: AlignLeft, label: 'Align', cmd: 'justifyLeft' },
|
||||
{ icon: List, label: 'List', cmd: 'insertUnorderedList' },
|
||||
],
|
||||
type Popover = 'color' | 'emoji' | 'link' | 'image' | 'video' | 'insert' | null
|
||||
type MediaType = 'link' | 'image' | 'video'
|
||||
|
||||
const textColors = [
|
||||
'#172B4D', '#42526E', '#5E6C84', '#7A869A', '#091E42', '#0747A6',
|
||||
'#0065FF', '#00875A', '#36B37E', '#FFAB00', '#FF8B00', '#DE350B',
|
||||
'#BF2600', '#6554C0', '#403294', '#FFFFFF', '#E3E8EF', '#B3BAC5',
|
||||
]
|
||||
|
||||
const highlightColors = [
|
||||
'transparent', '#DEEBFF', '#E6FCFF', '#E3FCEF', '#FFFAE6', '#FFEBE6',
|
||||
'#EAE6FF', '#FFF0B3', '#C6F6D5', '#FED7D7', '#E9D8FD', '#D6F5FF',
|
||||
]
|
||||
|
||||
const emojiGroups = [
|
||||
{ label: 'Smileys', items: ['😀', '😃', '😄', '😁', '😆', '😅', '😂', '🤣', '😊', '😇', '🙂', '🙃', '😉', '😌', '😍', '🥰', '😘', '😎', '🤓', '🧐', '🤩', '🥳', '😏', '😢', '😭', '😡', '🤔', '🤗', '🤭', '🤫'] },
|
||||
{ label: 'People', items: ['👋', '👏', '🙌', '👍', '👎', '👌', '🙏', '💪', '🤝', '👀', '🧠', '💬', '🗣️', '👤', '👥', '🧑💻', '👩💻', '👨💻'] },
|
||||
{ label: 'Work', items: ['✅', '❌', '⚠️', '❗', '❓', '💡', '🔥', '⭐', '🎯', '🚀', '📌', '📎', '📝', '📋', '📊', '📈', '📅', '⏰', '🔗', '🔒', '💻', '🛠️', '🏗️', '🎨', '🧪', '🎉'] },
|
||||
{ label: 'Objects', items: ['❤️', '💛', '💚', '💙', '💜', '🖤', '✨', '🌟', '☀️', '🌈', '🌱', '🌍', '☕', '🍎', '🎁', '🏆', '💰', '📣', '✏️', '📷', '🎥', '📁', '🗂️', '🗑️'] },
|
||||
]
|
||||
|
||||
const slashItems = [
|
||||
{ label: 'Paragraph', html: '<p>Paragraph</p>' },
|
||||
{ label: 'Heading', html: '<h2>Heading</h2>' },
|
||||
{ label: 'Bullet list', html: '<ul><li>Item</li></ul>' },
|
||||
{ label: 'Table', html: '<table class="w-full border"><tr><th>A</th><th>B</th></tr><tr><td> </td><td> </td></tr></table>' },
|
||||
{ label: 'Info panel', html: '<div class="rounded-lg border border-silos-tan bg-silos-cream/60 p-3 my-2">Info: add a note here.</div>' },
|
||||
{ label: 'Paragraph', description: 'Start with plain text', html: '<p>Paragraph</p>' },
|
||||
{ label: 'Heading', description: 'Add a section heading', html: '<h2>Heading</h2>' },
|
||||
{ label: 'Bullet list', description: 'Create a simple list', html: '<ul><li>List item</li></ul>' },
|
||||
{ label: 'Numbered list', description: 'Create a numbered list', html: '<ol><li>List item</li></ol>' },
|
||||
{ label: 'Table', description: 'Insert a formatted table', html: '<table class="silos-table"><thead><tr><th>Header</th><th>Header</th></tr></thead><tbody><tr><td>Cell</td><td>Cell</td></tr></tbody></table>' },
|
||||
{ label: 'Info panel', description: 'Highlight important information', html: '<div class="silos-panel"><strong>Info</strong><br />Add a note here.</div>' },
|
||||
]
|
||||
|
||||
const starterHtml: Record<string, string> = {
|
||||
|
|
@ -44,13 +62,39 @@ const starterHtml: Record<string, string> = {
|
|||
interface SiloEditorProps {
|
||||
html: string
|
||||
onChange: (html: string) => void
|
||||
editable?: boolean
|
||||
}
|
||||
|
||||
export default function SiloEditor({ html, onChange }: SiloEditorProps) {
|
||||
const editorRef = useRef<HTMLDivElement>(null)
|
||||
const [slashOpen, setSlashOpen] = useState(false)
|
||||
function isSafeUrl(value: string): boolean {
|
||||
return /^(https?:\/\/|mailto:|tel:)/i.test(value.trim())
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replace(/[&<>"']/g, (character) => {
|
||||
const entities: Record<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
}
|
||||
return entities[character]
|
||||
})
|
||||
}
|
||||
|
||||
export default function SiloEditor({ html, onChange, editable = true }: SiloEditorProps) {
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const editorRef = useRef<HTMLDivElement>(null)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const savedRange = useRef<Range | null>(null)
|
||||
const primed = useRef(false)
|
||||
const [popover, setPopover] = useState<Popover>(null)
|
||||
const [mediaType, setMediaType] = useState<MediaType>('link')
|
||||
const [mediaUrl, setMediaUrl] = useState('')
|
||||
const [mediaLabel, setMediaLabel] = useState('')
|
||||
const [emojiSearch, setEmojiSearch] = useState('')
|
||||
const [slashOpen, setSlashOpen] = useState(false)
|
||||
const [activeMarks, setActiveMarks] = useState<Record<string, boolean>>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (!primed.current && editorRef.current) {
|
||||
|
|
@ -59,133 +103,284 @@ export default function SiloEditor({ html, onChange }: SiloEditorProps) {
|
|||
}
|
||||
}, [html])
|
||||
|
||||
useEffect(() => {
|
||||
function closeOnOutside(event: globalThis.MouseEvent) {
|
||||
if (
|
||||
rootRef.current &&
|
||||
!rootRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setPopover(null)
|
||||
setSlashOpen(false)
|
||||
}
|
||||
}
|
||||
function closeOnEscape(event: globalThis.KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
setPopover(null)
|
||||
setSlashOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', closeOnOutside)
|
||||
document.addEventListener('keydown', closeOnEscape)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', closeOnOutside)
|
||||
document.removeEventListener('keydown', closeOnEscape)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
function updateMarks() {
|
||||
if (!editorRef.current || !editorRef.current.contains(document.activeElement)) return
|
||||
setActiveMarks({
|
||||
bold: document.queryCommandState('bold'),
|
||||
italic: document.queryCommandState('italic'),
|
||||
underline: document.queryCommandState('underline'),
|
||||
strikeThrough: document.queryCommandState('strikeThrough'),
|
||||
insertUnorderedList: document.queryCommandState('insertUnorderedList'),
|
||||
insertOrderedList: document.queryCommandState('insertOrderedList'),
|
||||
})
|
||||
}
|
||||
document.addEventListener('selectionchange', updateMarks)
|
||||
return () => document.removeEventListener('selectionchange', updateMarks)
|
||||
}, [])
|
||||
|
||||
function emit() {
|
||||
if (editorRef.current) onChange(editorRef.current.innerHTML)
|
||||
if (editable && editorRef.current) onChange(editorRef.current.innerHTML)
|
||||
}
|
||||
|
||||
function run(cmd: string, value?: string) {
|
||||
document.execCommand(cmd, false, value)
|
||||
function saveSelection() {
|
||||
const selection = window.getSelection()
|
||||
if (selection?.rangeCount && editorRef.current?.contains(selection.anchorNode)) {
|
||||
savedRange.current = selection.getRangeAt(0).cloneRange()
|
||||
}
|
||||
}
|
||||
|
||||
function restoreSelection() {
|
||||
if (!savedRange.current) {
|
||||
editorRef.current?.focus()
|
||||
return
|
||||
}
|
||||
const selection = window.getSelection()
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(savedRange.current)
|
||||
editorRef.current?.focus()
|
||||
}
|
||||
|
||||
function togglePopover(event: MouseEvent<HTMLButtonElement>, next: Popover) {
|
||||
event.preventDefault()
|
||||
saveSelection()
|
||||
setPopover((current) => (current === next ? null : next))
|
||||
setSlashOpen(false)
|
||||
}
|
||||
|
||||
function run(command: string, value?: string) {
|
||||
if (!editable) return
|
||||
restoreSelection()
|
||||
document.execCommand(command, false, value)
|
||||
emit()
|
||||
}
|
||||
|
||||
function insertHtml(snippet: string) {
|
||||
if (!editable) return
|
||||
restoreSelection()
|
||||
document.execCommand('insertHTML', false, snippet)
|
||||
setPopover(null)
|
||||
setSlashOpen(false)
|
||||
emit()
|
||||
}
|
||||
|
||||
function handleInput() {
|
||||
if (!editable) return
|
||||
const text = editorRef.current?.innerText ?? ''
|
||||
if (text.endsWith('/')) setSlashOpen(true)
|
||||
else setSlashOpen(false)
|
||||
setSlashOpen(text.endsWith('/'))
|
||||
emit()
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === 'Escape') setSlashOpen(false)
|
||||
function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
|
||||
if (event.key === 'Escape') {
|
||||
setSlashOpen(false)
|
||||
setPopover(null)
|
||||
}
|
||||
if (event.key === '/' && editable) {
|
||||
window.setTimeout(saveSelection, 0)
|
||||
}
|
||||
}
|
||||
|
||||
function openMedia(next: MediaType, event: MouseEvent<HTMLButtonElement>) {
|
||||
event.preventDefault()
|
||||
saveSelection()
|
||||
setMediaType(next)
|
||||
setMediaUrl('')
|
||||
setMediaLabel('')
|
||||
setPopover(next)
|
||||
}
|
||||
|
||||
function insertMedia() {
|
||||
const url = mediaUrl.trim()
|
||||
if (!isSafeUrl(url)) return
|
||||
const safeUrl = escapeHtml(url)
|
||||
const safeLabel = escapeHtml(mediaLabel.trim())
|
||||
if (mediaType === 'link') {
|
||||
if (mediaLabel.trim()) {
|
||||
insertHtml(
|
||||
`<a href="${safeUrl}" target="_blank" rel="noreferrer">${safeLabel}</a>`,
|
||||
)
|
||||
} else {
|
||||
restoreSelection()
|
||||
document.execCommand('createLink', false, safeUrl)
|
||||
emit()
|
||||
}
|
||||
} else if (mediaType === 'image') {
|
||||
insertHtml(
|
||||
`<figure class="silos-media"><img src="${safeUrl}" alt="${safeLabel || 'Inserted image'}" /><figcaption>${safeLabel}</figcaption></figure>`,
|
||||
)
|
||||
} else {
|
||||
insertHtml(
|
||||
`<div class="silos-media"><a href="${safeUrl}" target="_blank" rel="noreferrer">Open video</a><div class="silos-embed">${safeLabel || safeUrl}</div></div>`,
|
||||
)
|
||||
}
|
||||
setPopover(null)
|
||||
setMediaUrl('')
|
||||
setMediaLabel('')
|
||||
}
|
||||
|
||||
function insertFile(file: File) {
|
||||
if (!file.type.startsWith('image/')) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = () =>
|
||||
insertHtml(
|
||||
`<figure class="silos-media"><img src="${String(reader.result)}" alt="${escapeHtml(file.name)}" /><figcaption>${escapeHtml(file.name)}</figcaption></figure>`,
|
||||
)
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
const filteredEmojis = emojiGroups.map((group) => ({
|
||||
...group,
|
||||
items: emojiSearch
|
||||
? group.items.filter((emoji) => emoji.includes(emojiSearch))
|
||||
: group.items,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[calc(100vh-8rem)] flex-col">
|
||||
<div className="sticky top-0 z-10 flex flex-wrap items-center gap-1 border-b border-silos-cream-dark bg-white py-2">
|
||||
{toolbarGroups.map((group, gi) => (
|
||||
<div key={gi} className="flex items-center gap-0.5">
|
||||
{group.map(({ icon: Icon, label, cmd, value }) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
title={label}
|
||||
onClick={() => run(cmd, value)}
|
||||
className="rounded p-1.5 text-silos-muted transition-colors hover:bg-silos-cream hover:text-silos-text"
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</button>
|
||||
))}
|
||||
{gi < toolbarGroups.length - 1 && <div className="mx-1 h-5 w-px bg-silos-cream-dark" />}
|
||||
</div>
|
||||
))}
|
||||
<div className="mx-1 h-5 w-px bg-silos-cream-dark" />
|
||||
<button
|
||||
type="button"
|
||||
title="Link"
|
||||
onClick={() => {
|
||||
const url = window.prompt('Link URL')
|
||||
if (url) run('createLink', url)
|
||||
<div
|
||||
ref={rootRef}
|
||||
onMouseDown={(event) => {
|
||||
const target = event.target as HTMLElement
|
||||
if (!target.closest('[data-silo-popover]')) {
|
||||
if (target.closest('button, select')) saveSelection()
|
||||
setPopover(null)
|
||||
}
|
||||
}}
|
||||
className="rounded p-1.5 text-silos-muted hover:bg-silos-cream"
|
||||
className="relative flex min-h-[calc(100vh-8rem)] flex-col"
|
||||
>
|
||||
<Link className="h-4 w-4" />
|
||||
</button>
|
||||
<button type="button" title="Image" className="rounded p-1.5 text-silos-muted hover:bg-silos-cream">
|
||||
<Image className="h-4 w-4" />
|
||||
</button>
|
||||
<button type="button" title="Mention" className="rounded p-1.5 text-silos-muted hover:bg-silos-cream">
|
||||
<AtSign className="h-4 w-4" />
|
||||
</button>
|
||||
<button type="button" title="Emoji" className="rounded p-1.5 text-silos-muted hover:bg-silos-cream">
|
||||
<Smile className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="sticky top-0 z-10 flex flex-wrap items-center gap-1 border-b border-silos-cream-dark bg-white py-2">
|
||||
<select
|
||||
disabled={!editable}
|
||||
onMouseDown={saveSelection}
|
||||
defaultValue="paragraph"
|
||||
onChange={(event) => run('formatBlock', event.target.value)}
|
||||
className="h-8 rounded px-2 text-sm text-silos-text outline-none hover:bg-silos-cream disabled:opacity-40"
|
||||
aria-label="Text style"
|
||||
>
|
||||
<option value="paragraph">Normal text</option>
|
||||
<option value="h1">Heading 1</option>
|
||||
<option value="h2">Heading 2</option>
|
||||
<option value="h3">Heading 3</option>
|
||||
<option value="blockquote">Quote</option>
|
||||
<option value="pre">Code block</option>
|
||||
</select>
|
||||
<div className="mx-1 h-5 w-px bg-silos-cream-dark" />
|
||||
{[
|
||||
[Bold, 'Bold', 'bold'],
|
||||
[Italic, 'Italic', 'italic'],
|
||||
[Underline, 'Underline', 'underline'],
|
||||
[Strikethrough, 'Strikethrough', 'strikeThrough'],
|
||||
[List, 'Bullet list', 'insertUnorderedList'],
|
||||
[ListOrdered, 'Numbered list', 'insertOrderedList'],
|
||||
].map(([Icon, label, command]) => {
|
||||
const ToolbarIcon = Icon as typeof Bold
|
||||
return (
|
||||
<button
|
||||
key={String(label)}
|
||||
type="button"
|
||||
title="Table"
|
||||
onClick={() => insertHtml('<table class="w-full border"><tr><th>A</th><th>B</th></tr><tr><td></td><td></td></tr></table>')}
|
||||
className="rounded p-1.5 text-silos-muted hover:bg-silos-cream"
|
||||
title={String(label)}
|
||||
disabled={!editable}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => run(String(command))}
|
||||
className={`rounded p-1.5 text-silos-muted hover:bg-silos-cream hover:text-silos-text disabled:opacity-40 ${activeMarks[String(command)] ? 'bg-blue-100 text-blue-700' : ''}`}
|
||||
>
|
||||
<Table className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Insert"
|
||||
onClick={() => setSlashOpen((v) => !v)}
|
||||
className="rounded p-1.5 text-silos-muted hover:bg-silos-cream"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<ToolbarIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<div className="mx-1 h-5 w-px bg-silos-cream-dark" />
|
||||
{[['Left', AlignLeft, 'justifyLeft'], ['Center', AlignCenter, 'justifyCenter'], ['Right', AlignRight, 'justifyRight']].map(([label, Icon, command]) => {
|
||||
const ToolbarIcon = Icon as typeof AlignLeft
|
||||
return <button key={String(label)} type="button" title={`Align ${String(label).toLowerCase()}`} disabled={!editable} onMouseDown={(event) => event.preventDefault()} onClick={() => run(String(command))} className="rounded p-1.5 text-silos-muted hover:bg-silos-cream disabled:opacity-40"><ToolbarIcon className="h-4 w-4" /></button>
|
||||
})}
|
||||
<button type="button" title="Text and highlight colours" disabled={!editable} onClick={(event) => togglePopover(event, 'color')} className={`rounded p-1.5 text-silos-muted hover:bg-silos-cream disabled:opacity-40 ${popover === 'color' ? 'bg-silos-cream text-silos-brown' : ''}`}><Palette className="h-4 w-4" /></button>
|
||||
<button type="button" title="Link" disabled={!editable} onClick={(event) => openMedia('link', event)} className="rounded p-1.5 text-silos-muted hover:bg-silos-cream disabled:opacity-40"><Link className="h-4 w-4" /></button>
|
||||
<button type="button" title="Image URL" disabled={!editable} onClick={(event) => openMedia('image', event)} className="rounded p-1.5 text-silos-muted hover:bg-silos-cream disabled:opacity-40"><Image className="h-4 w-4" /></button>
|
||||
<button type="button" title="Upload image" disabled={!editable} onMouseDown={(event) => event.preventDefault()} onClick={() => { saveSelection(); fileRef.current?.click() }} className="rounded p-1.5 text-silos-muted hover:bg-silos-cream disabled:opacity-40"><FileUp className="h-4 w-4" /></button>
|
||||
<button type="button" title="Video URL" disabled={!editable} onClick={(event) => openMedia('video', event)} className="rounded p-1.5 text-silos-muted hover:bg-silos-cream disabled:opacity-40"><Video className="h-4 w-4" /></button>
|
||||
<button type="button" title="Mention" disabled={!editable} onClick={() => insertHtml('<span class="silos-mention">@mention</span>')} className="rounded p-1.5 text-silos-muted hover:bg-silos-cream disabled:opacity-40"><AtSign className="h-4 w-4" /></button>
|
||||
<button type="button" title="Emoji" disabled={!editable} onClick={(event) => togglePopover(event, 'emoji')} className={`rounded p-1.5 text-silos-muted hover:bg-silos-cream disabled:opacity-40 ${popover === 'emoji' ? 'bg-silos-cream text-silos-brown' : ''}`}><Smile className="h-4 w-4" /></button>
|
||||
<button type="button" title="Table" disabled={!editable} onClick={() => insertHtml('<table class="silos-table"><thead><tr><th>Header</th><th>Header</th><th>Header</th></tr></thead><tbody><tr><td>Cell</td><td>Cell</td><td>Cell</td></tr><tr><td>Cell</td><td>Cell</td><td>Cell</td></tr></tbody></table>')} className="rounded p-1.5 text-silos-muted hover:bg-silos-cream disabled:opacity-40"><Table className="h-4 w-4" /></button>
|
||||
<button type="button" title="Tabs" disabled={!editable} onClick={() => insertHtml('<div class="silos-tabs"><div class="silos-tab-list"><span class="silos-tab-active">Tab 1</span><span>Tab 2</span><span>+</span></div><div class="silos-tab-content">Tab content</div></div>')} className="rounded p-1.5 text-silos-muted hover:bg-silos-cream disabled:opacity-40"><Columns2 className="h-4 w-4" /></button>
|
||||
<button type="button" title="More insert options" disabled={!editable} onClick={(event) => togglePopover(event, 'insert')} className={`rounded p-1.5 text-silos-muted hover:bg-silos-cream disabled:opacity-40 ${popover === 'insert' ? 'bg-silos-cream text-silos-brown' : ''}`}><Plus className="h-4 w-4" /></button>
|
||||
</div>
|
||||
|
||||
{popover === 'color' && (
|
||||
<div data-silo-popover className="absolute left-24 top-11 z-30 w-72 rounded-xl border border-silos-cream-dark bg-white p-3 shadow-xl">
|
||||
<p className="mb-2 text-xs font-semibold text-silos-text">Text colour</p>
|
||||
<div className="grid grid-cols-9 gap-2">{textColors.map((color) => <button key={color} type="button" title={color} onMouseDown={(event) => event.preventDefault()} onClick={() => { run('foreColor', color); setPopover(null) }} className="h-6 w-6 rounded border ring-1 ring-silos-cream-dark" style={{ backgroundColor: color }}><Check className="mx-auto h-3 w-3 text-white opacity-0" /></button>)}</div>
|
||||
<p className="mb-2 mt-4 text-xs font-semibold text-silos-text">Highlight colour</p>
|
||||
<div className="grid grid-cols-6 gap-2">{highlightColors.map((color) => <button key={color} type="button" title={color === 'transparent' ? 'Remove highlight' : color} onMouseDown={(event) => event.preventDefault()} onClick={() => { run('hiliteColor', color); setPopover(null) }} className="h-7 w-7 rounded border ring-1 ring-silos-cream-dark" style={{ backgroundColor: color === 'transparent' ? 'white' : color }}>{color === 'transparent' && <span className="text-xs text-silos-muted">×</span>}</button>)}</div>
|
||||
<button type="button" onClick={() => { run('removeFormat'); setPopover(null) }} className="mt-3 w-full rounded-lg border border-silos-cream-dark py-1.5 text-xs text-silos-muted hover:bg-silos-cream">Clear formatting</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{popover === 'emoji' && (
|
||||
<div data-silo-popover className="absolute right-12 top-11 z-30 max-h-[28rem] w-80 overflow-y-auto rounded-xl border border-silos-cream-dark bg-white p-3 shadow-xl">
|
||||
<input autoFocus value={emojiSearch} onChange={(event) => setEmojiSearch(event.target.value)} placeholder="Search emojis" className="mb-3 w-full rounded-lg border border-silos-cream-dark px-3 py-2 text-sm outline-none focus:border-silos-tan" />
|
||||
{filteredEmojis.map((group) => group.items.length > 0 && <div key={group.label}><p className="mb-1 mt-2 text-xs font-semibold text-silos-muted">{group.label}</p><div className="grid grid-cols-8 gap-1">{group.items.map((emoji) => <button key={emoji} type="button" onClick={() => insertHtml(emoji)} className="rounded p-1 text-xl hover:bg-silos-cream">{emoji}</button>)}</div></div>)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(popover === 'link' || popover === 'image' || popover === 'video') && (
|
||||
<div data-silo-popover className="absolute right-20 top-11 z-30 w-80 rounded-xl border border-silos-cream-dark bg-white p-4 shadow-xl">
|
||||
<div className="mb-3 flex items-center gap-2"><Code2 className="h-4 w-4 text-silos-muted" /><h3 className="text-sm font-semibold text-silos-text">Insert {mediaType}</h3></div>
|
||||
<label className="mb-1 block text-xs font-medium text-silos-muted">{mediaType === 'link' ? 'URL' : `${mediaType} URL`}</label>
|
||||
<input autoFocus value={mediaUrl} onChange={(event) => setMediaUrl(event.target.value)} placeholder="https://..." className="w-full rounded-lg border border-silos-cream-dark px-3 py-2 text-sm outline-none focus:border-silos-tan" onKeyDown={(event) => event.key === 'Enter' && insertMedia()} />
|
||||
<label className="mb-1 mt-3 block text-xs font-medium text-silos-muted">{mediaType === 'link' ? 'Displayed text (optional)' : 'Caption (optional)'}</label>
|
||||
<input value={mediaLabel} onChange={(event) => setMediaLabel(event.target.value)} placeholder={mediaType === 'link' ? 'Open link' : 'Add a caption'} className="w-full rounded-lg border border-silos-cream-dark px-3 py-2 text-sm outline-none focus:border-silos-tan" onKeyDown={(event) => event.key === 'Enter' && insertMedia()} />
|
||||
{!isSafeUrl(mediaUrl) && mediaUrl.length > 0 && <p className="mt-2 text-xs text-red-600">Enter a valid http(s) URL.</p>}
|
||||
<div className="mt-4 flex justify-end gap-2"><button type="button" onClick={() => setPopover(null)} className="rounded-lg px-3 py-1.5 text-xs text-silos-muted hover:bg-silos-cream">Cancel</button><button type="button" disabled={!isSafeUrl(mediaUrl)} onClick={insertMedia} className="rounded-lg bg-silos-brown px-3 py-1.5 text-xs text-white disabled:opacity-40">Insert</button></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{popover === 'insert' && (
|
||||
<div data-silo-popover className="absolute right-0 top-11 z-30 w-72 rounded-xl border border-silos-cream-dark bg-white p-2 shadow-xl">
|
||||
{slashItems.map((item) => <button key={item.label} type="button" onClick={() => insertHtml(item.html)} className="flex w-full items-start gap-3 rounded-lg px-3 py-2 text-left hover:bg-silos-cream"><span className="mt-0.5 text-sm font-medium text-silos-text">{item.label}</span><span className="text-xs text-silos-muted">{item.description}</span></button>)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={(event) => { const file = event.target.files?.[0]; if (file) insertFile(file); event.target.value = '' }} />
|
||||
<div className="relative flex-1 py-6">
|
||||
<div
|
||||
ref={editorRef}
|
||||
contentEditable
|
||||
contentEditable={editable}
|
||||
suppressContentEditableWarning
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onMouseUp={saveSelection}
|
||||
onKeyUp={saveSelection}
|
||||
data-placeholder="Press / to insert elements, or just start writing..."
|
||||
className="prose min-h-[50vh] w-full max-w-none text-base leading-relaxed text-silos-text outline-none empty:before:text-silos-muted/60 empty:before:content-[attr(data-placeholder)]"
|
||||
/>
|
||||
{slashOpen && (
|
||||
<div className="absolute left-0 top-16 z-20 w-64 rounded-xl border border-silos-cream-dark bg-white p-1 shadow-lg">
|
||||
{slashItems.map((item) => (
|
||||
<button
|
||||
key={item.label}
|
||||
type="button"
|
||||
onClick={() => insertHtml(item.html)}
|
||||
className="w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-silos-cream"
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
{slashOpen && <div data-silo-popover className="absolute left-0 top-16 z-20 w-72 rounded-xl border border-silos-cream-dark bg-white p-1 shadow-lg">{slashItems.map((item) => <button key={item.label} type="button" onClick={() => insertHtml(item.html)} className="flex w-full flex-col rounded-lg px-3 py-2 text-left hover:bg-silos-cream"><span className="text-sm font-medium text-silos-text">{item.label}</span><span className="text-xs text-silos-muted">{item.description}</span></button>)}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="sticky bottom-0 border-t border-silos-cream-dark bg-white py-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.keys(starterHtml).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => insertHtml(starterHtml[t])}
|
||||
className="rounded-full border border-silos-cream-dark bg-silos-cream/40 px-3 py-1.5 text-xs text-silos-text hover:border-silos-tan"
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
<button type="button" className="rounded-full border border-silos-cream-dark px-3 py-1.5 text-xs text-silos-muted">
|
||||
All templates
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">{Object.keys(starterHtml).map((template) => <button key={template} type="button" disabled={!editable} onClick={() => insertHtml(starterHtml[template])} className="rounded-full border border-silos-cream-dark bg-silos-cream/40 px-3 py-1.5 text-xs text-silos-text hover:border-silos-tan disabled:opacity-40">{template}</button>)}<button type="button" disabled={!editable} onClick={() => setPopover('insert')} className="rounded-full border border-silos-cream-dark px-3 py-1.5 text-xs text-silos-muted hover:bg-silos-cream disabled:opacity-40">All blocks</button></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ interface SketchCanvasProps {
|
|||
elements: SketchElement[]
|
||||
viewport?: SketchViewport
|
||||
onChange: (elements: SketchElement[], viewport?: SketchViewport) => void
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
type DragMode =
|
||||
|
|
@ -69,7 +70,7 @@ function normalizeRect(x1: number, y1: number, x2: number, y2: number) {
|
|||
}
|
||||
}
|
||||
|
||||
export default function SketchCanvas({ elements, viewport, onChange }: SketchCanvasProps) {
|
||||
export default function SketchCanvas({ elements, viewport, onChange, readOnly = false }: SketchCanvasProps) {
|
||||
const [activeTool, setActiveTool] = useState<SketchTool>('pen')
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [pan, setPan] = useState({ x: viewport?.panX ?? 0, y: viewport?.panY ?? 0 })
|
||||
|
|
@ -113,6 +114,7 @@ export default function SketchCanvas({ elements, viewport, onChange }: SketchCan
|
|||
)
|
||||
|
||||
const undo = () => {
|
||||
if (readOnly) return
|
||||
if (history.length === 0) return
|
||||
const prev = history[history.length - 1]
|
||||
setFuture((f) => [{ elements, pan, zoom }, ...f])
|
||||
|
|
@ -124,6 +126,7 @@ export default function SketchCanvas({ elements, viewport, onChange }: SketchCan
|
|||
}
|
||||
|
||||
const redo = () => {
|
||||
if (readOnly) return
|
||||
if (future.length === 0) return
|
||||
const next = future[0]
|
||||
setHistory((h) => [...h, { elements, pan, zoom }])
|
||||
|
|
@ -135,6 +138,7 @@ export default function SketchCanvas({ elements, viewport, onChange }: SketchCan
|
|||
|
||||
useEffect(() => {
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (readOnly) return
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
if (editingId) return
|
||||
if (selectedId) {
|
||||
|
|
@ -160,7 +164,7 @@ export default function SketchCanvas({ elements, viewport, onChange }: SketchCan
|
|||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [selectedId, editingId, elements, pan, zoom, onChange, pushHistory])
|
||||
}, [selectedId, editingId, elements, pan, zoom, onChange, pushHistory, readOnly])
|
||||
|
||||
function findElementAt(point: Point): SketchElement | null {
|
||||
for (let i = elements.length - 1; i >= 0; i--) {
|
||||
|
|
@ -178,6 +182,8 @@ export default function SketchCanvas({ elements, viewport, onChange }: SketchCan
|
|||
return
|
||||
}
|
||||
|
||||
if (readOnly) return
|
||||
|
||||
if (activeTool === 'select') {
|
||||
const hit = findElementAt(point)
|
||||
if (hit) {
|
||||
|
|
@ -668,6 +674,7 @@ export default function SketchCanvas({ elements, viewport, onChange }: SketchCan
|
|||
}
|
||||
}}
|
||||
onDoubleClick={(e) => {
|
||||
if (readOnly) return
|
||||
e.stopPropagation()
|
||||
setEditingId(el.id)
|
||||
setActiveTool('select')
|
||||
|
|
@ -700,6 +707,7 @@ export default function SketchCanvas({ elements, viewport, onChange }: SketchCan
|
|||
}
|
||||
}}
|
||||
onDoubleClick={(e) => {
|
||||
if (readOnly) return
|
||||
e.stopPropagation()
|
||||
setEditingId(el.id)
|
||||
setActiveTool('select')
|
||||
|
|
@ -750,6 +758,7 @@ export default function SketchCanvas({ elements, viewport, onChange }: SketchCan
|
|||
>
|
||||
<input
|
||||
value={cell}
|
||||
readOnly={readOnly}
|
||||
onChange={(e) => {
|
||||
const cells = el.cells.map((r) => [...r])
|
||||
cells[ri][ci] = e.target.value
|
||||
|
|
@ -806,6 +815,7 @@ export default function SketchCanvas({ elements, viewport, onChange }: SketchCan
|
|||
setActiveTool(id)
|
||||
setShowMore(false)
|
||||
}}
|
||||
disabled={readOnly && id !== 'select' && id !== 'hand'}
|
||||
className={`rounded-lg p-2 transition-colors ${
|
||||
activeTool === id
|
||||
? 'bg-silos-brown/10 text-silos-brown'
|
||||
|
|
@ -824,6 +834,7 @@ export default function SketchCanvas({ elements, viewport, onChange }: SketchCan
|
|||
setActiveTool('rect')
|
||||
setShowMore(false)
|
||||
}}
|
||||
disabled={readOnly}
|
||||
className={`rounded-lg p-2 transition-colors ${
|
||||
activeTool === 'rect'
|
||||
? 'bg-silos-brown/10 text-silos-brown'
|
||||
|
|
|
|||
123
frontend/src/components/workspace/CommentsPanel.tsx
Normal file
123
frontend/src/components/workspace/CommentsPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Menu, Share2, Link2, MoreHorizontal, Users, Search } from 'lucide-react'
|
||||
import { Menu, Share2, Link2, MoreHorizontal, Users, Search, Copy, Archive, Trash2, Plus, X, Bell } from 'lucide-react'
|
||||
import { logos } from '../../data/brandAssets'
|
||||
import { getStoredUser } from '../../lib/api'
|
||||
import { documentsApi, getStoredUser, type ApiNotification } from '../../lib/api'
|
||||
import { getDocument, updateDocument, type DocumentRecord } from '../../lib/documents'
|
||||
import CreateDropdown from '../create/CreateDropdown'
|
||||
import WorkspaceSidebar from './WorkspaceSidebar'
|
||||
|
|
@ -18,7 +18,13 @@ interface WorkspaceShellProps {
|
|||
layout?: WorkspaceLayout
|
||||
headerActions?: React.ReactNode
|
||||
hideTitle?: boolean
|
||||
titleReadOnly?: boolean
|
||||
onTitleChange?: (title: string) => void
|
||||
presence?: { name: string; initial: string }[]
|
||||
onCreateChild?: () => void
|
||||
onDuplicate?: () => void
|
||||
onArchive?: () => void
|
||||
onDelete?: () => void
|
||||
}
|
||||
|
||||
export default function WorkspaceShell({
|
||||
|
|
@ -28,13 +34,26 @@ export default function WorkspaceShell({
|
|||
layout = 'default',
|
||||
headerActions,
|
||||
hideTitle = false,
|
||||
titleReadOnly = false,
|
||||
onTitleChange,
|
||||
presence = [],
|
||||
onCreateChild,
|
||||
onDuplicate,
|
||||
onArchive,
|
||||
onDelete,
|
||||
}: WorkspaceShellProps) {
|
||||
const user = getStoredUser()
|
||||
const [shareOpen, setShareOpen] = useState(false)
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [parentStead, setParentStead] = useState<DocumentRecord | null>(null)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [notifications, setNotifications] = useState<ApiNotification[]>([])
|
||||
const [notificationsOpen, setNotificationsOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
void documentsApi.listNotifications().then(setNotifications).catch(() => setNotifications([]))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!doc.steadId) {
|
||||
|
|
@ -59,6 +78,10 @@ export default function WorkspaceShell({
|
|||
}, [])
|
||||
|
||||
function handleTitleChange(title: string) {
|
||||
if (onTitleChange) {
|
||||
onTitleChange(title)
|
||||
return
|
||||
}
|
||||
void updateDocument(doc.id, { title }).then((updated) => {
|
||||
if (updated) onDocChange(updated)
|
||||
})
|
||||
|
|
@ -106,7 +129,10 @@ export default function WorkspaceShell({
|
|||
>
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<CreateDropdown steadId={doc.type === 'stead' ? doc.id : doc.steadId} />
|
||||
<CreateDropdown
|
||||
steadId={doc.type === 'stead' ? doc.id : doc.steadId}
|
||||
parentPageId={doc.type === 'silo' ? doc.id : undefined}
|
||||
/>
|
||||
<div className="flex items-center gap-1 border-l border-silos-cream-dark pl-2">
|
||||
<span className="hidden text-xs text-green-600 sm:inline">Saved</span>
|
||||
{headerActions}
|
||||
|
|
@ -142,9 +168,29 @@ export default function WorkspaceShell({
|
|||
>
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button className="rounded-lg p-1.5 text-silos-muted hover:bg-silos-cream" aria-label="More">
|
||||
<div className="relative">
|
||||
<button type="button" onClick={() => { setNotificationsOpen((value) => !value); if (!notificationsOpen) void documentsApi.markNotificationsRead().then(() => setNotifications((items) => items.map((item) => ({ ...item, read: true })))) }} className="relative rounded-lg p-1.5 text-silos-muted hover:bg-silos-cream" aria-label="Notifications">
|
||||
<Bell className="h-3.5 w-3.5" />
|
||||
{notifications.some((item) => !item.read) && <span className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-red-500" />}
|
||||
</button>
|
||||
{notificationsOpen && <div className="absolute right-0 top-9 z-50 w-80 rounded-xl border border-silos-cream-dark bg-white p-2 shadow-xl">
|
||||
<div className="flex items-center justify-between px-2 py-1"><p className="text-xs font-semibold text-silos-text">Notifications</p><button type="button" onClick={() => setNotificationsOpen(false)} aria-label="Close notifications"><X className="h-3.5 w-3.5 text-silos-muted" /></button></div>
|
||||
{notifications.length === 0 ? <p className="px-2 py-4 text-xs text-silos-muted">You’re all caught up.</p> : notifications.slice(0, 8).map((notification) => <div key={notification.id} className="border-t border-silos-cream px-2 py-2 text-xs text-silos-text"><p>{notification.message}</p><time className="text-[10px] text-silos-muted">{new Date(notification.created_at).toLocaleString()}</time></div>)}
|
||||
</div>}
|
||||
</div>
|
||||
<button onClick={() => setMenuOpen((value) => !value)} className="rounded-lg p-1.5 text-silos-muted hover:bg-silos-cream" aria-label="More">
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div className="absolute right-3 top-10 z-50 w-56 rounded-xl border border-silos-cream-dark bg-white p-1.5 shadow-xl">
|
||||
<p className="px-2.5 py-2 text-[10px] font-semibold uppercase tracking-wide text-silos-muted">Page actions</p>
|
||||
{onCreateChild && <button onClick={() => { setMenuOpen(false); onCreateChild() }} className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm text-silos-text hover:bg-silos-cream"><Plus className="h-3.5 w-3.5" />Create child page</button>}
|
||||
{onDuplicate && <button onClick={() => { setMenuOpen(false); onDuplicate() }} className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm text-silos-text hover:bg-silos-cream"><Copy className="h-3.5 w-3.5" />Duplicate page</button>}
|
||||
{onArchive && <button onClick={() => { setMenuOpen(false); onArchive() }} className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm text-silos-text hover:bg-silos-cream"><Archive className="h-3.5 w-3.5" />Archive page</button>}
|
||||
{onDelete && <button onClick={() => { setMenuOpen(false); onDelete() }} className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm text-red-700 hover:bg-red-50"><Trash2 className="h-3.5 w-3.5" />Delete permanently</button>}
|
||||
<button onClick={() => setMenuOpen(false)} className="mt-1 flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm text-silos-muted hover:bg-silos-cream"><X className="h-3.5 w-3.5" />Close menu</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
|
@ -167,9 +213,11 @@ export default function WorkspaceShell({
|
|||
<>
|
||||
<input
|
||||
value={doc.title}
|
||||
readOnly={titleReadOnly}
|
||||
onChange={(e) => handleTitleChange(e.target.value)}
|
||||
className={`w-full border-none bg-transparent font-bold text-silos-text outline-none ${
|
||||
isPage ? 'text-3xl' : 'text-2xl'
|
||||
titleReadOnly ? 'cursor-default' : ''
|
||||
} ${isPage ? 'text-3xl' : 'text-2xl'
|
||||
}`}
|
||||
placeholder={isPage ? 'Give this page a title' : 'Start with a title...'}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,32 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { FileText, Zap, PenTool, LayoutGrid, Plus } from 'lucide-react'
|
||||
import { listDocuments, listDocumentsByStead, type DocumentRecord } from '../../lib/documents'
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Archive,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
FileText,
|
||||
LayoutGrid,
|
||||
Link2,
|
||||
MoreHorizontal,
|
||||
PenTool,
|
||||
Plus,
|
||||
Star,
|
||||
Trash2,
|
||||
X,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
createDocument,
|
||||
deleteDocument,
|
||||
listDocuments,
|
||||
listDocumentsByStead,
|
||||
toggleStar,
|
||||
updateDocument,
|
||||
type DocumentRecord,
|
||||
} from '../../lib/documents'
|
||||
import { getStoredUser } from '../../lib/api'
|
||||
import type { CreateType } from '../../data/createData'
|
||||
|
||||
const typeIcons: Record<CreateType, typeof FileText> = {
|
||||
|
|
@ -27,85 +52,181 @@ interface WorkspaceSidebarProps {
|
|||
export default function WorkspaceSidebar({ collapsed, activeDocId, steadId }: WorkspaceSidebarProps) {
|
||||
const [docs, setDocs] = useState<DocumentRecord[]>([])
|
||||
const [steads, setSteads] = useState<DocumentRecord[]>([])
|
||||
const [creatingChildFor, setCreatingChildFor] = useState<string | null>(null)
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({})
|
||||
const [menuId, setMenuId] = useState<string | null>(null)
|
||||
const [renameId, setRenameId] = useState<string | null>(null)
|
||||
const [renameValue, setRenameValue] = useState('')
|
||||
const [moveId, setMoveId] = useState<string | null>(null)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
if (steadId) {
|
||||
void Promise.all([listDocumentsByStead(steadId), listDocuments('stead')]).then(
|
||||
const load = steadId
|
||||
? Promise.all([listDocumentsByStead(steadId), listDocuments('stead')]).then(
|
||||
([siblings, spaces]) => {
|
||||
setDocs(siblings)
|
||||
setSteads(spaces)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
void listDocuments().then((all) => {
|
||||
setDocs(all.filter((d) => d.type !== 'stead'))
|
||||
setSteads(all.filter((d) => d.type === 'stead'))
|
||||
: listDocuments().then((all) => {
|
||||
setDocs(all.filter((doc) => doc.type !== 'stead'))
|
||||
setSteads(all.filter((doc) => doc.type === 'stead'))
|
||||
})
|
||||
}
|
||||
void load
|
||||
}, [activeDocId, steadId])
|
||||
|
||||
const parent = steads.find((s) => s.id === steadId)
|
||||
useEffect(() => {
|
||||
function closeMenu(event: MouseEvent) {
|
||||
if (!(event.target as HTMLElement).closest('[data-page-menu]')) setMenuId(null)
|
||||
}
|
||||
document.addEventListener('mousedown', closeMenu)
|
||||
return () => document.removeEventListener('mousedown', closeMenu)
|
||||
}, [])
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<aside className="flex w-12 shrink-0 flex-col items-center border-r border-silos-cream-dark bg-white py-3">
|
||||
{docs.slice(0, 6).map((doc) => {
|
||||
const Icon = typeIcons[doc.type]
|
||||
return (
|
||||
<Link
|
||||
key={doc.id}
|
||||
to={`/workspace/${doc.type}/${doc.id}`}
|
||||
title={doc.title}
|
||||
className={`mb-1 rounded-lg p-2 ${
|
||||
doc.id === activeDocId ? 'bg-silos-brown/10' : 'hover:bg-silos-cream'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" style={{ color: typeColors[doc.type] }} />
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</aside>
|
||||
)
|
||||
const parent = steads.find((space) => space.id === steadId)
|
||||
const roots = docs.filter((doc) => !doc.parentPageId)
|
||||
const childrenByParent = docs.reduce<Record<string, DocumentRecord[]>>((groups, doc) => {
|
||||
if (doc.parentPageId) groups[doc.parentPageId] = [...(groups[doc.parentPageId] ?? []), doc]
|
||||
return groups
|
||||
}, {})
|
||||
|
||||
function descendantsOf(id: string): Set<string> {
|
||||
const descendants = new Set<string>()
|
||||
const visit = (parentId: string) => {
|
||||
for (const child of childrenByParent[parentId] ?? []) {
|
||||
descendants.add(child.id)
|
||||
visit(child.id)
|
||||
}
|
||||
}
|
||||
visit(id)
|
||||
return descendants
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="flex w-56 shrink-0 flex-col border-r border-silos-cream-dark bg-white">
|
||||
<div className="border-b border-silos-cream-dark p-3">
|
||||
<Link to="/dashboard" className="text-xs font-medium text-silos-muted hover:text-silos-brown">
|
||||
← Back to Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
async function createChildPage(doc: DocumentRecord) {
|
||||
setCreatingChildFor(doc.id)
|
||||
try {
|
||||
const child = await createDocument(
|
||||
'silo',
|
||||
`Child of ${doc.title}`,
|
||||
{ body: '', bodyHtml: '', status: 'draft' },
|
||||
undefined,
|
||||
getStoredUser()?.full_name ?? 'You',
|
||||
doc.steadId,
|
||||
doc.id,
|
||||
)
|
||||
navigate(`/workspace/silo/${child.id}`)
|
||||
} finally {
|
||||
setCreatingChildFor(null)
|
||||
}
|
||||
}
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{parent && (
|
||||
<>
|
||||
<Link
|
||||
to={`/workspace/stead/${parent.id}`}
|
||||
className="mb-2 flex items-center gap-2 rounded-lg px-2 py-2 text-sm font-medium text-silos-text hover:bg-silos-cream"
|
||||
>
|
||||
<LayoutGrid className="h-3.5 w-3.5 text-silos-tan" />
|
||||
<span className="truncate">{parent.title}</span>
|
||||
</Link>
|
||||
<p className="px-2 py-1.5 text-xs font-semibold uppercase tracking-wide text-silos-muted">
|
||||
In this space
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{!parent && (
|
||||
<p className="px-2 py-1.5 text-xs font-semibold uppercase tracking-wide text-silos-muted">
|
||||
Content
|
||||
</p>
|
||||
)}
|
||||
{docs.length === 0 ? (
|
||||
<p className="px-2 py-4 text-xs text-silos-muted">No documents yet</p>
|
||||
) : (
|
||||
docs.map((doc) => {
|
||||
function startRename(doc: DocumentRecord) {
|
||||
setMenuId(null)
|
||||
setRenameId(doc.id)
|
||||
setRenameValue(doc.title)
|
||||
}
|
||||
|
||||
async function saveRename(doc: DocumentRecord) {
|
||||
const title = renameValue.trim()
|
||||
setRenameId(null)
|
||||
if (!title || title === doc.title) return
|
||||
const updated = await updateDocument(doc.id, { title })
|
||||
if (updated) setDocs((current) => current.map((item) => (item.id === doc.id ? updated : item)))
|
||||
}
|
||||
|
||||
async function duplicatePage(doc: DocumentRecord) {
|
||||
setMenuId(null)
|
||||
const duplicate = await createDocument(
|
||||
doc.type,
|
||||
`${doc.title} copy`,
|
||||
{ ...doc.content, status: 'draft' },
|
||||
doc.templateId,
|
||||
getStoredUser()?.full_name ?? 'You',
|
||||
doc.steadId,
|
||||
doc.parentPageId,
|
||||
)
|
||||
navigate(`/workspace/${doc.type}/${duplicate.id}`)
|
||||
}
|
||||
|
||||
async function archivePage(doc: DocumentRecord) {
|
||||
setMenuId(null)
|
||||
const updated = await updateDocument(doc.id, {
|
||||
content: { ...doc.content, archived: true, archivedAt: new Date().toISOString() },
|
||||
})
|
||||
if (updated) setDocs((current) => current.filter((item) => item.id !== doc.id))
|
||||
}
|
||||
|
||||
async function removePage(doc: DocumentRecord) {
|
||||
setMenuId(null)
|
||||
if (!window.confirm(`Delete "${doc.title}" permanently?`)) return
|
||||
if (await deleteDocument(doc.id)) {
|
||||
setDocs((current) => current.filter((item) => item.id !== doc.id))
|
||||
if (doc.id === activeDocId) navigate('/dashboard')
|
||||
}
|
||||
}
|
||||
|
||||
async function movePage(doc: DocumentRecord, parentPageId?: string) {
|
||||
const updated = await updateDocument(doc.id, { parentPageId })
|
||||
if (updated) {
|
||||
setDocs((current) => current.map((item) => (item.id === doc.id ? updated : item)))
|
||||
setMoveId(null)
|
||||
setMenuId(null)
|
||||
}
|
||||
}
|
||||
|
||||
function renderPage(doc: DocumentRecord, depth = 0): ReactNode {
|
||||
const Icon = typeIcons[doc.type]
|
||||
const children = childrenByParent[doc.id] ?? []
|
||||
const isExpanded = expanded[doc.id] ?? doc.id === activeDocId
|
||||
const isRenaming = renameId === doc.id
|
||||
const isMenuOpen = menuId === doc.id
|
||||
|
||||
return (
|
||||
<div key={doc.id}>
|
||||
<div
|
||||
className="group relative flex items-center rounded-md"
|
||||
style={{ paddingLeft: `${depth * 14}px` }}
|
||||
data-page-menu
|
||||
>
|
||||
{children.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((current) => ({ ...current, [doc.id]: !isExpanded }))}
|
||||
className="rounded p-1 text-silos-muted hover:bg-silos-cream"
|
||||
aria-label={isExpanded ? `Collapse ${doc.title}` : `Expand ${doc.title}`}
|
||||
>
|
||||
{isExpanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
) : (
|
||||
<span className="flex w-5 justify-center text-[16px] leading-none text-silos-muted" aria-hidden="true">
|
||||
•
|
||||
</span>
|
||||
)}
|
||||
|
||||
{isRenaming ? (
|
||||
<form
|
||||
className="flex min-w-0 flex-1 items-center gap-1 px-1 py-1"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void saveRename(doc)
|
||||
}}
|
||||
>
|
||||
<input
|
||||
autoFocus
|
||||
value={renameValue}
|
||||
onChange={(event) => setRenameValue(event.target.value)}
|
||||
onBlur={() => void saveRename(doc)}
|
||||
className="min-w-0 flex-1 rounded border border-silos-brown/40 bg-white px-2 py-1 text-sm text-silos-text outline-none ring-2 ring-silos-brown/10"
|
||||
aria-label={`Rename ${doc.title}`}
|
||||
/>
|
||||
<button type="submit" className="rounded p-1 text-green-700 hover:bg-green-50" aria-label="Save name">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<Link
|
||||
key={doc.id}
|
||||
to={`/workspace/${doc.type}/${doc.id}`}
|
||||
className={`mb-0.5 flex items-center gap-2 rounded-lg px-2 py-2 text-sm transition-colors ${
|
||||
className={`flex min-w-0 flex-1 items-center gap-2 rounded-lg px-2 py-2 text-sm transition-colors ${
|
||||
doc.id === activeDocId
|
||||
? 'bg-silos-brown/10 font-medium text-silos-brown'
|
||||
: 'text-silos-text hover:bg-silos-cream'
|
||||
|
|
@ -114,20 +235,98 @@ export default function WorkspaceSidebar({ collapsed, activeDocId, steadId }: Wo
|
|||
<Icon className="h-3.5 w-3.5 shrink-0" style={{ color: typeColors[doc.type] }} />
|
||||
<span className="truncate">{doc.title}</span>
|
||||
</Link>
|
||||
)
|
||||
})
|
||||
)}
|
||||
|
||||
{doc.type === 'silo' && !isRenaming && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void createChildPage(doc)
|
||||
}}
|
||||
disabled={creatingChildFor === doc.id}
|
||||
className="rounded p-1 text-silos-muted opacity-0 transition-opacity hover:bg-silos-cream hover:text-silos-brown group-hover:opacity-100 disabled:opacity-50"
|
||||
aria-label={`Create child page under ${doc.title}`}
|
||||
title="Create child page"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{!isRenaming && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setMenuId(isMenuOpen ? null : doc.id)
|
||||
}}
|
||||
className="mr-1 rounded p-1 text-silos-muted opacity-0 transition-opacity hover:bg-silos-cream hover:text-silos-text group-hover:opacity-100"
|
||||
aria-label={`More options for ${doc.title}`}
|
||||
title="More options"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isMenuOpen && (
|
||||
<div className="absolute right-1 top-full z-50 mt-1 w-56 rounded-xl border border-silos-cream-dark bg-white p-1.5 shadow-xl">
|
||||
<button onClick={() => navigate(`/workspace/${doc.type}/${doc.id}`)} className="page-menu-item"><FileText className="h-4 w-4" />Edit</button>
|
||||
<button onClick={() => startRename(doc)} className="page-menu-item"><FileText className="h-4 w-4" />Rename</button>
|
||||
<button onClick={async () => { setMenuId(null); const updated = await toggleStar(doc.id, !doc.starred); if (updated) setDocs((current) => current.map((item) => item.id === doc.id ? updated : item)) }} className="page-menu-item"><Star className="h-4 w-4" />{doc.starred ? 'Unstar' : 'Star'}</button>
|
||||
<button onClick={() => { setMenuId(null); void navigator.clipboard.writeText(`${window.location.origin}/workspace/${doc.type}/${doc.id}`) }} className="page-menu-item"><Link2 className="h-4 w-4" />Copy link</button>
|
||||
<button onClick={() => void duplicatePage(doc)} className="page-menu-item"><Copy className="h-4 w-4" />Make a copy</button>
|
||||
<button onClick={() => { setMenuId(null); setMoveId(doc.id) }} className="page-menu-item"><ChevronRight className="h-4 w-4" />Move</button>
|
||||
<div className="my-1 border-t border-silos-cream-dark" />
|
||||
<button onClick={() => void archivePage(doc)} className="page-menu-item"><Archive className="h-4 w-4" />Archive</button>
|
||||
<button onClick={() => void removePage(doc)} className="page-menu-item text-red-700 hover:bg-red-50"><Trash2 className="h-4 w-4" />Delete</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-silos-cream-dark p-2">
|
||||
<Link
|
||||
to="/dashboard"
|
||||
className="flex items-center gap-2 rounded-lg px-2 py-2 text-sm text-silos-muted hover:bg-silos-cream hover:text-silos-brown"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create new
|
||||
</Link>
|
||||
{isExpanded && children.map((child) => renderPage(child, depth + 1))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<aside className="flex w-12 shrink-0 flex-col items-center border-r border-silos-cream-dark bg-white py-3">
|
||||
{docs.slice(0, 6).map((doc) => {
|
||||
const Icon = typeIcons[doc.type]
|
||||
return <Link key={doc.id} to={`/workspace/${doc.type}/${doc.id}`} title={doc.title} className={`mb-1 rounded-lg p-2 ${doc.id === activeDocId ? 'bg-silos-brown/10' : 'hover:bg-silos-cream'}`}><Icon className="h-4 w-4" style={{ color: typeColors[doc.type] }} /></Link>
|
||||
})}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
const movingDoc = moveId ? docs.find((doc) => doc.id === moveId) : undefined
|
||||
const excludedIds = movingDoc ? descendantsOf(movingDoc.id) : new Set<string>()
|
||||
|
||||
return (
|
||||
<aside className="flex w-64 shrink-0 flex-col border-r border-silos-cream-dark bg-white">
|
||||
<div className="border-b border-silos-cream-dark p-3">
|
||||
<Link to="/dashboard" className="text-xs font-medium text-silos-muted hover:text-silos-brown">← Back to Dashboard</Link>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{parent && <Link to={`/workspace/stead/${parent.id}`} className="mb-2 flex items-center gap-2 rounded-lg px-2 py-2 text-sm font-medium text-silos-text hover:bg-silos-cream"><LayoutGrid className="h-3.5 w-3.5 text-silos-tan" /><span className="truncate">{parent.title}</span></Link>}
|
||||
<p className="px-2 py-1.5 text-xs font-semibold uppercase tracking-wide text-silos-muted">{parent ? 'In this space' : 'Content'}</p>
|
||||
{docs.length === 0 ? <p className="px-2 py-4 text-xs text-silos-muted">No documents yet</p> : roots.map((doc) => renderPage(doc))}
|
||||
</div>
|
||||
<div className="border-t border-silos-cream-dark p-2">
|
||||
<Link to="/dashboard" className="flex items-center gap-2 rounded-lg px-2 py-2 text-sm text-silos-muted hover:bg-silos-cream hover:text-silos-brown"><Plus className="h-4 w-4" />Create new</Link>
|
||||
</div>
|
||||
{movingDoc && (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/20 p-4" role="dialog" aria-modal="true" aria-label="Move page">
|
||||
<div className="w-full max-w-md rounded-xl border border-silos-cream-dark bg-white p-4 shadow-2xl">
|
||||
<div className="mb-3 flex items-center justify-between"><h2 className="font-semibold text-silos-text">Move “{movingDoc.title}”</h2><button onClick={() => setMoveId(null)} className="rounded p-1 hover:bg-silos-cream" aria-label="Close"><X className="h-4 w-4" /></button></div>
|
||||
<p className="mb-3 text-xs text-silos-muted">Choose a parent page or move it to the top level.</p>
|
||||
<div className="max-h-64 space-y-1 overflow-y-auto">
|
||||
<button onClick={() => void movePage(movingDoc)} className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm hover:bg-silos-cream"><span>•</span>Top level</button>
|
||||
{docs.filter((doc) => doc.id !== movingDoc.id && doc.type === 'silo' && !excludedIds.has(doc.id)).map((doc) => <button key={doc.id} onClick={() => void movePage(movingDoc, doc.id)} className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm hover:bg-silos-cream"><FileText className="h-4 w-4 text-silos-muted" />{doc.title}</button>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,45 @@ export interface ShedTemplate {
|
|||
available: boolean
|
||||
}
|
||||
|
||||
export interface SiloTemplate {
|
||||
id: string
|
||||
label: string
|
||||
description: string
|
||||
emoji: string
|
||||
html: string
|
||||
}
|
||||
|
||||
export const siloTemplates: SiloTemplate[] = [
|
||||
{
|
||||
id: 'blank',
|
||||
label: 'Blank page',
|
||||
description: 'Start with a clean page',
|
||||
emoji: '📄',
|
||||
html: '',
|
||||
},
|
||||
{
|
||||
id: 'meeting',
|
||||
label: 'Meeting notes',
|
||||
description: 'Capture attendees, agenda, and actions',
|
||||
emoji: '📝',
|
||||
html: '<h2>Meeting notes</h2><p><strong>Date:</strong></p><p><strong>Attendees:</strong></p><h3>Agenda</h3><ul><li>Topic</li></ul><h3>Actions</h3><ul><li>Action — owner — due date</li></ul>',
|
||||
},
|
||||
{
|
||||
id: 'project-plan',
|
||||
label: 'Project plan',
|
||||
description: 'Organize goals, milestones, and next steps',
|
||||
emoji: '🗺️',
|
||||
html: '<h2>Project plan</h2><h3>Goals</h3><ul><li>Goal 1</li></ul><h3>Milestones</h3><ol><li>Milestone 1</li></ol><h3>Next steps</h3><p>Describe the next step.</p>',
|
||||
},
|
||||
{
|
||||
id: 'how-to',
|
||||
label: 'How-to guide',
|
||||
description: 'Create a clear step-by-step guide',
|
||||
emoji: '🧭',
|
||||
html: '<h2>How to…</h2><p>Describe the outcome this guide helps someone achieve.</p><h3>Before you start</h3><ul><li>Requirement</li></ul><h3>Steps</h3><ol><li>Step one</li><li>Step two</li></ol>',
|
||||
},
|
||||
]
|
||||
|
||||
export const createOptions: CreateOption[] = [
|
||||
{
|
||||
id: 'shed',
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { useEffect, useRef } from 'react'
|
||||
import { updateDocument, type DocumentRecord } from '../lib/documents'
|
||||
|
||||
export function useAutoSave(doc: DocumentRecord | null) {
|
||||
export function useAutoSave(doc: DocumentRecord | null, enabled = true) {
|
||||
const isFirstRender = useRef(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!doc) return
|
||||
if (!doc || !enabled) return
|
||||
if (isFirstRender.current) {
|
||||
isFirstRender.current = false
|
||||
return
|
||||
|
|
@ -16,5 +16,5 @@ export function useAutoSave(doc: DocumentRecord | null) {
|
|||
}, 800)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [doc?.id, doc?.title, doc?.content])
|
||||
}, [doc?.id, doc?.title, doc?.content, enabled])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,22 @@ body {
|
|||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.page-menu-item {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem 0.625rem;
|
||||
text-align: left;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-silos-text);
|
||||
}
|
||||
|
||||
.page-menu-item:hover {
|
||||
background: var(--color-silos-cream);
|
||||
}
|
||||
|
||||
@keyframes workflow-pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.08); opacity: 0.85; }
|
||||
|
|
@ -48,3 +64,97 @@ body {
|
|||
stroke-dasharray: 8 4;
|
||||
animation: workflow-flow 1.5s linear infinite;
|
||||
}
|
||||
|
||||
.prose .silos-table {
|
||||
width: 100%;
|
||||
margin: 1rem 0;
|
||||
border-collapse: collapse;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-silos-cream-dark);
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.prose .silos-table th,
|
||||
.prose .silos-table td {
|
||||
min-width: 8rem;
|
||||
border: 1px solid var(--color-silos-cream-dark);
|
||||
padding: 0.65rem 0.75rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.prose .silos-table th {
|
||||
background: var(--color-silos-cream);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prose .silos-panel {
|
||||
margin: 1rem 0;
|
||||
border-left: 4px solid var(--color-silos-tan);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--color-silos-cream);
|
||||
padding: 0.85rem 1rem;
|
||||
}
|
||||
|
||||
.prose .silos-media {
|
||||
margin: 1rem 0;
|
||||
border-radius: 0.75rem;
|
||||
background: var(--color-silos-cream);
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.prose .silos-media img {
|
||||
max-height: 32rem;
|
||||
max-width: 100%;
|
||||
border-radius: 0.5rem;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.prose .silos-media figcaption {
|
||||
margin-top: 0.45rem;
|
||||
color: var(--color-silos-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.prose .silos-embed {
|
||||
margin-top: 0.65rem;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--color-silos-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.prose .silos-mention {
|
||||
border-radius: 0.25rem;
|
||||
background: #deebff;
|
||||
color: #0747a6;
|
||||
padding: 0.1rem 0.3rem;
|
||||
}
|
||||
|
||||
.prose .silos-tabs {
|
||||
margin: 1rem 0;
|
||||
border: 1px solid var(--color-silos-cream-dark);
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.prose .silos-tab-list {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
border-bottom: 1px solid var(--color-silos-cream-dark);
|
||||
padding-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.prose .silos-tab-list span {
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.3rem 0.65rem;
|
||||
color: var(--color-silos-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.prose .silos-tab-list .silos-tab-active {
|
||||
background: var(--color-silos-brown);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.prose .silos-tab-content {
|
||||
padding: 0.85rem 0.25rem 0.25rem;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -147,6 +147,12 @@ export const authApi = {
|
|||
method: 'POST',
|
||||
body: JSON.stringify({ token, new_password }),
|
||||
}),
|
||||
changePassword: (current_password: string, new_password: string) =>
|
||||
request<{ message: string }>('/auth/change-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ current_password, new_password }),
|
||||
}),
|
||||
googleLoginUrl: () => `${API_BASE}/auth/google/login`,
|
||||
}
|
||||
|
||||
export function saveAuth(tokens: AuthTokens) {
|
||||
|
|
@ -180,12 +186,58 @@ export interface DocumentMember {
|
|||
email: string
|
||||
}
|
||||
|
||||
export interface ApiComment {
|
||||
id: string
|
||||
document_id: string
|
||||
body: string
|
||||
parent_id?: string | null
|
||||
author_id: number
|
||||
author_name: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
resolved: boolean
|
||||
}
|
||||
|
||||
export interface ApiNotification {
|
||||
id: string
|
||||
kind: string
|
||||
message: string
|
||||
document_id?: string | null
|
||||
comment_id?: string | null
|
||||
actor_name: string
|
||||
read: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ApiActivity {
|
||||
id: string
|
||||
document_id: string
|
||||
kind: string
|
||||
message: string
|
||||
actor_name: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ApiTemplate {
|
||||
id: string
|
||||
owner_id: number
|
||||
name: string
|
||||
description: string
|
||||
category: string
|
||||
emoji: string
|
||||
content: Record<string, unknown>
|
||||
created_at: string
|
||||
updated_at: string
|
||||
members: Array<{ user_id: number; name: string; email: string }>
|
||||
}
|
||||
|
||||
export interface ApiDocument {
|
||||
id: string
|
||||
type: 'shed' | 'silo' | 'sketch' | 'stead'
|
||||
title: string
|
||||
template_id?: string | null
|
||||
stead_id?: string | null
|
||||
parent_page_id?: string | null
|
||||
content: Record<string, unknown>
|
||||
created_by: string
|
||||
created_at: string
|
||||
|
|
@ -197,30 +249,45 @@ export interface ApiDocument {
|
|||
members?: DocumentMember[]
|
||||
}
|
||||
|
||||
export interface ApiDocumentVersion {
|
||||
id: string
|
||||
document_id: string
|
||||
title: string
|
||||
content: Record<string, unknown>
|
||||
created_by: string
|
||||
created_at: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface DocumentCreatePayload {
|
||||
type: 'shed' | 'silo' | 'sketch' | 'stead'
|
||||
title: string
|
||||
content?: Record<string, unknown>
|
||||
template_id?: string
|
||||
stead_id?: string
|
||||
parent_page_id?: string | null
|
||||
}
|
||||
|
||||
export interface DocumentUpdatePayload {
|
||||
title?: string
|
||||
content?: Record<string, unknown>
|
||||
starred?: boolean
|
||||
stead_id?: string
|
||||
parent_page_id?: string
|
||||
}
|
||||
|
||||
export const documentsApi = {
|
||||
list: (params?: {
|
||||
type?: string
|
||||
stead_id?: string
|
||||
parent_page_id?: string
|
||||
starred?: boolean
|
||||
sort?: 'updated_at' | 'last_visited_at'
|
||||
}) => {
|
||||
const search = new URLSearchParams()
|
||||
if (params?.type) search.set('type', params.type)
|
||||
if (params?.stead_id) search.set('stead_id', params.stead_id)
|
||||
if (params?.parent_page_id) search.set('parent_page_id', params.parent_page_id)
|
||||
if (params?.starred !== undefined) search.set('starred', String(params.starred))
|
||||
if (params?.sort) search.set('sort', params.sort)
|
||||
const qs = search.toString()
|
||||
|
|
@ -258,4 +325,43 @@ export const documentsApi = {
|
|||
|
||||
delete: (id: string) =>
|
||||
request<void>(`/documents/${id}`, { method: 'DELETE' }),
|
||||
|
||||
listComments: (id: string) => request<ApiComment[]>(`/documents/${id}/comments`),
|
||||
|
||||
createComment: (id: string, body: string, parent_id?: string) =>
|
||||
request<ApiComment>(`/documents/${id}/comments`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ body, parent_id }),
|
||||
}),
|
||||
|
||||
deleteComment: (documentId: string, commentId: string) =>
|
||||
request<void>(`/documents/${documentId}/comments/${commentId}`, { method: 'DELETE' }),
|
||||
resolveComment: (documentId: string, commentId: string, resolved: boolean) =>
|
||||
request<ApiComment>(`/documents/${documentId}/comments/${commentId}/resolve`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ resolved }),
|
||||
}),
|
||||
listActivity: (documentId: string) => request<ApiActivity[]>(`/documents/${documentId}/activity`),
|
||||
listNotifications: () => request<ApiNotification[]>('/documents/notifications'),
|
||||
markNotificationsRead: () => request<void>('/documents/notifications/read', { method: 'POST' }),
|
||||
listHistory: (documentId: string) => request<ApiDocumentVersion[]>(`/documents/${documentId}/history`),
|
||||
restoreHistory: (documentId: string, versionId: string) =>
|
||||
request<ApiDocument>(`/documents/${documentId}/history/${versionId}/restore`, { method: 'POST' }),
|
||||
exportDocument: (documentId: string, format: 'html' | 'text' = 'html') =>
|
||||
request<string>(`/documents/${documentId}/export?format=${format}`),
|
||||
}
|
||||
|
||||
export const templatesApi = {
|
||||
list: () => request<ApiTemplate[]>('/templates'),
|
||||
create: (payload: Omit<ApiTemplate, 'id' | 'owner_id' | 'created_at' | 'updated_at' | 'members'>) =>
|
||||
request<ApiTemplate>('/templates', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
delete: (id: string) => request<void>(`/templates/${id}`, { method: 'DELETE' }),
|
||||
share: (id: string, email_or_username: string) =>
|
||||
request<ApiTemplate>(`/templates/${id}/share`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email_or_username }),
|
||||
}),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export interface DocumentMeta {
|
|||
title: string
|
||||
templateId?: ShedTemplateId
|
||||
steadId?: string
|
||||
parentPageId?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
createdBy: string
|
||||
|
|
@ -24,6 +25,7 @@ export interface DocumentRecord extends DocumentMeta {
|
|||
export interface ListDocumentsOptions {
|
||||
sort?: 'updated_at' | 'last_visited_at'
|
||||
starred?: boolean
|
||||
parentPageId?: string
|
||||
}
|
||||
|
||||
function fromApi(doc: ApiDocument): DocumentRecord {
|
||||
|
|
@ -33,6 +35,7 @@ function fromApi(doc: ApiDocument): DocumentRecord {
|
|||
title: doc.title,
|
||||
templateId: doc.template_id as ShedTemplateId | undefined,
|
||||
steadId: doc.stead_id ?? undefined,
|
||||
parentPageId: doc.parent_page_id ?? undefined,
|
||||
content: doc.content,
|
||||
createdBy: doc.created_by,
|
||||
createdAt: doc.created_at,
|
||||
|
|
@ -53,13 +56,14 @@ export async function listDocuments(
|
|||
type,
|
||||
starred: options?.starred,
|
||||
sort: options?.sort,
|
||||
parent_page_id: options?.parentPageId,
|
||||
})
|
||||
return docs.map(fromApi)
|
||||
return docs.filter((doc) => !(doc.content?.archived === true)).map(fromApi)
|
||||
}
|
||||
|
||||
export async function listDocumentsByStead(steadId: string): Promise<DocumentRecord[]> {
|
||||
const docs = await documentsApi.list({ stead_id: steadId })
|
||||
return docs.filter((d) => d.type !== 'stead').map(fromApi)
|
||||
return docs.filter((d) => d.type !== 'stead' && d.content?.archived !== true).map(fromApi)
|
||||
}
|
||||
|
||||
export async function searchDocuments(q: string): Promise<DocumentRecord[]> {
|
||||
|
|
@ -87,6 +91,7 @@ export async function createDocument(
|
|||
templateId?: ShedTemplateId,
|
||||
_createdBy = 'You',
|
||||
steadId?: string,
|
||||
parentPageId?: string,
|
||||
): Promise<DocumentRecord> {
|
||||
const doc = await documentsApi.create({
|
||||
type,
|
||||
|
|
@ -94,18 +99,23 @@ export async function createDocument(
|
|||
content,
|
||||
template_id: templateId,
|
||||
stead_id: steadId,
|
||||
parent_page_id: parentPageId,
|
||||
})
|
||||
return fromApi(doc)
|
||||
}
|
||||
|
||||
export async function updateDocument(
|
||||
id: string,
|
||||
updates: Partial<Pick<DocumentRecord, 'title' | 'content'>>,
|
||||
updates: Partial<Pick<DocumentRecord, 'title' | 'content' | 'steadId' | 'parentPageId'>> & {
|
||||
parentPageId?: string | null
|
||||
},
|
||||
): Promise<DocumentRecord | null> {
|
||||
try {
|
||||
const doc = await documentsApi.update(id, {
|
||||
title: updates.title,
|
||||
content: updates.content,
|
||||
stead_id: updates.steadId,
|
||||
parent_page_id: updates.parentPageId,
|
||||
})
|
||||
return fromApi(doc)
|
||||
} catch {
|
||||
|
|
@ -113,6 +123,15 @@ export async function updateDocument(
|
|||
}
|
||||
}
|
||||
|
||||
export async function deleteDocument(id: string): Promise<boolean> {
|
||||
try {
|
||||
await documentsApi.delete(id)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleStar(id: string, starred: boolean): Promise<DocumentRecord | null> {
|
||||
try {
|
||||
const doc = await documentsApi.update(id, { starred })
|
||||
|
|
|
|||
|
|
@ -2,10 +2,12 @@ import { useState, useEffect } from 'react'
|
|||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import WorkspaceShell from '../../components/workspace/WorkspaceShell'
|
||||
import SiloEditor from '../../components/silo/SiloEditor'
|
||||
import { getDocument, type DocumentRecord } from '../../lib/documents'
|
||||
import { useAutoSave } from '../../hooks/useAutoSave'
|
||||
import { createDocument, deleteDocument, getDocument, updateDocument, type DocumentRecord } from '../../lib/documents'
|
||||
import { useRecordVisit } from '../../hooks/useRecordVisit'
|
||||
import { ApiError } from '../../lib/api'
|
||||
import { ApiError, documentsApi, type ApiDocumentVersion } from '../../lib/api'
|
||||
import { formatRelativeTime } from '../../lib/documentUtils'
|
||||
import CommentsPanel from '../../components/workspace/CommentsPanel'
|
||||
import { Clock3, Download, MessageSquare, RotateCcw, X } from 'lucide-react'
|
||||
|
||||
function siloHtml(content: Record<string, unknown>): string {
|
||||
if (typeof content.bodyHtml === 'string' && content.bodyHtml) return content.bodyHtml
|
||||
|
|
@ -17,6 +19,13 @@ export default function SiloPage() {
|
|||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const [doc, setDoc] = useState<DocumentRecord | null>(null)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
const [commentsOpen, setCommentsOpen] = useState(false)
|
||||
const [historyOpen, setHistoryOpen] = useState(false)
|
||||
const [history, setHistory] = useState<ApiDocumentVersion[]>([])
|
||||
const [historyError, setHistoryError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
|
|
@ -34,9 +43,20 @@ export default function SiloPage() {
|
|||
})
|
||||
}, [id, navigate])
|
||||
|
||||
useAutoSave(doc)
|
||||
useRecordVisit(doc?.id)
|
||||
|
||||
useEffect(() => {
|
||||
function onShortcut(event: KeyboardEvent) {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
|
||||
event.preventDefault()
|
||||
if (editing && !saving) void update()
|
||||
}
|
||||
if (event.key === 'Escape' && historyOpen) setHistoryOpen(false)
|
||||
}
|
||||
window.addEventListener('keydown', onShortcut)
|
||||
return () => window.removeEventListener('keydown', onShortcut)
|
||||
}, [editing, saving, historyOpen])
|
||||
|
||||
if (!doc) return null
|
||||
|
||||
const status = (doc.content.status as string) || 'draft'
|
||||
|
|
@ -49,34 +69,135 @@ export default function SiloPage() {
|
|||
)
|
||||
}
|
||||
|
||||
function publish() {
|
||||
setDoc((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
content: {
|
||||
...prev.content,
|
||||
status: status === 'published' ? 'draft' : 'published',
|
||||
},
|
||||
async function update() {
|
||||
if (!doc) return
|
||||
setSaving(true)
|
||||
setSaveError(null)
|
||||
const updated = await updateDocument(doc.id, { title: doc.title, content: doc.content })
|
||||
setSaving(false)
|
||||
if (updated) {
|
||||
setDoc(updated)
|
||||
setEditing(false)
|
||||
} else {
|
||||
setSaveError('Could not save this page. Check your connection and try again.')
|
||||
}
|
||||
: prev,
|
||||
)
|
||||
}
|
||||
|
||||
async function publish() {
|
||||
if (!doc) return
|
||||
setSaving(true)
|
||||
setSaveError(null)
|
||||
const updated = await updateDocument(doc.id, {
|
||||
title: doc.title,
|
||||
content: { ...doc.content, status: status === 'published' ? 'draft' : 'published' },
|
||||
})
|
||||
setSaving(false)
|
||||
if (updated) {
|
||||
setDoc(updated)
|
||||
setEditing(false)
|
||||
} else {
|
||||
setSaveError('Could not publish this page. Check your connection and try again.')
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async function openHistory() {
|
||||
if (!doc) return
|
||||
setHistoryError(null)
|
||||
try {
|
||||
setHistory(await documentsApi.listHistory(doc.id))
|
||||
setHistoryOpen(true)
|
||||
} catch (err) {
|
||||
setHistoryError(err instanceof Error ? err.message : 'Could not load version history.')
|
||||
}
|
||||
}
|
||||
|
||||
async function restore(version: ApiDocumentVersion) {
|
||||
if (!doc || !window.confirm(`Restore "${version.title}" from ${new Date(version.created_at).toLocaleString()}?`)) return
|
||||
try {
|
||||
await documentsApi.restoreHistory(doc.id, version.id)
|
||||
const restored = await getDocument(doc.id)
|
||||
setDoc(restored)
|
||||
setHistoryOpen(false)
|
||||
setEditing(false)
|
||||
} catch (err) {
|
||||
setHistoryError(err instanceof Error ? err.message : 'Could not restore this version.')
|
||||
}
|
||||
}
|
||||
|
||||
async function exportPage(format: 'html' | 'text') {
|
||||
if (!doc) return
|
||||
try {
|
||||
const content = await documentsApi.exportDocument(doc.id, format)
|
||||
const blob = new Blob([content], { type: format === 'html' ? 'text/html' : 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = `${doc.title.replace(/[^\w\s-]/g, '').trim() || 'page'}.${format === 'html' ? 'html' : 'txt'}`
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (err) {
|
||||
setSaveError(err instanceof Error ? err.message : 'Could not export this page.')
|
||||
}
|
||||
}
|
||||
|
||||
async function createChildPage() {
|
||||
if (!doc) return
|
||||
const child = await createDocument('silo', `Child of ${doc.title}`, { body: '', bodyHtml: '', status: 'draft' }, undefined, doc.createdBy, doc.steadId, doc.id)
|
||||
navigate(`/workspace/silo/${child.id}`)
|
||||
}
|
||||
|
||||
async function duplicatePage() {
|
||||
if (!doc) return
|
||||
const duplicate = await createDocument('silo', `${doc.title} copy`, { ...doc.content, status: 'draft' }, doc.templateId, doc.createdBy, doc.steadId, doc.parentPageId)
|
||||
navigate(`/workspace/silo/${duplicate.id}`)
|
||||
}
|
||||
|
||||
async function archivePage() {
|
||||
if (!doc) return
|
||||
const updated = await updateDocument(doc.id, { content: { ...doc.content, archived: true, archivedAt: new Date().toISOString() } })
|
||||
if (updated) navigate('/dashboard')
|
||||
else setSaveError('Could not archive this page. Check your connection and try again.')
|
||||
}
|
||||
|
||||
async function removePage() {
|
||||
if (!doc || !window.confirm('Delete this page permanently?')) return
|
||||
if (await deleteDocument(doc.id)) navigate('/dashboard')
|
||||
else setSaveError('Could not delete this page. Check your connection and try again.')
|
||||
}
|
||||
|
||||
const pageText = siloHtml(doc.content).replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
const wordCount = pageText ? pageText.split(' ').length : 0
|
||||
|
||||
return (
|
||||
<WorkspaceShell
|
||||
doc={doc}
|
||||
onDocChange={setDoc}
|
||||
layout="page"
|
||||
titleReadOnly={!editing}
|
||||
onTitleChange={(title) =>
|
||||
setDoc((prev) => (prev ? { ...prev, title } : prev))
|
||||
}
|
||||
headerActions={
|
||||
<>
|
||||
{!editing ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={publish}
|
||||
className="hidden rounded-lg bg-silos-brown px-3 py-1 text-xs font-medium text-white hover:bg-silos-brown-dark sm:block"
|
||||
onClick={() => setEditing(true)}
|
||||
className="rounded-lg bg-silos-brown px-3 py-1 text-xs font-medium text-white hover:bg-silos-brown-dark"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button type="button" onClick={() => void update()} disabled={saving} className="rounded-lg border border-silos-cream-dark px-3 py-1 text-xs font-medium text-silos-text hover:bg-silos-cream disabled:opacity-50">
|
||||
{saving ? 'Saving…' : 'Update'}
|
||||
</button>
|
||||
<button type="button" onClick={() => void publish()} disabled={saving} className="rounded-lg bg-silos-brown px-3 py-1 text-xs font-medium text-white hover:bg-silos-brown-dark disabled:opacity-50">
|
||||
{status === 'published' ? 'Unpublish' : 'Publish'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/dashboard')}
|
||||
|
|
@ -84,10 +205,60 @@ export default function SiloPage() {
|
|||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCommentsOpen(true)}
|
||||
className="rounded-lg p-1.5 text-silos-muted hover:bg-silos-cream"
|
||||
aria-label="Open comments"
|
||||
title="Comments"
|
||||
>
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button type="button" onClick={() => void openHistory()} className="rounded-lg p-1.5 text-silos-muted hover:bg-silos-cream" aria-label="Open version history" title="Version history">
|
||||
<Clock3 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button type="button" onClick={() => void exportPage('html')} className="rounded-lg p-1.5 text-silos-muted hover:bg-silos-cream" aria-label="Export page" title="Export HTML">
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
onCreateChild={() => void createChildPage()}
|
||||
onDuplicate={() => void duplicatePage()}
|
||||
onArchive={() => void archivePage()}
|
||||
onDelete={() => void removePage()}
|
||||
>
|
||||
<SiloEditor html={siloHtml(doc.content)} onChange={patchHtml} />
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-silos-cream-dark bg-silos-cream/30 px-4 py-2.5 text-xs text-silos-muted">
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={`rounded-full px-2.5 py-1 font-medium ${
|
||||
status === 'published'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-amber-100 text-amber-700'
|
||||
}`}
|
||||
>
|
||||
{status === 'published' ? 'Published' : 'Draft'}
|
||||
</span>
|
||||
<span>{wordCount} words</span>
|
||||
<span>Updated {formatRelativeTime(doc.updatedAt)}</span>
|
||||
</div>
|
||||
{saveError && <span className="text-red-700">{saveError}</span>}
|
||||
</div>
|
||||
<div aria-live="polite">
|
||||
<SiloEditor html={siloHtml(doc.content)} onChange={patchHtml} editable={editing} />
|
||||
</div>
|
||||
{commentsOpen && <CommentsPanel documentId={doc.id} onClose={() => setCommentsOpen(false)} />}
|
||||
{historyOpen && (
|
||||
<aside className="fixed right-0 top-12 z-40 flex h-[calc(100vh-3rem)] w-full max-w-md flex-col border-l border-silos-cream-dark bg-white shadow-xl" aria-label="Version history">
|
||||
<div className="flex items-center justify-between border-b border-silos-cream-dark px-4 py-3">
|
||||
<div><h2 className="font-semibold text-silos-text">Version history</h2><p className="text-xs text-silos-muted">Restore an earlier saved version</p></div>
|
||||
<button type="button" onClick={() => setHistoryOpen(false)} aria-label="Close version history"><X className="h-4 w-4 text-silos-muted" /></button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{historyError && <p className="mb-3 text-xs text-red-700">{historyError}</p>}
|
||||
{history.length === 0 ? <p className="text-sm text-silos-muted">No earlier versions yet.</p> : <div className="space-y-3">{history.map((version) => <article key={version.id} className="rounded-xl border border-silos-cream-dark p-3"><div className="flex items-start justify-between gap-3"><div><p className="text-sm font-medium text-silos-text">{version.title}</p><p className="mt-1 text-xs text-silos-muted">{version.reason} · {version.created_by}</p><time className="text-[10px] text-silos-muted">{new Date(version.created_at).toLocaleString()}</time></div><button type="button" onClick={() => void restore(version)} className="flex items-center gap-1 rounded-lg border border-silos-cream-dark px-2 py-1 text-xs text-silos-brown hover:bg-silos-cream"><RotateCcw className="h-3 w-3" />Restore</button></div></article>)}</div>}
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
</WorkspaceShell>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { useNavigate, useParams } from 'react-router-dom'
|
|||
import WorkspaceShell from '../../components/workspace/WorkspaceShell'
|
||||
import SketchCanvas from '../../components/sketch/SketchCanvas'
|
||||
import { getDocument, type DocumentRecord } from '../../lib/documents'
|
||||
import { useAutoSave } from '../../hooks/useAutoSave'
|
||||
import { updateDocument } from '../../lib/documents'
|
||||
import { useRecordVisit } from '../../hooks/useRecordVisit'
|
||||
import { ApiError } from '../../lib/api'
|
||||
import {
|
||||
|
|
@ -16,6 +16,8 @@ export default function SketchPage() {
|
|||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const [doc, setDoc] = useState<DocumentRecord | null>(null)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
|
|
@ -33,7 +35,6 @@ export default function SketchPage() {
|
|||
})
|
||||
}, [id, navigate])
|
||||
|
||||
useAutoSave(doc)
|
||||
useRecordVisit(doc?.id)
|
||||
|
||||
const sketchContent = useMemo(
|
||||
|
|
@ -48,14 +49,66 @@ export default function SketchPage() {
|
|||
setDoc((prev) => (prev ? { ...prev, content: next } : prev))
|
||||
}
|
||||
|
||||
async function update() {
|
||||
if (!doc) return
|
||||
setSaving(true)
|
||||
const updated = await updateDocument(doc.id, { title: doc.title, content: doc.content })
|
||||
setSaving(false)
|
||||
if (updated) {
|
||||
setDoc(updated)
|
||||
setEditing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function publish() {
|
||||
if (!doc) return
|
||||
setSaving(true)
|
||||
const updated = await updateDocument(doc.id, {
|
||||
title: doc.title,
|
||||
content: { ...doc.content, status: 'published' },
|
||||
})
|
||||
setSaving(false)
|
||||
if (updated) {
|
||||
setDoc(updated)
|
||||
setEditing(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkspaceShell doc={doc} onDocChange={setDoc} layout="canvas" hideTitle>
|
||||
<WorkspaceShell
|
||||
doc={doc}
|
||||
onDocChange={setDoc}
|
||||
layout="canvas"
|
||||
hideTitle
|
||||
headerActions={
|
||||
!editing ? (
|
||||
<button type="button" onClick={() => setEditing(true)} className="rounded-lg bg-silos-brown px-3 py-1 text-xs font-medium text-white hover:bg-silos-brown-dark">
|
||||
Edit
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button type="button" onClick={() => void update()} disabled={saving} className="rounded-lg border border-silos-cream-dark px-3 py-1 text-xs font-medium text-silos-text hover:bg-silos-cream disabled:opacity-50">
|
||||
{saving ? 'Saving…' : 'Update'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void publish()}
|
||||
disabled={saving}
|
||||
className="rounded-lg bg-silos-brown px-3 py-1 text-xs font-medium text-white hover:bg-silos-brown-dark disabled:opacity-50"
|
||||
>
|
||||
Publish
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center border-b border-silos-cream-dark px-6 py-3">
|
||||
<input
|
||||
value={doc.title}
|
||||
readOnly={!editing}
|
||||
onChange={(e) => setDoc((prev) => (prev ? { ...prev, title: e.target.value } : prev))}
|
||||
className="border-none bg-transparent text-lg font-semibold text-silos-text outline-none"
|
||||
className="border-none bg-transparent text-lg font-semibold text-silos-text outline-none read-only:cursor-default"
|
||||
placeholder="Untitled whiteboard"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -64,6 +117,7 @@ export default function SketchPage() {
|
|||
elements={sketchContent.elements}
|
||||
viewport={sketchContent.viewport}
|
||||
onChange={patchSketch}
|
||||
readOnly={!editing}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
47
runtime-validation-report.md
Normal file
47
runtime-validation-report.md
Normal 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
|
||||
```
|
||||
Loading…
Add table
Add a link
Reference in a new issue