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