first commit

This commit is contained in:
Anurag Jangid 2026-10-08 02:44:15 +05:30
commit 6e354ba041
109 changed files with 12110 additions and 0 deletions

15
.gitignore vendored Normal file
View file

@ -0,0 +1,15 @@
# Python
backend/venv/
backend/__pycache__/
backend/**/__pycache__/
backend/.env
backend/silos.db
*.pyc
# Node
frontend/node_modules/
frontend/dist/
# OS
.DS_Store
Thumbs.db

49
README.md Normal file
View file

@ -0,0 +1,49 @@
# Silos
A collaborative workspace platform — your knowledge, your team, one place.
## Tech Stack
- **Frontend:** React + TypeScript + Tailwind CSS
- **Backend:** FastAPI + SQLAlchemy
- **Database:** PostgreSQL (users, auth) + MongoDB (whiteboards, sketches)
## Quick Start
### 1. Start databases
```bash
docker compose up -d
```
### 2. Backend
```bash
cd backend
copy .env.example .env
python -m venv venv
venv\Scripts\activate
pip install -r requirements.txt
uvicorn app.main:app --reload --port 8000
```
### 3. Frontend
```bash
cd frontend
npm install
npm run dev
```
- Landing page: [http://localhost:5173](http://localhost:5173)
- Login page: [http://localhost:5173/login](http://localhost:5173/login)
- API docs: [http://localhost:8000/docs](http://localhost:8000/docs)
## Project Structure
```
Silos/
├── frontend/ # React + Tailwind
├── backend/ # FastAPI
└── docker-compose.yml
```

19
backend/.env.example Normal file
View file

@ -0,0 +1,19 @@
# Database — SQLite works out of the box for local dev
DATABASE_URL=sqlite:///./silos.db
# For PostgreSQL (with docker compose up -d), use instead:
# DATABASE_URL=postgresql+psycopg2://silos:silos_secret@localhost:5432/silos
# MongoDB (for whiteboards & sketches — used in later phases)
MONGODB_URL=mongodb://localhost:27017
MONGODB_DB=silos
# Auth
SECRET_KEY=change-this-to-a-long-random-secret-key-in-production
ACCESS_TOKEN_EXPIRE_MINUTES=60
REFRESH_TOKEN_EXPIRE_DAYS=7
# App
APP_NAME=Silos API
DEBUG=true
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173

86
backend/README.md Normal file
View file

@ -0,0 +1,86 @@
# Silos Backend
FastAPI backend with PostgreSQL/SQLite (users/auth) and MongoDB (workspace documents).
## Setup
### 1. Start databases
From the project root:
```bash
docker compose up mongo -d
```
Optional (PostgreSQL instead of SQLite):
```bash
docker compose up mongo postgres -d
```
Then confirm the API: `GET http://localhost:8000/api/v1/health` should report `"mongodb": "connected"`.
MongoDB is required for Silo pages, Sketch whiteboards, Stead spaces, and Shed docs.
SQLite is the default for user authentication (`DATABASE_URL` in `.env`).
### 2. Configure environment
```bash
cd backend
copy .env.example .env
```
### 3. Create virtual environment & install dependencies
```bash
python -m venv venv
venv\Scripts\activate
pip install -r requirements.txt
```
### 4. Run the API
```bash
uvicorn app.main:app --reload --port 8000
```
API docs: [http://localhost:8000/docs](http://localhost:8000/docs)
## API Endpoints
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/v1/health` | Health check |
| POST | `/api/v1/auth/register` | Create new user |
| POST | `/api/v1/auth/login` | Login & get JWT tokens |
| GET | `/api/v1/auth/me` | Get current user (requires auth) |
| PATCH | `/api/v1/auth/me` | Update user profile |
| POST | `/api/v1/auth/forgot-password` | Request password reset |
| POST | `/api/v1/auth/reset-password` | Reset password with token |
| GET | `/api/v1/documents` | List documents (optional `?type=` or `?stead_id=`) |
| GET | `/api/v1/documents/search?q=` | Search documents by title, type, author, body |
| GET | `/api/v1/documents/{id}` | Get a document |
| POST | `/api/v1/documents` | Create a document |
| PATCH | `/api/v1/documents/{id}` | Update title/content |
| POST | `/api/v1/documents/{id}/share` | Invite a user by email/username |
| DELETE | `/api/v1/documents/{id}/share/{user_id}` | Remove a member |
| DELETE | `/api/v1/documents/{id}` | Delete a document |
| WS | `/api/v1/ws/collab/{id}` | Live collab for Shed meeting notes |
## Project Structure
```
backend/
├── app/
│ ├── main.py # FastAPI app entry
│ ├── config.py # Settings from .env
│ ├── database.py # PostgreSQL (SQLAlchemy)
│ ├── mongo.py # MongoDB connection
│ ├── models/ # SQLAlchemy models
│ ├── schemas/ # Pydantic schemas
│ ├── routers/ # API routes
│ ├── services/ # Business logic
│ └── utils/ # Security helpers
├── requirements.txt
└── .env.example
```

1
backend/app/__init__.py Normal file
View file

@ -0,0 +1 @@

31
backend/app/config.py Normal file
View file

@ -0,0 +1,31 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
app_name: str = "Silos API"
debug: bool = True
# SQLite by default for local dev; switch to PostgreSQL in production
database_url: str = "sqlite:///./silos.db"
mongodb_url: str = "mongodb://localhost:27017"
mongodb_db: str = "silos"
secret_key: str = "change-this-to-a-long-random-secret-key-in-production"
access_token_expire_minutes: int = 60
refresh_token_expire_days: int = 7
cors_origins: str = "http://localhost:5173,http://127.0.0.1:5173"
@property
def is_sqlite(self) -> bool:
return self.database_url.startswith("sqlite")
@property
def cors_origin_list(self) -> list[str]:
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
settings = Settings()

27
backend/app/database.py Normal file
View file

@ -0,0 +1,27 @@
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.config import settings
_connect_args = {"check_same_thread": False} if settings.is_sqlite else {}
engine = create_engine(
settings.database_url,
pool_pre_ping=not settings.is_sqlite,
connect_args=_connect_args,
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
class Base(DeclarativeBase):
pass
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()

View file

@ -0,0 +1,61 @@
import secrets
from datetime import UTC, datetime, timedelta
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from motor.motor_asyncio import AsyncIOMotorDatabase
from sqlalchemy.orm import Session
from app.database import get_db
from app.mongo import get_mongo_db
from app.models.user import User
from app.utils.security import verify_token
security_scheme = HTTPBearer(auto_error=False)
def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(security_scheme),
db: Session = Depends(get_db),
) -> User:
if credentials is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
subject = verify_token(credentials.credentials, expected_type="access")
if subject is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
user = db.query(User).filter(User.email == subject).first()
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
if not user.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Account is inactive")
return user
def get_mongo_db_dep() -> AsyncIOMotorDatabase:
try:
return get_mongo_db()
except RuntimeError:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="MongoDB is not available. Start it with: docker compose up mongo -d",
)
def generate_reset_token() -> str:
return secrets.token_urlsafe(32)
def reset_token_expiry(hours: int = 1) -> datetime:
return datetime.now(UTC) + timedelta(hours=hours)

57
backend/app/main.py Normal file
View file

@ -0,0 +1,57 @@
from contextlib import asynccontextmanager
import logging
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import text
from sqlalchemy.exc import OperationalError
from app.config import settings
from app.database import Base, engine
from app.mongo import close_mongo, connect_mongo
from app.routers import auth, collab, documents, health
from app.services.document_service import ensure_indexes
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(_: FastAPI):
try:
Base.metadata.create_all(bind=engine)
logger.info("Database connected and tables ready")
except OperationalError:
logger.warning("Database unavailable — check DATABASE_URL in .env")
try:
await connect_mongo()
from app.mongo import get_mongo_db
await ensure_indexes(get_mongo_db())
logger.info("MongoDB connected and indexes ready")
except Exception:
logger.warning("MongoDB unavailable — will connect when available")
yield
await close_mongo()
app = FastAPI(title=settings.app_name, debug=settings.debug, lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origin_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(health.router, prefix="/api/v1")
app.include_router(auth.router, prefix="/api/v1")
app.include_router(documents.router, prefix="/api/v1")
app.include_router(collab.router, prefix="/api/v1")
@app.get("/")
def root() -> dict[str, str]:
return {"message": "Silos API", "docs": "/docs"}

View file

@ -0,0 +1,3 @@
from app.models.user import PasswordResetToken, User
__all__ = ["User", "PasswordResetToken"]

View file

@ -0,0 +1,39 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
username: Mapped[str] = mapped_column(String(100), unique=True, index=True, nullable=False)
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
full_name: Mapped[str] = mapped_column(String(255), nullable=False)
employee_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
phone: Mapped[str | None] = mapped_column(String(50), nullable=True)
occupation: Mapped[str | None] = mapped_column(String(150), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
)
class PasswordResetToken(Base):
__tablename__ = "password_reset_tokens"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
user_id: Mapped[int] = mapped_column(index=True, nullable=False)
token: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
used: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)

26
backend/app/mongo.py Normal file
View file

@ -0,0 +1,26 @@
from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
from app.config import settings
mongo_client: AsyncIOMotorClient | None = None
mongo_db: AsyncIOMotorDatabase | None = None
async def connect_mongo() -> None:
global mongo_client, mongo_db
mongo_client = AsyncIOMotorClient(settings.mongodb_url)
mongo_db = mongo_client[settings.mongodb_db]
async def close_mongo() -> None:
global mongo_client, mongo_db
if mongo_client:
mongo_client.close()
mongo_client = None
mongo_db = None
def get_mongo_db() -> AsyncIOMotorDatabase:
if mongo_db is None:
raise RuntimeError("MongoDB is not connected")
return mongo_db

View file

@ -0,0 +1 @@

116
backend/app/routers/auth.py Normal file
View file

@ -0,0 +1,116 @@
from datetime import timedelta
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.config import settings
from app.database import get_db
from app.dependencies import (
generate_reset_token,
get_current_user,
reset_token_expiry,
)
from app.models.user import User
from app.schemas.user import (
ForgotPasswordRequest,
LoginRequest,
MessageResponse,
ResetPasswordRequest,
TokenResponse,
UserCreate,
UserResponse,
UserUpdate,
)
from app.services.user_service import (
authenticate_user,
create_password_reset_token,
create_user,
get_user_by_email,
get_user_by_username,
get_valid_reset_token,
update_user,
)
from app.utils.security import create_access_token, create_refresh_token, hash_password
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
def register(data: UserCreate, db: Session = Depends(get_db)) -> User:
if get_user_by_email(db, data.email.lower()):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered")
if get_user_by_username(db, data.username.lower()):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Username already taken")
return create_user(db, data)
@router.post("/login", response_model=TokenResponse)
def login(data: LoginRequest, db: Session = Depends(get_db)) -> TokenResponse:
user = authenticate_user(db, data.username_or_email, data.password)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid username/email or password",
)
if not user.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Account is inactive")
access_token = create_access_token(
user.email,
timedelta(days=7) if data.remember_me else None,
)
return TokenResponse(
access_token=access_token,
refresh_token=create_refresh_token(user.email),
user=UserResponse.model_validate(user),
)
@router.get("/me", response_model=UserResponse)
def get_me(current_user: User = Depends(get_current_user)) -> User:
return current_user
@router.patch("/me", response_model=UserResponse)
def update_me(
data: UserUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> User:
return update_user(db, current_user, data)
@router.post("/forgot-password", response_model=MessageResponse)
def forgot_password(data: ForgotPasswordRequest, db: Session = Depends(get_db)) -> MessageResponse:
user = get_user_by_email(db, data.email.lower())
if user is None:
return MessageResponse(message="If that email exists, a reset link has been sent.")
token = generate_reset_token()
create_password_reset_token(db, user, token, reset_token_expiry())
response = MessageResponse(message="If that email exists, a reset link has been sent.")
if settings.debug:
response.reset_token = token
return response
@router.post("/reset-password", response_model=MessageResponse)
def reset_password(data: ResetPasswordRequest, db: Session = Depends(get_db)) -> MessageResponse:
reset = get_valid_reset_token(db, data.token)
if reset is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired reset token")
user = db.query(User).filter(User.id == reset.user_id).first()
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
user.hashed_password = hash_password(data.new_password)
reset.used = True
db.commit()
return MessageResponse(message="Password reset successfully. You can now log in.")

View file

@ -0,0 +1,97 @@
from collections import defaultdict
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from sqlalchemy.orm import Session
from app.database import SessionLocal
from app.mongo import get_mongo_db
from app.services.document_service import get_document
from app.services.user_service import get_user_by_email
from app.utils.security import verify_token
router = APIRouter()
rooms: dict[str, set[WebSocket]] = defaultdict(set)
presence: dict[str, dict[int, str]] = defaultdict(dict)
async def _user_from_token(token: str):
subject = verify_token(token, expected_type="access")
if not subject:
return None
db: Session = SessionLocal()
try:
return get_user_by_email(db, subject)
finally:
db.close()
@router.websocket("/ws/collab/{doc_id}")
async def collab_socket(websocket: WebSocket, doc_id: str) -> None:
token = websocket.query_params.get("token", "")
user = await _user_from_token(token)
if user is None:
await websocket.close(code=4401)
return
try:
mongo = get_mongo_db()
except RuntimeError:
await websocket.close(code=1013)
return
doc = await get_document(mongo, doc_id, user.id)
if doc is None:
await websocket.close(code=4403)
return
await websocket.accept()
rooms[doc_id].add(websocket)
presence[doc_id][user.id] = user.full_name or user.username
async def broadcast(payload: dict, skip: WebSocket | None = None) -> None:
dead: list[WebSocket] = []
for client in rooms[doc_id]:
if client is skip:
continue
try:
await client.send_json(payload)
except Exception:
dead.append(client)
for client in dead:
rooms[doc_id].discard(client)
await broadcast(
{
"type": "presence",
"peers": [{"id": uid, "name": name} for uid, name in presence[doc_id].items()],
}
)
try:
while True:
message = await websocket.receive_json()
if message.get("type") == "content":
await broadcast(
{
"type": "content",
"content": message.get("content"),
"from": user.id,
},
skip=websocket,
)
except WebSocketDisconnect:
pass
finally:
rooms[doc_id].discard(websocket)
presence[doc_id].pop(user.id, None)
if rooms[doc_id]:
await broadcast(
{
"type": "presence",
"peers": [{"id": uid, "name": name} for uid, name in presence[doc_id].items()],
}
)
else:
rooms.pop(doc_id, None)
presence.pop(doc_id, None)

View file

@ -0,0 +1,159 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status
from motor.motor_asyncio import AsyncIOMotorDatabase
from sqlalchemy.orm import Session
from app.database import get_db
from app.dependencies import get_current_user, get_mongo_db_dep
from app.models.user import User
from app.schemas.document import DocumentCreate, DocumentResponse, DocumentUpdate, ShareRequest
from app.services.document_service import (
add_member,
create_document,
delete_document,
get_document,
list_documents,
record_visit,
remove_member,
search_documents,
update_document,
)
from app.services.user_service import get_user_by_email_or_username
router = APIRouter(prefix="/documents", tags=["documents"])
@router.get("", response_model=list[DocumentResponse])
async def list_docs(
type: str | None = Query(None, description="Filter by document type"),
stead_id: str | None = Query(None, description="Filter by parent stead"),
starred: bool | None = Query(None, description="Filter starred projects"),
sort: str = Query("updated_at", description="Sort field: updated_at or last_visited_at"),
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
) -> list[DocumentResponse]:
sort_field = "last_visited_at" if sort == "last_visited_at" else "updated_at"
docs = await list_documents(
db,
current_user.id,
doc_type=type,
stead_id=stead_id,
starred=starred,
sort=sort_field,
)
return [DocumentResponse(**doc) for doc in docs]
@router.get("/search", response_model=list[DocumentResponse])
async def search_docs(
q: str = Query("", min_length=0),
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
) -> list[DocumentResponse]:
docs = await search_documents(db, current_user.id, q)
return [DocumentResponse(**doc) for doc in docs]
@router.get("/{doc_id}", response_model=DocumentResponse)
async def get_doc(
doc_id: str,
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
) -> DocumentResponse:
doc = await get_document(db, doc_id, current_user.id)
if doc is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Document not found or access denied")
return DocumentResponse(**doc)
@router.post("", response_model=DocumentResponse, status_code=status.HTTP_201_CREATED)
async def create_doc(
data: DocumentCreate,
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
) -> DocumentResponse:
created_by = current_user.full_name or current_user.username
doc = await create_document(db, current_user.id, created_by, data)
return DocumentResponse(**doc)
@router.patch("/{doc_id}", response_model=DocumentResponse)
async def update_doc(
doc_id: str,
data: DocumentUpdate,
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
) -> DocumentResponse:
doc = await update_document(db, doc_id, current_user.id, data)
if doc is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Document not found or you do not have edit access",
)
return DocumentResponse(**doc)
@router.post("/{doc_id}/visit", response_model=DocumentResponse)
async def visit_doc(
doc_id: str,
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
) -> DocumentResponse:
doc = await record_visit(db, doc_id, current_user.id)
if doc is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Document not found or access denied")
return DocumentResponse(**doc)
@router.post("/{doc_id}/share", response_model=DocumentResponse)
async def share_doc(
doc_id: str,
data: ShareRequest,
current_user: User = Depends(get_current_user),
mongo: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
sql: Session = Depends(get_db),
) -> DocumentResponse:
identifier = data.email_or_username.strip().lower()
user = get_user_by_email_or_username(sql, identifier)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
if user.id == current_user.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="You already own this document")
doc = await add_member(
mongo,
doc_id,
current_user.id,
{
"user_id": user.id,
"name": user.full_name or user.username,
"email": user.email,
},
data.role,
)
if doc is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only the owner can share this document")
return DocumentResponse(**doc)
@router.delete("/{doc_id}/share/{user_id}", response_model=DocumentResponse)
async def unshare_doc(
doc_id: str,
user_id: int,
current_user: User = Depends(get_current_user),
mongo: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
) -> DocumentResponse:
doc = await remove_member(mongo, doc_id, current_user.id, user_id)
if doc is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only the owner can update sharing")
return DocumentResponse(**doc)
@router.delete("/{doc_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_doc(
doc_id: str,
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
) -> None:
deleted = await delete_document(db, doc_id, current_user.id)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")

View file

@ -0,0 +1,40 @@
from fastapi import APIRouter
from sqlalchemy import text
from sqlalchemy.exc import OperationalError
from app.config import settings
from app.database import engine
from app.mongo import mongo_client, mongo_db
router = APIRouter(tags=["health"])
@router.get("/health")
async def health_check() -> dict[str, str]:
db_status = "disconnected"
try:
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
db_status = "sqlite" if settings.is_sqlite else "connected"
except OperationalError:
pass
mongo_status = "disconnected"
if mongo_client is not None and mongo_db is not None:
try:
await mongo_client.admin.command("ping")
mongo_status = "connected"
except Exception:
mongo_status = "disconnected"
overall = "ok" if db_status != "disconnected" and mongo_status == "connected" else "degraded"
return {
"status": overall,
"service": "silos-api",
"database": db_status,
"mongodb": mongo_status,
"hint": ""
if mongo_status == "connected"
else "Start MongoDB with: docker compose up mongo -d",
}

View file

@ -0,0 +1,21 @@
from app.schemas.user import (
ForgotPasswordRequest,
LoginRequest,
MessageResponse,
ResetPasswordRequest,
TokenResponse,
UserCreate,
UserResponse,
UserUpdate,
)
__all__ = [
"UserCreate",
"UserUpdate",
"UserResponse",
"LoginRequest",
"TokenResponse",
"ForgotPasswordRequest",
"ResetPasswordRequest",
"MessageResponse",
]

View file

@ -0,0 +1,51 @@
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, Field
CreateType = Literal["shed", "silo", "sketch", "stead"]
MemberRole = Literal["editor", "viewer"]
Visibility = Literal["private", "members"]
class DocumentMember(BaseModel):
user_id: int
role: MemberRole
name: str = ""
email: str = ""
class DocumentCreate(BaseModel):
type: CreateType
title: str
content: dict[str, Any] = Field(default_factory=dict)
template_id: str | None = None
stead_id: str | None = None
class DocumentUpdate(BaseModel):
title: str | None = None
content: dict[str, Any] | None = None
starred: bool | None = None
class ShareRequest(BaseModel):
email_or_username: str
role: MemberRole = "editor"
class DocumentResponse(BaseModel):
id: str
type: CreateType
title: str
template_id: str | None = None
stead_id: str | None = None
content: dict[str, Any]
created_by: str
created_at: datetime
updated_at: datetime
starred: bool = False
last_visited_at: datetime | None = None
owner_id: int | None = None
visibility: Visibility = "private"
members: list[DocumentMember] = Field(default_factory=list)

View file

@ -0,0 +1,59 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict, EmailStr, Field
class UserBase(BaseModel):
email: EmailStr
username: str = Field(min_length=3, max_length=100)
full_name: str = Field(min_length=1, max_length=255)
employee_id: str | None = Field(default=None, max_length=100)
phone: str | None = Field(default=None, max_length=50)
occupation: str | None = Field(default=None, max_length=150)
class UserCreate(UserBase):
password: str = Field(min_length=8, max_length=128)
class UserUpdate(BaseModel):
full_name: str | None = Field(default=None, min_length=1, max_length=255)
employee_id: str | None = Field(default=None, max_length=100)
phone: str | None = Field(default=None, max_length=50)
occupation: str | None = Field(default=None, max_length=150)
class UserResponse(UserBase):
model_config = ConfigDict(from_attributes=True)
id: int
is_active: bool
created_at: datetime
updated_at: datetime
class LoginRequest(BaseModel):
username_or_email: str = Field(min_length=1)
password: str = Field(min_length=1)
remember_me: bool = False
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
user: UserResponse
class ForgotPasswordRequest(BaseModel):
email: EmailStr
class ResetPasswordRequest(BaseModel):
token: str
new_password: str = Field(min_length=8, max_length=128)
class MessageResponse(BaseModel):
message: str
reset_token: str | None = None

View file

@ -0,0 +1 @@

View file

@ -0,0 +1,256 @@
import re
import uuid
from datetime import UTC, datetime
from typing import Any, Literal
from motor.motor_asyncio import AsyncIOMotorDatabase
from pymongo import ReturnDocument
from app.schemas.document import CreateType, DocumentCreate, DocumentUpdate, MemberRole
COLLECTION = "documents"
SortField = Literal["updated_at", "last_visited_at"]
async def ensure_indexes(db: AsyncIOMotorDatabase) -> None:
col = db[COLLECTION]
await col.create_index([("owner_id", 1), ("updated_at", -1)])
await col.create_index([("owner_id", 1), ("last_visited_at", -1)])
await col.create_index([("owner_id", 1), ("stead_id", 1)])
await col.create_index([("owner_id", 1), ("type", 1)])
await col.create_index([("owner_id", 1), ("starred", 1)])
await col.create_index([("members.user_id", 1)])
await col.create_index([("title", "text"), ("created_by", "text")])
def _serialize(doc: dict[str, Any]) -> dict[str, Any]:
return {
"id": doc["_id"],
"type": doc["type"],
"title": doc["title"],
"template_id": doc.get("template_id"),
"stead_id": doc.get("stead_id"),
"content": doc.get("content", {}),
"created_by": doc["created_by"],
"created_at": doc["created_at"],
"updated_at": doc["updated_at"],
"starred": doc.get("starred", False),
"last_visited_at": doc.get("last_visited_at"),
"owner_id": doc.get("owner_id"),
"visibility": doc.get("visibility", "private"),
"members": doc.get("members", []),
}
def access_query(user_id: int) -> dict[str, Any]:
return {"$or": [{"owner_id": user_id}, {"members.user_id": user_id}]}
def can_edit(doc: dict[str, Any], user_id: int) -> bool:
if doc.get("owner_id") == user_id:
return True
for member in doc.get("members", []):
if member.get("user_id") == user_id and member.get("role") == "editor":
return True
return False
async def create_document(
db: AsyncIOMotorDatabase,
owner_id: int,
created_by: str,
data: DocumentCreate,
) -> dict[str, Any]:
now = datetime.now(UTC)
doc_id = str(uuid.uuid4())
doc = {
"_id": doc_id,
"owner_id": owner_id,
"type": data.type,
"title": data.title,
"template_id": data.template_id,
"stead_id": data.stead_id,
"content": data.content,
"created_by": created_by,
"created_at": now,
"updated_at": now,
"starred": False,
"last_visited_at": now,
"visibility": "private",
"members": [],
}
await db[COLLECTION].insert_one(doc)
return _serialize(doc)
async def get_document(
db: AsyncIOMotorDatabase,
doc_id: str,
user_id: int,
) -> dict[str, Any] | None:
doc = await db[COLLECTION].find_one({"_id": doc_id, **access_query(user_id)})
return _serialize(doc) if doc else None
async def list_documents(
db: AsyncIOMotorDatabase,
user_id: int,
doc_type: CreateType | None = None,
stead_id: str | None = None,
starred: bool | None = None,
sort: SortField = "updated_at",
) -> list[dict[str, Any]]:
query: dict[str, Any] = access_query(user_id)
extras: dict[str, Any] = {}
if doc_type:
extras["type"] = doc_type
if stead_id:
extras["stead_id"] = stead_id
if starred is not None:
extras["starred"] = starred
if extras:
query = {"$and": [query, extras]}
cursor = db[COLLECTION].find(query).sort(sort, -1)
return [_serialize(doc) async for doc in cursor]
async def search_documents(
db: AsyncIOMotorDatabase,
user_id: int,
q: str,
limit: int = 20,
) -> list[dict[str, Any]]:
term = q.strip()
if not term:
return []
pattern = re.escape(term)
regex = {"$regex": pattern, "$options": "i"}
query = {
"$and": [
access_query(user_id),
{
"$or": [
{"title": regex},
{"type": regex},
{"created_by": regex},
{"content.body": regex},
{"content.bodyHtml": regex},
]
},
]
}
cursor = db[COLLECTION].find(query).sort([("last_visited_at", -1), ("updated_at", -1)]).limit(limit)
return [_serialize(doc) async for doc in cursor]
async def update_document(
db: AsyncIOMotorDatabase,
doc_id: str,
user_id: int,
data: DocumentUpdate,
) -> dict[str, Any] | None:
existing = await db[COLLECTION].find_one({"_id": doc_id, **access_query(user_id)})
if existing is None:
return None
if not can_edit(existing, user_id):
return None
updates: dict[str, Any] = {"updated_at": datetime.now(UTC)}
if data.title is not None:
updates["title"] = data.title
if data.content is not None:
updates["content"] = data.content
if data.starred is not None:
updates["starred"] = data.starred
result = await db[COLLECTION].find_one_and_update(
{"_id": doc_id},
{"$set": updates},
return_document=ReturnDocument.AFTER,
)
return _serialize(result) if result else None
async def record_visit(
db: AsyncIOMotorDatabase,
doc_id: str,
user_id: int,
) -> dict[str, Any] | None:
now = datetime.now(UTC)
result = await db[COLLECTION].find_one_and_update(
{"_id": doc_id, **access_query(user_id)},
{"$set": {"last_visited_at": now}},
return_document=ReturnDocument.AFTER,
)
return _serialize(result) if result else None
async def add_member(
db: AsyncIOMotorDatabase,
doc_id: str,
owner_id: int,
member: dict[str, Any],
role: MemberRole,
) -> dict[str, Any] | None:
existing = await db[COLLECTION].find_one({"_id": doc_id, "owner_id": owner_id})
if existing is None:
return None
members = list(existing.get("members", []))
members = [m for m in members if m.get("user_id") != member["user_id"]]
members.append(
{
"user_id": member["user_id"],
"role": role,
"name": member.get("name", ""),
"email": member.get("email", ""),
}
)
result = await db[COLLECTION].find_one_and_update(
{"_id": doc_id, "owner_id": owner_id},
{
"$set": {
"members": members,
"visibility": "members",
"updated_at": datetime.now(UTC),
}
},
return_document=ReturnDocument.AFTER,
)
return _serialize(result) if result else None
async def remove_member(
db: AsyncIOMotorDatabase,
doc_id: str,
owner_id: int,
member_user_id: int,
) -> dict[str, Any] | None:
existing = await db[COLLECTION].find_one({"_id": doc_id, "owner_id": owner_id})
if existing is None:
return None
members = [m for m in existing.get("members", []) if m.get("user_id") != member_user_id]
visibility = "members" if members else "private"
result = await db[COLLECTION].find_one_and_update(
{"_id": doc_id, "owner_id": owner_id},
{
"$set": {
"members": members,
"visibility": visibility,
"updated_at": datetime.now(UTC),
}
},
return_document=ReturnDocument.AFTER,
)
return _serialize(result) if result else None
async def delete_document(
db: AsyncIOMotorDatabase,
doc_id: str,
owner_id: int,
) -> bool:
result = await db[COLLECTION].delete_one({"_id": doc_id, "owner_id": owner_id})
return result.deleted_count > 0

View file

@ -0,0 +1,80 @@
from sqlalchemy.orm import Session
from app.models.user import PasswordResetToken, User
from app.schemas.user import UserCreate, UserUpdate
from app.utils.security import hash_password, verify_password
def get_user_by_id(db: Session, user_id: int) -> User | None:
return db.query(User).filter(User.id == user_id).first()
def get_user_by_email(db: Session, email: str) -> User | None:
return db.query(User).filter(User.email == email).first()
def get_user_by_username(db: Session, username: str) -> User | None:
return db.query(User).filter(User.username == username).first()
def get_user_by_email_or_username(db: Session, identifier: str) -> User | None:
return (
db.query(User)
.filter((User.email == identifier) | (User.username == identifier))
.first()
)
def create_user(db: Session, data: UserCreate) -> User:
user = User(
email=data.email.lower(),
username=data.username.lower(),
full_name=data.full_name,
employee_id=data.employee_id,
phone=data.phone,
occupation=data.occupation,
hashed_password=hash_password(data.password),
)
db.add(user)
db.commit()
db.refresh(user)
return user
def update_user(db: Session, user: User, data: UserUpdate) -> User:
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(user, field, value)
db.commit()
db.refresh(user)
return user
def authenticate_user(db: Session, identifier: str, password: str) -> User | None:
user = get_user_by_email_or_username(db, identifier)
if user is None or not verify_password(password, user.hashed_password):
return None
return user
def create_password_reset_token(db: Session, user: User, token: str, expires_at) -> PasswordResetToken:
reset = PasswordResetToken(user_id=user.id, token=token, expires_at=expires_at)
db.add(reset)
db.commit()
db.refresh(reset)
return reset
def get_valid_reset_token(db: Session, token: str) -> PasswordResetToken | None:
from datetime import UTC, datetime
reset = (
db.query(PasswordResetToken)
.filter(
PasswordResetToken.token == token,
PasswordResetToken.used.is_(False),
PasswordResetToken.expires_at > datetime.now(UTC),
)
.first()
)
return reset

View file

@ -0,0 +1 @@

View file

@ -0,0 +1,46 @@
import bcrypt
from datetime import UTC, datetime, timedelta
from typing import Any
from jose import JWTError, jwt
from app.config import settings
ALGORITHM = "HS256"
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
def verify_password(plain_password: str, hashed_password: str) -> bool:
return bcrypt.checkpw(plain_password.encode(), hashed_password.encode())
def create_access_token(subject: str, expires_delta: timedelta | None = None) -> str:
expire = datetime.now(UTC) + (
expires_delta or timedelta(minutes=settings.access_token_expire_minutes)
)
payload = {"sub": subject, "exp": expire, "type": "access"}
return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM)
def create_refresh_token(subject: str) -> str:
expire = datetime.now(UTC) + timedelta(days=settings.refresh_token_expire_days)
payload = {"sub": subject, "exp": expire, "type": "refresh"}
return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM)
def decode_token(token: str) -> dict[str, Any]:
return jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM])
def verify_token(token: str, expected_type: str = "access") -> str | None:
try:
payload = decode_token(token)
if payload.get("type") != expected_type:
return None
subject = payload.get("sub")
return subject if isinstance(subject, str) else None
except JWTError:
return None

12
backend/requirements.txt Normal file
View file

@ -0,0 +1,12 @@
fastapi==0.115.8
uvicorn[standard]==0.34.0
sqlalchemy==2.0.38
psycopg2-binary==2.9.10
pydantic[email]==2.10.6
pydantic-settings==2.8.1
python-jose[cryptography]==3.4.0
bcrypt==4.2.1
python-multipart==0.0.20
motor==3.7.0
alembic==1.14.1
email-validator==2.2.0

26
docker-compose.yml Normal file
View file

@ -0,0 +1,26 @@
services:
postgres:
image: postgres:16-alpine
container_name: silos-postgres
restart: unless-stopped
environment:
POSTGRES_USER: silos
POSTGRES_PASSWORD: silos_secret
POSTGRES_DB: silos
ports:
- "5432:5432"
volumes:
- silos_pg_data:/var/lib/postgresql/data
mongo:
image: mongo:7
container_name: silos-mongo
restart: unless-stopped
ports:
- "27017:27017"
volumes:
- silos_mongo_data:/data/db
volumes:
silos_pg_data:
silos_mongo_data:

19
frontend/index.html Normal file
View file

@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/Assets/Rusty brown logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Silos — Your knowledge. Your team. One place.</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:wght@600;700&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2565
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

27
frontend/package.json Normal file
View file

@ -0,0 +1,27 @@
{
"name": "silos-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"framer-motion": "^12.4.7",
"lucide-react": "^0.475.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.2.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.9",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.9",
"typescript": "~5.7.2",
"vite": "^6.2.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 680 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 980 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 925 KiB

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-label="Apple">
<path fill="#000" d="M17.05 20.28c-.98.95-2.05.8-3.08.35-1.09-.46-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.35C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09l.01-.01zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"/>
</svg>

After

Width:  |  Height:  |  Size: 448 B

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-label="GitHub">
<path fill="#24292F" d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/>
</svg>

After

Width:  |  Height:  |  Size: 841 B

View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-label="Google">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>

After

Width:  |  Height:  |  Size: 727 B

View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 23 23" role="img" aria-label="Microsoft">
<path fill="#f35325" d="M1 1h10v10H1z"/>
<path fill="#81bc06" d="M12 1h10v10H12z"/>
<path fill="#05a6f0" d="M1 12h10v10H1z"/>
<path fill="#ffba08" d="M12 12h10v10H12z"/>
</svg>

After

Width:  |  Height:  |  Size: 280 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-label="Okta">
<circle cx="12" cy="12" r="11" fill="#007DC1"/>
<circle cx="12" cy="12" r="5" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 193 B

View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-label="Slack">
<path fill="#E01E5A" d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313z"/>
<path fill="#36C5F0" d="M8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312z"/>
<path fill="#2EB67D" d="M18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zM17.688 8.834a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312z"/>
<path fill="#ECB22E" d="M15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.528 2.528 0 0 1-2.52-2.522v-2.522h2.52zM15.165 17.688a2.528 2.528 0 0 1-2.52-2.523 2.528 2.528 0 0 1 2.52-2.52h6.313A2.528 2.528 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

76
frontend/src/App.tsx Normal file
View file

@ -0,0 +1,76 @@
import { Routes, Route } from 'react-router-dom'
import LandingPage from './pages/LandingPage'
import LoginPage from './pages/LoginPage'
import DashboardPage from './pages/DashboardPage'
import ShedPage from './pages/workspace/ShedPage'
import SiloPage from './pages/workspace/SiloPage'
import SketchPage from './pages/workspace/SketchPage'
import SteadPage from './pages/workspace/SteadPage'
import SteadCreatePage from './pages/workspace/SteadCreatePage'
import ForbiddenPage from './pages/ForbiddenPage'
import ProtectedRoute from './components/ProtectedRoute'
export default function App() {
return (
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/login" element={<LoginPage />} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<DashboardPage />
</ProtectedRoute>
}
/>
<Route
path="/forbidden"
element={
<ProtectedRoute>
<ForbiddenPage />
</ProtectedRoute>
}
/>
<Route
path="/workspace/shed/:id"
element={
<ProtectedRoute>
<ShedPage />
</ProtectedRoute>
}
/>
<Route
path="/workspace/silo/:id"
element={
<ProtectedRoute>
<SiloPage />
</ProtectedRoute>
}
/>
<Route
path="/workspace/sketch/:id"
element={
<ProtectedRoute>
<SketchPage />
</ProtectedRoute>
}
/>
<Route
path="/workspace/stead/new"
element={
<ProtectedRoute>
<SteadCreatePage />
</ProtectedRoute>
}
/>
<Route
path="/workspace/stead/:id"
element={
<ProtectedRoute>
<SteadPage />
</ProtectedRoute>
}
/>
</Routes>
)
}

View file

@ -0,0 +1,152 @@
import { motion } from 'framer-motion'
import { features } from '../data/landingData'
function FeaturePreview({ type }: { type: string }) {
switch (type) {
case 'editor':
return (
<div className="rounded-lg border border-silos-cream-dark bg-white p-3">
<div className="flex gap-1.5 border-b border-silos-cream-dark pb-2">
{['H1', 'H2', 'B', '•'].map((btn) => (
<span
key={btn}
className="rounded px-2 py-0.5 text-[10px] font-medium text-silos-muted bg-silos-cream"
>
{btn}
</span>
))}
</div>
<div className="mt-2 space-y-1.5">
<div className="h-2 w-3/4 rounded bg-silos-text/20" />
<div className="h-1.5 w-full rounded bg-silos-muted/20" />
<div className="h-1.5 w-5/6 rounded bg-silos-muted/20" />
</div>
</div>
)
case 'database':
return (
<div className="rounded-lg border border-silos-cream-dark bg-white p-3">
<div className="grid grid-cols-3 gap-1 text-[9px] font-medium text-silos-muted">
<span>Name</span>
<span>Status</span>
<span>Owner</span>
</div>
{[1, 2, 3].map((row) => (
<div key={row} className="mt-1 grid grid-cols-3 gap-1">
<div className="h-1.5 rounded bg-silos-cream" />
<div className="h-1.5 rounded bg-silos-tan/30" />
<div className="h-1.5 rounded bg-silos-cream" />
</div>
))}
</div>
)
case 'whiteboard':
return (
<div className="rounded-lg border border-silos-cream-dark bg-white p-3">
<svg viewBox="0 0 120 60" className="w-full">
<rect x="10" y="10" width="30" height="20" rx="3" fill="#C17D65" opacity="0.3" stroke="#6B3A2C" strokeWidth="1" />
<rect x="50" y="25" width="30" height="20" rx="3" fill="#6B3A2C" opacity="0.2" stroke="#6B3A2C" strokeWidth="1" />
<line x1="40" y1="20" x2="50" y2="35" stroke="#C17D65" strokeWidth="1.5" strokeDasharray="3 2" />
<circle cx="90" cy="20" r="8" fill="#C17D65" opacity="0.3" stroke="#6B3A2C" strokeWidth="1" />
</svg>
</div>
)
case 'kanban':
return (
<div className="rounded-lg border border-silos-cream-dark bg-white p-3">
<div className="flex gap-2">
{['To Do', 'In Progress', 'Done'].map((col, i) => (
<div key={col} className="flex-1">
<p className="text-[8px] font-medium text-silos-muted">{col}</p>
<div className="mt-1 space-y-1">
{Array.from({ length: i === 1 ? 2 : 1 }).map((_, j) => (
<div
key={j}
className={`h-4 rounded ${i === 2 ? 'bg-silos-tan/30' : 'bg-silos-cream'}`}
/>
))}
</div>
</div>
))}
</div>
</div>
)
case 'integrations':
return (
<div className="flex items-center justify-center gap-4 rounded-lg border border-silos-cream-dark bg-white p-4">
{['Slack', 'GitHub', 'Drive'].map((name) => (
<div
key={name}
className="flex h-8 w-8 items-center justify-center rounded-lg bg-silos-cream text-[8px] font-bold text-silos-muted"
>
{name[0]}
</div>
))}
</div>
)
case 'collaboration':
return (
<div className="flex items-center justify-center gap-2 rounded-lg border border-silos-cream-dark bg-white p-4">
<div className="flex -space-x-2">
{['#C17D65', '#6B3A2C', '#8B4D3B'].map((color, i) => (
<div
key={i}
className="flex h-7 w-7 items-center justify-center rounded-full border-2 border-white text-[9px] font-medium text-white"
style={{ backgroundColor: color }}
>
{['S', 'M', 'A'][i]}
</div>
))}
</div>
<span className="rounded-full bg-silos-cream px-2 py-0.5 text-[9px] text-silos-muted">
+3 people editing
</span>
</div>
)
default:
return null
}
}
export default function Features() {
return (
<section id="features" className="bg-white px-4 py-20 sm:px-6 lg:px-8">
<div className="mx-auto max-w-7xl">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="text-center"
>
<h2 className="font-display text-3xl font-bold text-silos-text sm:text-4xl">
Everything your team needs to work better
</h2>
<p className="mx-auto mt-4 max-w-2xl text-silos-muted">
From documents to databases, whiteboards to boards — all in one connected workspace.
</p>
</motion.div>
<div className="mt-14 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{features.map((feature, i) => (
<motion.div
key={feature.title}
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: i * 0.08 }}
whileHover={{ y: -4, transition: { duration: 0.2 } }}
className="group rounded-2xl border border-silos-cream-dark bg-silos-cream/50 p-6 transition-shadow hover:shadow-lg"
>
<FeaturePreview type={feature.preview} />
<h3 className="mt-4 text-lg font-semibold text-silos-text group-hover:text-silos-brown">
{feature.title}
</h3>
<p className="mt-2 text-sm text-silos-muted">{feature.description}</p>
</motion.div>
))}
</div>
</div>
</section>
)
}

View file

@ -0,0 +1,53 @@
import { Link } from 'react-router-dom'
const footerLinks = [
{ label: 'Privacy Policy', href: '#' },
{ label: 'Terms & Conditions', href: '#' },
{ label: 'Legal', href: '#' },
{ label: 'Contact Us', href: '#contact' },
]
export default function Footer() {
return (
<footer className="bg-silos-brown-dark">
{/* CTA bar */}
<div className="border-b border-white/10 bg-silos-brown px-4 py-10 sm:px-6 lg:px-8">
<div className="mx-auto flex max-w-7xl flex-col items-center text-center">
<h3 className="text-2xl font-semibold text-white">Ready to get started?</h3>
<Link
to="/login"
className="mt-5 rounded-full bg-white px-8 py-3 text-sm font-semibold text-silos-brown shadow-lg transition-all hover:bg-silos-cream hover:shadow-xl"
>
Get Started Free
</Link>
<p className="mt-3 text-sm text-white/60">No credit card required</p>
</div>
</div>
{/* Bottom bar */}
<div className="px-4 py-5 sm:px-6 lg:px-8">
<div className="mx-auto flex max-w-7xl flex-col items-center justify-between gap-4 sm:flex-row">
<p className="text-sm text-white/60">
&copy; {new Date().getFullYear()} Silos. All rights reserved.
</p>
<nav id="contact" className="flex flex-wrap items-center justify-center gap-1">
{footerLinks.map((link, i) => (
<span key={link.label} className="flex items-center">
<a
href={link.href}
className="px-2 text-sm text-white/70 transition-colors hover:text-white"
>
{link.label}
</a>
{i < footerLinks.length - 1 && (
<span className="text-white/30">|</span>
)}
</span>
))}
</nav>
</div>
</div>
</footer>
)
}

View file

@ -0,0 +1,290 @@
import { useState, useRef, useEffect } from 'react'
import { Link } from 'react-router-dom'
import {
ChevronDown,
Search,
X,
FileText,
LayoutGrid,
Database,
PenTool,
Plug,
BookOpen,
LayoutTemplate,
Users,
Newspaper,
HelpCircle,
type LucideIcon,
} from 'lucide-react'
import { logos } from '../data/brandAssets'
import { products, resources, productDetails } from '../data/landingData'
const iconMap: Record<string, LucideIcon> = {
FileText,
LayoutGrid,
Database,
PenTool,
Plug,
BookOpen,
LayoutTemplate,
Users,
Newspaper,
HelpCircle,
}
type DropdownItem = {
name: string
description: string
icon: string
}
function DropdownMenu({
label,
items,
isOpen,
onToggle,
onClose,
}: {
label: string
items: readonly DropdownItem[]
isOpen: boolean
onToggle: () => void
onClose: () => void
}) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose()
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [isOpen, onClose])
return (
<div ref={ref} className="relative">
<button
onClick={onToggle}
className="flex items-center gap-1 rounded-lg px-3 py-2 text-sm font-medium text-white/90 transition-colors hover:bg-white/10 hover:text-white"
>
{label}
<ChevronDown
className={`h-4 w-4 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
/>
</button>
{isOpen && (
<div className="absolute left-0 top-full z-50 mt-2 w-80 overflow-hidden rounded-xl border border-white/10 bg-silos-brown-dark shadow-2xl">
<div className="p-2">
{items.map((item) => {
const Icon = iconMap[item.icon]
return (
<button
key={item.name}
onClick={onClose}
className="flex w-full items-start gap-3 rounded-lg p-3 text-left transition-colors hover:bg-white/10"
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-white/10">
{Icon && <Icon className="h-4 w-4 text-silos-tan" />}
</div>
<div>
<p className="text-sm font-medium text-white">{item.name}</p>
<p className="mt-0.5 text-xs text-white/60">{item.description}</p>
</div>
</button>
)
})}
</div>
</div>
)}
</div>
)
}
function ProductDetailsDropdown({
isOpen,
onToggle,
onClose,
}: {
isOpen: boolean
onToggle: () => void
onClose: () => void
}) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose()
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [isOpen, onClose])
return (
<div ref={ref} className="relative">
<button
onClick={onToggle}
className="flex items-center gap-1 rounded-lg px-3 py-2 text-sm font-medium text-white/90 transition-colors hover:bg-white/10 hover:text-white"
>
Product Details
<ChevronDown
className={`h-4 w-4 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
/>
</button>
{isOpen && (
<div className="absolute left-0 top-full z-50 mt-2 w-72 overflow-hidden rounded-xl border border-white/10 bg-silos-brown-dark shadow-2xl">
<div className="p-3">
{productDetails.map((item) => (
<button
key={item.title}
onClick={onClose}
className="block w-full rounded-lg p-3 text-left transition-colors hover:bg-white/10"
>
<p className="text-sm font-medium text-white">{item.title}</p>
<p className="mt-0.5 text-xs text-white/60">{item.description}</p>
</button>
))}
</div>
</div>
)}
</div>
)
}
function SearchOverlay({
isOpen,
onClose,
}: {
isOpen: boolean
onClose: () => void
}) {
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (isOpen) {
inputRef.current?.focus()
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
return () => {
document.body.style.overflow = ''
}
}, [isOpen])
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') onClose()
}
if (isOpen) {
document.addEventListener('keydown', handleKeyDown)
}
return () => document.removeEventListener('keydown', handleKeyDown)
}, [isOpen, onClose])
if (!isOpen) return null
return (
<div className="fixed inset-0 z-[100] flex items-start justify-center bg-black/50 pt-24 backdrop-blur-sm">
<div className="mx-4 w-full max-w-2xl">
<div className="overflow-hidden rounded-2xl bg-white shadow-2xl">
<div className="flex items-center gap-3 border-b border-silos-cream-dark px-5 py-4">
<Search className="h-5 w-5 text-silos-muted" />
<input
ref={inputRef}
type="text"
placeholder="Search Silos..."
className="flex-1 bg-transparent text-lg text-silos-text outline-none placeholder:text-silos-muted"
/>
<button
onClick={onClose}
className="rounded-lg p-1.5 text-silos-muted transition-colors hover:bg-silos-cream hover:text-silos-text"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="px-5 py-4">
<p className="text-sm text-silos-muted">
Search for docs, spaces, templates, and help articles...
</p>
</div>
</div>
</div>
</div>
)
}
export default function Header() {
const [openDropdown, setOpenDropdown] = useState<string | null>(null)
const [searchOpen, setSearchOpen] = useState(false)
const closeAll = () => setOpenDropdown(null)
return (
<>
<header className="sticky top-0 z-40 bg-silos-brown shadow-lg">
<div className="mx-auto flex max-w-7xl items-center justify-between gap-3 px-4 py-2 sm:px-6 lg:px-8">
<Link to="/" className="shrink-0 leading-none">
<img src={logos.white} alt="Silos" className="block h-9 w-auto sm:h-10" />
</Link>
<nav className="hidden items-center gap-1 lg:flex">
<DropdownMenu
label="Products"
items={products}
isOpen={openDropdown === 'products'}
onToggle={() =>
setOpenDropdown(openDropdown === 'products' ? null : 'products')
}
onClose={closeAll}
/>
<DropdownMenu
label="Resources"
items={resources}
isOpen={openDropdown === 'resources'}
onToggle={() =>
setOpenDropdown(openDropdown === 'resources' ? null : 'resources')
}
onClose={closeAll}
/>
<ProductDetailsDropdown
isOpen={openDropdown === 'details'}
onToggle={() =>
setOpenDropdown(openDropdown === 'details' ? null : 'details')
}
onClose={closeAll}
/>
</nav>
<div className="flex items-center gap-3">
<button
onClick={() => setSearchOpen(true)}
className="flex items-center gap-2 rounded-full border border-white/20 bg-white/10 px-3 py-1.5 text-sm text-white/80 transition-all hover:border-white/30 hover:bg-white/15 hover:text-white"
>
<Search className="h-4 w-4" />
<span className="hidden sm:inline">Search Silos...</span>
</button>
<Link
to="/login"
className="rounded-full border border-white/30 bg-white/10 px-4 py-1.5 text-sm font-medium text-white transition-all hover:bg-white hover:text-silos-brown"
>
Sign Up
</Link>
</div>
</div>
</header>
<SearchOverlay isOpen={searchOpen} onClose={() => setSearchOpen(false)} />
</>
)
}

View file

@ -0,0 +1,164 @@
import { Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import { Users, FileText, Handshake, Shield } from 'lucide-react'
import { logos } from '../data/brandAssets'
import { stats } from '../data/landingData'
const statIcons = {
Users,
FileText,
Handshake,
Shield,
}
function DashboardPreview() {
return (
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 0.3 }}
className="relative"
>
<div className="overflow-hidden rounded-2xl border border-silos-cream-dark bg-white shadow-2xl">
<div className="flex">
{/* Sidebar */}
<div className="w-44 shrink-0 bg-silos-brown p-4">
<img src={logos.white} alt="Silos" className="mb-6 h-6 w-auto" />
<nav className="space-y-1">
{['Home', 'Spaces', 'Pages', 'Boards', 'Whiteboards'].map((item, i) => (
<div
key={item}
className={`rounded-lg px-3 py-2 text-xs ${
i === 0
? 'bg-white/15 font-medium text-white'
: 'text-white/60'
}`}
>
{item}
</div>
))}
</nav>
</div>
{/* Main content */}
<div className="flex-1 p-5">
<p className="text-sm font-semibold text-silos-text">Welcome back, Alex</p>
<p className="mt-0.5 text-xs text-silos-muted">Here's what's happening in your workspace</p>
<div className="mt-4 grid grid-cols-2 gap-3">
<div className="rounded-lg border border-silos-cream-dark p-3">
<p className="text-xs font-medium text-silos-text">Recent Pages</p>
<div className="mt-2 space-y-1.5">
{['Q4 Planning', 'API Docs', 'Team Handbook'].map((page) => (
<div key={page} className="flex items-center gap-2">
<div className="h-1.5 w-1.5 rounded-full bg-silos-tan" />
<span className="text-[10px] text-silos-muted">{page}</span>
</div>
))}
</div>
</div>
<div className="rounded-lg border border-silos-cream-dark p-3">
<p className="text-xs font-medium text-silos-text">Team Activity</p>
<div className="mt-2 space-y-1.5">
{['Sarah edited Roadmap', 'Mike added comment'].map((act) => (
<div key={act} className="text-[10px] text-silos-muted">{act}</div>
))}
</div>
</div>
<div className="rounded-lg border border-silos-cream-dark p-3">
<p className="text-xs font-medium text-silos-text">My Tasks</p>
<div className="mt-2 flex gap-2">
{['To Do', 'In Progress', 'Done'].map((col, i) => (
<div key={col} className="flex-1">
<p className="text-[9px] font-medium text-silos-muted">{col}</p>
<div className={`mt-1 h-8 rounded ${i === 1 ? 'bg-silos-tan/20' : 'bg-silos-cream'}`} />
</div>
))}
</div>
</div>
<div className="rounded-lg border border-silos-cream-dark p-3">
<p className="text-xs font-medium text-silos-text">Pinned Spaces</p>
<div className="mt-2 flex gap-1.5">
{['Engineering', 'Design', 'Product'].map((space) => (
<span
key={space}
className="rounded-full bg-silos-cream px-2 py-0.5 text-[9px] text-silos-muted"
>
{space}
</span>
))}
</div>
</div>
</div>
</div>
</div>
</div>
<div className="absolute -bottom-4 -right-4 -z-10 h-full w-full rounded-2xl bg-silos-tan/20" />
</motion.div>
)
}
export default function Hero() {
return (
<section className="relative overflow-hidden bg-silos-cream px-4 py-16 sm:px-6 lg:px-8 lg:py-24">
<div className="mx-auto grid max-w-7xl items-center gap-12 lg:grid-cols-2 lg:gap-16">
<motion.div
initial={{ opacity: 0, x: -30 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.6 }}
>
<h1 className="font-display text-4xl font-bold leading-tight text-silos-text sm:text-5xl lg:text-[3.25rem]">
Your knowledge.
<br />
Your team.
<br />
One place.
</h1>
<p className="mt-6 max-w-lg text-lg leading-relaxed text-silos-muted">
Silos is the connected workspace where teams create, organize and share
knowledge effortlessly.
</p>
<div className="mt-8 flex flex-wrap gap-4">
<Link
to="/login"
className="rounded-full bg-silos-brown px-8 py-3 text-sm font-semibold text-white shadow-lg transition-all hover:bg-silos-brown-dark hover:shadow-xl"
>
Get Started
</Link>
<a
href="#features"
className="rounded-full border-2 border-silos-brown px-8 py-3 text-sm font-semibold text-silos-brown transition-all hover:bg-silos-brown hover:text-white"
>
Learn More
</a>
</div>
<div className="mt-12 grid grid-cols-2 gap-6 sm:grid-cols-4">
{stats.map((stat, i) => {
const Icon = statIcons[stat.icon as keyof typeof statIcons]
return (
<motion.div
key={stat.label}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.5 + i * 0.1 }}
className="flex flex-col items-center text-center sm:items-start sm:text-left"
>
<Icon className="mb-2 h-5 w-5 text-silos-tan" />
<span className="text-sm font-semibold text-silos-text">{stat.label}</span>
</motion.div>
)
})}
</div>
</motion.div>
<DashboardPreview />
</div>
</section>
)
}

View file

@ -0,0 +1,14 @@
import { type ReactNode } from 'react'
import { Navigate, useLocation } from 'react-router-dom'
import { getStoredUser } from '../lib/api'
export default function ProtectedRoute({ children }: { children: ReactNode }) {
const location = useLocation()
const user = getStoredUser()
if (!user) {
return <Navigate to="/login" state={{ from: location }} replace />
}
return <>{children}</>
}

View file

@ -0,0 +1,148 @@
import { useState, useEffect } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import {
FilePlus,
FolderOpen,
Users,
Share2,
Flag,
type LucideIcon,
} from 'lucide-react'
import { workflowSteps } from '../data/landingData'
const stepIcons: Record<string, LucideIcon> = {
FilePlus,
FolderOpen,
Users,
Share2,
Flag,
}
export default function Workflow() {
const [activeStep, setActiveStep] = useState(0)
useEffect(() => {
const interval = setInterval(() => {
setActiveStep((prev) => (prev + 1) % workflowSteps.length)
}, 2500)
return () => clearInterval(interval)
}, [])
return (
<section className="bg-silos-cream px-4 py-20 sm:px-6 lg:px-8">
<div className="mx-auto max-w-7xl">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="text-center"
>
<h2 className="font-display text-3xl font-bold text-silos-text sm:text-4xl">
A workflow that flows with you
</h2>
<p className="mx-auto mt-4 max-w-2xl text-silos-muted">
From idea to execution — Silos adapts to how your team actually works.
</p>
</motion.div>
<div className="mt-16 hidden lg:block">
<div className="relative flex items-start justify-between">
{workflowSteps.map((step, i) => {
const Icon = stepIcons[step.icon]
const isActive = activeStep === i
const isPast = i < activeStep
return (
<div key={step.title} className="relative flex flex-1 flex-col items-center">
{i < workflowSteps.length - 1 && (
<div className="absolute left-[calc(50%+28px)] top-7 h-0.5 w-[calc(100%-56px)]">
<div className="h-full w-full bg-silos-cream-dark" />
<motion.div
className="absolute inset-0 h-full origin-left bg-silos-tan"
initial={{ scaleX: 0 }}
animate={{ scaleX: isPast ? 1 : isActive ? 0.5 : 0 }}
transition={{ duration: 0.8 }}
/>
</div>
)}
<motion.button
onClick={() => setActiveStep(i)}
className={`relative z-10 flex h-14 w-14 items-center justify-center rounded-full border-2 transition-colors ${
isActive
? 'border-silos-brown bg-silos-brown text-white shadow-lg'
: isPast
? 'border-silos-tan bg-silos-tan text-white'
: 'border-silos-cream-dark bg-white text-silos-muted'
}`}
animate={isActive ? { scale: [1, 1.1, 1] } : { scale: 1 }}
transition={{ duration: 0.6, repeat: isActive ? Infinity : 0, repeatDelay: 1.5 }}
>
<Icon className="h-6 w-6" />
</motion.button>
<AnimatePresence mode="wait">
<motion.div
key={isActive ? 'active' : 'inactive'}
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -5 }}
className="mt-4 text-center"
>
<p
className={`text-sm font-semibold ${
isActive ? 'text-silos-brown' : 'text-silos-text'
}`}
>
{step.title}
</p>
<p className="mt-1 max-w-[140px] text-xs text-silos-muted">
{step.description}
</p>
</motion.div>
</AnimatePresence>
</div>
)
})}
</div>
</div>
{/* Mobile workflow */}
<div className="mt-12 space-y-6 lg:hidden">
{workflowSteps.map((step, i) => {
const Icon = stepIcons[step.icon]
const isActive = activeStep === i
return (
<motion.div
key={step.title}
initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ delay: i * 0.1 }}
className={`flex items-center gap-4 rounded-xl p-4 transition-colors ${
isActive ? 'bg-white shadow-md' : 'bg-white/50'
}`}
>
<div
className={`flex h-12 w-12 shrink-0 items-center justify-center rounded-full ${
isActive
? 'bg-silos-brown text-white'
: 'bg-silos-cream text-silos-muted'
}`}
>
<Icon className="h-5 w-5" />
</div>
<div>
<p className="font-semibold text-silos-text">{step.title}</p>
<p className="text-sm text-silos-muted">{step.description}</p>
</div>
</motion.div>
)
})}
</div>
</div>
</section>
)
}

View file

@ -0,0 +1,49 @@
import { Link } from 'react-router-dom'
const footerLinks = [
{ label: 'Privacy Policy', href: '#' },
{ label: 'Terms & Conditions', href: '#' },
{ label: 'Legal', href: '#' },
{ label: 'Contact Us', href: '#contact' },
{ label: 'Support', href: '#support' },
]
export default function AuthFooter() {
return (
<footer className="bg-silos-brown-dark">
<div className="px-4 py-5 sm:px-6 lg:px-8">
<div className="mx-auto flex max-w-7xl flex-col items-center justify-between gap-4 lg:flex-row">
<p className="text-sm text-white/60 lg:flex-1">
&copy; {new Date().getFullYear()} Silos. All rights reserved.
</p>
<Link
to="/login"
className="rounded-full border border-white/40 px-6 py-2 text-sm font-medium text-white transition-colors hover:border-white hover:bg-white/10"
>
Get Started
</Link>
<nav
id="support"
className="flex flex-wrap items-center justify-center gap-1 lg:flex-1 lg:justify-end"
>
{footerLinks.map((link, i) => (
<span key={link.label} className="flex items-center">
<a
href={link.href}
className="px-2 text-sm text-white/70 transition-colors hover:text-white"
>
{link.label}
</a>
{i < footerLinks.length - 1 && (
<span className="text-white/30">|</span>
)}
</span>
))}
</nav>
</div>
</div>
</footer>
)
}

View file

@ -0,0 +1,83 @@
import { Link } from 'react-router-dom'
import { Shield, Users, LayoutGrid } from 'lucide-react'
import { logos } from '../../data/brandAssets'
const features = [
{
icon: Shield,
title: 'Secure & Private',
description: 'Your data is protected with enterprise-grade security',
},
{
icon: Users,
title: 'Seamless Collaboration',
description: 'Work together in real time across your entire team',
},
{
icon: LayoutGrid,
title: 'All in One Place',
description: 'Docs, boards, databases, and whiteboards unified',
},
]
function AuthIllustration() {
return (
<svg viewBox="0 0 320 200" className="w-full max-w-sm opacity-80" aria-hidden>
<rect x="20" y="30" width="100" height="70" rx="8" fill="#C17D65" opacity="0.25" stroke="#6B3A2C" strokeWidth="1.5" />
<rect x="40" y="50" width="60" height="8" rx="2" fill="#6B3A2C" opacity="0.3" />
<rect x="40" y="65" width="45" height="6" rx="2" fill="#6B3A2C" opacity="0.2" />
<rect x="40" y="78" width="50" height="6" rx="2" fill="#6B3A2C" opacity="0.2" />
<rect x="200" y="20" width="90" height="60" rx="8" fill="#C17D65" opacity="0.2" stroke="#6B3A2C" strokeWidth="1.5" />
<circle cx="245" cy="50" r="12" fill="#6B3A2C" opacity="0.25" />
<path d="M120 65 L200 50" stroke="#C17D65" strokeWidth="2" strokeDasharray="4 3" />
<path d="M120 85 L200 70" stroke="#C17D65" strokeWidth="2" strokeDasharray="4 3" />
<rect x="130" y="100" width="70" height="80" rx="8" fill="#6B3A2C" opacity="0.15" stroke="#6B3A2C" strokeWidth="1.5" />
<rect x="145" y="115" width="40" height="30" rx="4" fill="#C17D65" opacity="0.4" />
<circle cx="165" cy="160" r="8" fill="none" stroke="#6B3A2C" strokeWidth="2" />
<rect x="161" y="164" width="8" height="10" rx="2" fill="#6B3A2C" opacity="0.4" />
<path d="M80 100 Q100 120 130 100" stroke="#C17D65" strokeWidth="1.5" fill="none" />
<path d="M270 80 Q250 100 230 90" stroke="#C17D65" strokeWidth="1.5" fill="none" />
</svg>
)
}
export default function AuthSidebar() {
return (
<aside className="hidden w-full max-w-xs shrink-0 flex-col py-8 pl-8 pr-4 xl:flex xl:max-w-sm xl:self-start xl:py-10 xl:pl-12">
<Link to="/">
<img src={logos.rustyBrown} alt="Silos" className="h-9 w-auto" />
</Link>
<div className="mt-10">
<h1 className="text-3xl font-bold leading-tight text-silos-text">
Welcome to Silos
</h1>
<p className="mt-3 text-sm leading-relaxed text-silos-muted">
Your knowledge. Your team. One secure workspace.
</p>
<div className="my-8">
<AuthIllustration />
</div>
<ul className="space-y-5">
{features.map(({ icon: Icon, title, description }) => (
<li key={title} className="flex gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-silos-brown/10">
<Icon className="h-4 w-4 text-silos-brown" />
</div>
<div>
<p className="text-sm font-semibold text-silos-text">{title}</p>
<p className="mt-0.5 text-xs text-silos-muted">{description}</p>
</div>
</li>
))}
</ul>
</div>
</aside>
)
}

View file

@ -0,0 +1,65 @@
import { ArrowLeft, Mail } from 'lucide-react'
interface ForgotPasswordFormProps {
email: string
onEmailChange: (v: string) => void
onSubmit: () => void
onBack: () => void
}
export default function ForgotPasswordForm({
email,
onEmailChange,
onSubmit,
onBack,
}: ForgotPasswordFormProps) {
return (
<form
onSubmit={(e) => {
e.preventDefault()
onSubmit()
}}
className="space-y-5"
>
<button
type="button"
onClick={onBack}
className="flex items-center gap-1.5 text-sm text-silos-muted hover:text-silos-brown"
>
<ArrowLeft className="h-4 w-4" />
Back to login
</button>
<div>
<h2 className="text-xl font-semibold text-silos-text">Forgot password?</h2>
<p className="mt-1 text-sm text-silos-muted">
Enter your email and we&apos;ll send you a reset link.
</p>
</div>
<div>
<label htmlFor="forgot-email" className="mb-1.5 block text-sm font-medium text-silos-text">
Email
</label>
<div className="relative">
<Mail className="absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-silos-muted" />
<input
id="forgot-email"
type="email"
value={email}
onChange={(e) => onEmailChange(e.target.value)}
placeholder="you@company.com"
className="w-full rounded-xl border border-silos-cream-dark bg-white py-3 pl-10 pr-4 text-sm text-silos-text outline-none transition-colors focus:border-silos-tan focus:ring-2 focus:ring-silos-tan/20"
/>
</div>
</div>
<button
type="submit"
className="w-full rounded-xl bg-silos-brown py-3 text-sm font-semibold text-white transition-colors hover:bg-silos-brown-dark"
>
Send Reset Link
</button>
</form>
)
}

View file

@ -0,0 +1,104 @@
import { User } from 'lucide-react'
import PasswordField from './PasswordField'
import SSOButtons from './SSOButtons'
interface LoginFormProps {
username: string
password: string
rememberMe: boolean
onUsernameChange: (v: string) => void
onPasswordChange: (v: string) => void
onRememberMeChange: (v: boolean) => void
onSubmit: () => void
onForgotPassword: () => void
onSSOSelect: (providerId: string) => void
}
export default function LoginForm({
username,
password,
rememberMe,
onUsernameChange,
onPasswordChange,
onRememberMeChange,
onSubmit,
onForgotPassword,
onSSOSelect,
}: LoginFormProps) {
return (
<form
onSubmit={(e) => {
e.preventDefault()
onSubmit()
}}
className="space-y-5"
>
<div>
<h2 className="text-xl font-semibold text-silos-text">Login to your account</h2>
<p className="mt-1 text-sm text-silos-muted">
Welcome back! Please enter your details.
</p>
</div>
<div>
<label htmlFor="login-username" className="mb-1.5 block text-sm font-medium text-silos-text">
Username or Email
</label>
<div className="relative">
<User className="absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-silos-muted" />
<input
id="login-username"
type="text"
value={username}
onChange={(e) => onUsernameChange(e.target.value)}
placeholder="Enter your username or email"
className="w-full rounded-xl border border-silos-cream-dark bg-white py-3 pl-10 pr-4 text-sm text-silos-text outline-none transition-colors focus:border-silos-tan focus:ring-2 focus:ring-silos-tan/20"
/>
</div>
</div>
<PasswordField
id="login-password"
value={password}
onChange={onPasswordChange}
/>
<div className="flex items-center justify-between">
<label className="flex cursor-pointer items-center gap-2 text-sm text-silos-muted">
<input
type="checkbox"
checked={rememberMe}
onChange={(e) => onRememberMeChange(e.target.checked)}
className="h-4 w-4 rounded border-silos-cream-dark text-silos-brown focus:ring-silos-tan"
/>
Remember me
</label>
<button
type="button"
onClick={onForgotPassword}
className="text-sm font-medium text-silos-tan hover:text-silos-brown"
>
Forgot password?
</button>
</div>
<button
type="submit"
className="w-full rounded-xl bg-silos-brown py-3 text-sm font-semibold text-white transition-colors hover:bg-silos-brown-dark"
>
Login
</button>
<div className="relative py-2">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-silos-cream-dark" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white px-3 text-silos-muted">or continue with</span>
</div>
</div>
<SSOButtons onSelect={onSSOSelect} compact />
</form>
)
}

View file

@ -0,0 +1,47 @@
import { useState } from 'react'
import { Eye, EyeOff, Lock } from 'lucide-react'
interface PasswordFieldProps {
label?: string
value: string
onChange: (value: string) => void
placeholder?: string
id?: string
}
export default function PasswordField({
label = 'Password',
value,
onChange,
placeholder = 'Enter your password',
id = 'password',
}: PasswordFieldProps) {
const [show, setShow] = useState(false)
return (
<div>
<label htmlFor={id} className="mb-1.5 block text-sm font-medium text-silos-text">
{label}
</label>
<div className="relative">
<Lock className="absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-silos-muted" />
<input
id={id}
type={show ? 'text' : 'password'}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="w-full rounded-xl border border-silos-cream-dark bg-white py-3 pl-10 pr-12 text-sm text-silos-text outline-none transition-colors focus:border-silos-tan focus:ring-2 focus:ring-silos-tan/20"
/>
<button
type="button"
onClick={() => setShow(!show)}
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-silos-muted hover:text-silos-text"
aria-label={show ? 'Hide password' : 'Show password'}
>
{show ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
)
}

View file

@ -0,0 +1,52 @@
import { KeyRound, UserPlus } from 'lucide-react'
interface QuickActionsProps {
onResetPassword: () => void
onCreateAccount: () => void
}
export default function QuickActions({
onResetPassword,
onCreateAccount,
}: QuickActionsProps) {
return (
<div className="space-y-6">
<div>
<h3 className="text-sm font-semibold text-silos-text">Quick Actions</h3>
<div className="mt-3 space-y-2">
<button
type="button"
onClick={onResetPassword}
className="flex w-full items-start gap-3 rounded-xl border border-silos-cream-dark bg-silos-cream/30 p-4 text-left transition-colors hover:border-silos-tan hover:bg-silos-cream/60"
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-silos-brown/10">
<KeyRound className="h-4 w-4 text-silos-brown" />
</div>
<div>
<p className="text-sm font-semibold text-silos-text">Reset Password</p>
<p className="mt-0.5 text-xs text-silos-muted">
Reset your account password securely
</p>
</div>
</button>
<button
type="button"
onClick={onCreateAccount}
className="flex w-full items-start gap-3 rounded-xl border border-silos-cream-dark bg-silos-cream/30 p-4 text-left transition-colors hover:border-silos-tan hover:bg-silos-cream/60"
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-silos-brown/10">
<UserPlus className="h-4 w-4 text-silos-brown" />
</div>
<div>
<p className="text-sm font-semibold text-silos-text">Create New Account</p>
<p className="mt-0.5 text-xs text-silos-muted">
Join Silos and start your journey today
</p>
</div>
</button>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,69 @@
import { ArrowLeft } from 'lucide-react'
import PasswordField from './PasswordField'
interface ResetPasswordFormProps {
newPassword: string
confirmPassword: string
onNewPasswordChange: (v: string) => void
onConfirmPasswordChange: (v: string) => void
onSubmit: () => void
onBack: () => void
}
export default function ResetPasswordForm({
newPassword,
confirmPassword,
onNewPasswordChange,
onConfirmPasswordChange,
onSubmit,
onBack,
}: ResetPasswordFormProps) {
return (
<form
onSubmit={(e) => {
e.preventDefault()
onSubmit()
}}
className="space-y-5"
>
<button
type="button"
onClick={onBack}
className="flex items-center gap-1.5 text-sm text-silos-muted hover:text-silos-brown"
>
<ArrowLeft className="h-4 w-4" />
Back to login
</button>
<div>
<h2 className="text-xl font-semibold text-silos-text">Reset your password</h2>
<p className="mt-1 text-sm text-silos-muted">
Enter a new password for your account.
</p>
</div>
<PasswordField
id="reset-new-password"
label="New Password"
value={newPassword}
onChange={onNewPasswordChange}
placeholder="Enter new password"
/>
<PasswordField
id="reset-confirm-password"
label="Confirm New Password"
value={confirmPassword}
onChange={onConfirmPasswordChange}
placeholder="Confirm new password"
/>
<button
type="submit"
className="w-full rounded-xl bg-silos-brown py-3 text-sm font-semibold text-white transition-colors hover:bg-silos-brown-dark"
>
Reset Password
</button>
</form>
)
}

View file

@ -0,0 +1,34 @@
import { ssoProviders } from '../../data/authData'
interface SSOButtonsProps {
onSelect: (providerId: string) => void
selectedProvider?: string | null
compact?: boolean
}
export default function SSOButtons({ onSelect, selectedProvider, compact = false }: SSOButtonsProps) {
return (
<div className={`grid gap-2 ${compact ? 'grid-cols-2 sm:grid-cols-3' : 'grid-cols-2 sm:grid-cols-3'}`}>
{ssoProviders.map((provider) => (
<button
key={provider.id}
type="button"
onClick={() => onSelect(provider.id)}
className={`flex items-center justify-center gap-2 rounded-xl border bg-white text-sm font-medium transition-all hover:shadow-md ${
selectedProvider === provider.id
? 'border-silos-brown bg-silos-brown/5 text-silos-brown ring-2 ring-silos-brown/20'
: 'border-silos-cream-dark text-silos-text hover:border-silos-tan'
} ${compact ? 'px-3 py-2' : 'px-4 py-2.5'}`}
>
<img
src={provider.logo}
alt=""
className="h-5 w-5 shrink-0 object-contain"
aria-hidden
/>
{provider.name}
</button>
))}
</div>
)
}

View file

@ -0,0 +1,64 @@
import SSOButtons from './SSOButtons'
import UserInfoFields from './UserInfoFields'
import type { UserInfo } from '../../data/authData'
interface SSOFormProps {
selectedProvider: string | null
userInfo: UserInfo
onProviderSelect: (providerId: string) => void
onUserInfoChange: (field: keyof UserInfo, value: string) => void
onSubmit: () => void
}
export default function SSOForm({
selectedProvider,
userInfo,
onProviderSelect,
onUserInfoChange,
onSubmit,
}: SSOFormProps) {
return (
<form
onSubmit={(e) => {
e.preventDefault()
onSubmit()
}}
className="space-y-5"
>
<div>
<h2 className="text-xl font-semibold text-silos-text">SSO Login</h2>
<p className="mt-1 text-sm text-silos-muted">
Sign in with your organization&apos;s identity provider.
</p>
</div>
<SSOButtons
onSelect={onProviderSelect}
selectedProvider={selectedProvider}
/>
{selectedProvider && (
<p className="rounded-lg bg-silos-brown/5 px-3 py-2 text-sm text-silos-brown">
Continuing with{' '}
<span className="font-semibold capitalize">{selectedProvider}</span>
</p>
)}
<div className="rounded-xl border border-silos-cream-dark bg-silos-cream/40 p-4">
<p className="mb-3 text-sm font-semibold text-silos-text">User Information</p>
<p className="mb-3 text-xs text-silos-muted">
Complete your profile after SSO authentication.
</p>
<UserInfoFields values={userInfo} onChange={onUserInfoChange} compact />
</div>
<button
type="submit"
disabled={!selectedProvider}
className="w-full rounded-xl bg-silos-brown py-3 text-sm font-semibold text-white transition-colors hover:bg-silos-brown-dark disabled:cursor-not-allowed disabled:opacity-50"
>
Continue with SSO
</button>
</form>
)
}

View file

@ -0,0 +1,90 @@
import { AtSign } from 'lucide-react'
import PasswordField from './PasswordField'
import UserInfoFields from './UserInfoFields'
import type { UserInfo } from '../../data/authData'
interface SignUpFormProps {
email: string
password: string
confirmPassword: string
userInfo: UserInfo
onEmailChange: (v: string) => void
onPasswordChange: (v: string) => void
onConfirmPasswordChange: (v: string) => void
onUserInfoChange: (field: keyof UserInfo, value: string) => void
onSubmit: () => void
}
export default function SignUpForm({
email,
password,
confirmPassword,
userInfo,
onEmailChange,
onPasswordChange,
onConfirmPasswordChange,
onUserInfoChange,
onSubmit,
}: SignUpFormProps) {
return (
<form
onSubmit={(e) => {
e.preventDefault()
onSubmit()
}}
className="space-y-5"
>
<div>
<h2 className="text-xl font-semibold text-silos-text">Create your account</h2>
<p className="mt-1 text-sm text-silos-muted">
Join Silos and start your journey today.
</p>
</div>
<div>
<label htmlFor="signup-email" className="mb-1.5 block text-sm font-medium text-silos-text">
Email
</label>
<div className="relative">
<AtSign className="absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-silos-muted" />
<input
id="signup-email"
type="email"
value={email}
onChange={(e) => onEmailChange(e.target.value)}
placeholder="you@company.com"
className="w-full rounded-xl border border-silos-cream-dark bg-white py-3 pl-10 pr-4 text-sm text-silos-text outline-none transition-colors focus:border-silos-tan focus:ring-2 focus:ring-silos-tan/20"
/>
</div>
</div>
<PasswordField
id="signup-password"
label="Password"
value={password}
onChange={onPasswordChange}
placeholder="Create a strong password"
/>
<PasswordField
id="signup-confirm-password"
label="Confirm Password"
value={confirmPassword}
onChange={onConfirmPasswordChange}
placeholder="Confirm your password"
/>
<div className="rounded-xl border border-silos-cream-dark bg-silos-cream/40 p-4">
<p className="mb-3 text-sm font-semibold text-silos-text">User Information</p>
<UserInfoFields values={userInfo} onChange={onUserInfoChange} compact />
</div>
<button
type="submit"
className="w-full rounded-xl bg-silos-brown py-3 text-sm font-semibold text-white transition-colors hover:bg-silos-brown-dark"
>
Create Account
</button>
</form>
)
}

View file

@ -0,0 +1,78 @@
import { User, AtSign, Hash, Phone, Briefcase } from 'lucide-react'
import type { UserInfo } from '../../data/authData'
interface UserInfoFieldsProps {
values: UserInfo
onChange: (field: keyof UserInfo, value: string) => void
compact?: boolean
}
const fields: {
key: keyof UserInfo
label: string
placeholder: string
icon: typeof User
type?: string
}[] = [
{ key: 'fullName', label: 'Full Name', placeholder: 'Enter your full name', icon: User },
{ key: 'username', label: 'Username', placeholder: 'Choose a username', icon: AtSign },
{ key: 'employeeId', label: 'Employee ID', placeholder: 'Enter your employee ID', icon: Hash },
{ key: 'phone', label: 'Phone Number', placeholder: 'Enter your phone number', icon: Phone, type: 'tel' },
{ key: 'occupation', label: 'Occupation', placeholder: 'Enter your occupation', icon: Briefcase },
]
export default function UserInfoFields({ values, onChange, compact = false }: UserInfoFieldsProps) {
return (
<div className={compact ? 'space-y-3' : 'space-y-4'}>
{fields.map(({ key, label, placeholder, icon: Icon, type = 'text' }) => (
<div key={key}>
<label
htmlFor={`user-${key}`}
className={`mb-1.5 block font-medium text-silos-text ${compact ? 'text-xs' : 'text-sm'}`}
>
{label}
</label>
<div className="relative">
<Icon
className={`absolute left-3.5 top-1/2 -translate-y-1/2 text-silos-muted ${compact ? 'h-3.5 w-3.5' : 'h-4 w-4'}`}
/>
<input
id={`user-${key}`}
type={type}
value={values[key]}
onChange={(e) => onChange(key, e.target.value)}
placeholder={placeholder}
className={`w-full rounded-xl border border-silos-cream-dark bg-white text-silos-text outline-none transition-colors focus:border-silos-tan focus:ring-2 focus:ring-silos-tan/20 ${
compact ? 'py-2 pl-9 pr-3 text-xs' : 'py-3 pl-10 pr-4 text-sm'
}`}
/>
</div>
</div>
))}
</div>
)
}
export function UserInfoList({ values }: { values: UserInfo }) {
const items = [
{ label: 'Full Name', value: values.fullName, icon: User },
{ label: 'Username', value: values.username, icon: AtSign },
{ label: 'Employee ID', value: values.employeeId, icon: Hash },
{ label: 'Phone Number', value: values.phone, icon: Phone },
{ label: 'Occupation', value: values.occupation, icon: Briefcase },
]
return (
<ul className="space-y-2.5">
{items.map(({ label, value, icon: Icon }) => (
<li key={label} className="flex items-center gap-2.5 text-sm">
<Icon className="h-3.5 w-3.5 shrink-0 text-silos-muted" />
<span className="text-silos-muted">{label}:</span>
<span className="truncate font-medium text-silos-text">
{value || '—'}
</span>
</li>
))}
</ul>
)
}

View file

@ -0,0 +1,176 @@
import { useState, useRef, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import {
Plus,
ChevronRight,
Zap,
FileText,
PenTool,
LayoutGrid,
type LucideIcon,
} from 'lucide-react'
import { createOptions, shedTemplates } from '../../data/createData'
import {
createDocument,
getDefaultShedContent,
getDefaultTitle,
} from '../../lib/documents'
import { getStoredUser } from '../../lib/api'
const iconMap: Record<string, LucideIcon> = {
Zap,
FileText,
PenTool,
LayoutGrid,
}
export default function CreateDropdown({ steadId }: { steadId?: string }) {
const [open, setOpen] = useState(false)
const [shedOpen, setShedOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const navigate = useNavigate()
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false)
setShedOpen(false)
}
}
if (open) document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [open])
const user = getStoredUser()
const author = user?.full_name ?? 'You'
async function handleCreate(type: 'silo' | 'sketch') {
const defaults: Record<string, Record<string, unknown>> = {
silo: { body: '', bodyHtml: '', status: 'draft' },
sketch: { elements: [] },
}
const doc = await createDocument(
type,
getDefaultTitle(type),
defaults[type],
undefined,
author,
steadId,
)
setOpen(false)
navigate(`/workspace/${type}/${doc.id}`)
}
function handleCreateStead() {
setOpen(false)
navigate('/workspace/stead/new')
}
async function handleCreateShed(templateId: typeof shedTemplates[number]['id']) {
if (templateId === 'templates') return
const content = getDefaultShedContent(templateId)
const doc = await createDocument(
'shed',
getDefaultTitle('shed', templateId),
content,
templateId,
author,
steadId,
)
setOpen(false)
setShedOpen(false)
navigate(`/workspace/shed/${doc.id}`)
}
return (
<div ref={ref} className="relative">
<button
onClick={() => {
setOpen(!open)
setShedOpen(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"
>
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">Create</span>
</button>
{open && (
<div className="absolute right-0 top-full z-50 mt-2 w-80 overflow-hidden rounded-xl border border-silos-cream-dark bg-white shadow-2xl">
<div className="border-b border-silos-cream-dark px-4 py-3">
<p className="text-sm font-semibold text-silos-text">Create new</p>
<p className="text-xs text-silos-muted">Choose what you want to build</p>
</div>
<div className="p-2">
{createOptions.map((option) => {
const Icon = iconMap[option.icon]
const isShed = option.id === 'shed'
return (
<div key={option.id} className="relative">
<button
onClick={() => {
if (isShed) {
setShedOpen(!shedOpen)
} else if (option.id === 'stead') {
handleCreateStead()
} else if (option.id !== 'shed') {
void handleCreate(option.id)
}
}}
className="flex w-full items-start gap-3 rounded-lg p-3 text-left transition-colors hover:bg-silos-cream/60"
>
<div
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg"
style={{ backgroundColor: `${option.color}22` }}
>
{Icon && <Icon className="h-4 w-4" style={{ color: option.color }} />}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-silos-text">
{option.product}
<span className="ml-1.5 text-xs font-normal text-silos-muted">
— {option.label}
</span>
</p>
<p className="mt-0.5 text-xs text-silos-muted">{option.description}</p>
</div>
{isShed && (
<ChevronRight
className={`mt-1 h-4 w-4 shrink-0 text-silos-muted transition-transform ${
shedOpen ? 'rotate-90' : ''
}`}
/>
)}
</button>
{isShed && shedOpen && (
<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">
{shedTemplates.map((template) => (
<button
key={template.id}
disabled={!template.available}
onClick={() => void handleCreateShed(template.id)}
className="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-sm transition-colors hover:bg-white disabled:cursor-not-allowed disabled:opacity-50"
>
<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>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,50 @@
import { ChevronDown, TrendingUp } from 'lucide-react'
import { activityStats } from '../../data/dashboardData'
interface ActivityStatusProps {
documentCount?: number
}
export default function ActivityStatus({ documentCount = 0 }: ActivityStatusProps) {
return (
<div className="rounded-2xl border border-silos-cream-dark bg-white p-5">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-silos-text">Your Activity Status</h3>
<button className="flex items-center gap-1 rounded-lg border border-silos-cream-dark px-2.5 py-1 text-xs text-silos-muted hover:bg-silos-cream">
This Week
<ChevronDown className="h-3 w-3" />
</button>
</div>
<div className="mt-5 grid grid-cols-3 gap-4">
<div>
<p className="text-2xl font-bold text-silos-text">{documentCount}</p>
<p className="mt-0.5 text-xs text-silos-muted">Documents</p>
</div>
{activityStats.slice(1).map((stat) => (
<div key={stat.label}>
<p className="text-2xl font-bold text-silos-text">{stat.value}</p>
<p className="mt-0.5 text-xs text-silos-muted">{stat.label}</p>
<p className="mt-1 flex items-center gap-0.5 text-[10px] font-medium text-green-600">
<TrendingUp className="h-3 w-3" />
{stat.change}
</p>
</div>
))}
</div>
<div className="mt-6">
<div className="flex items-center justify-between text-sm">
<span className="font-medium text-silos-text">Your Contribution</span>
<span className="text-silos-muted">Top 15%</span>
</div>
<div className="mt-2 h-2 overflow-hidden rounded-full bg-silos-cream">
<div className="h-full w-[85%] rounded-full bg-silos-brown" />
</div>
<p className="mt-2 text-xs text-silos-muted">
You&apos;re in the top 15% of contributors in your team this week
</p>
</div>
</div>
)
}

View file

@ -0,0 +1,123 @@
import { useState } from 'react'
import RecentActivities from './RecentActivities'
import ActivityStatus from './ActivityStatus'
import DiscoverPanel from './DiscoverPanel'
import RecentProjectsGrid from './RecentProjectsGrid'
import { getStoredUser } from '../../lib/api'
import type { DocumentRecord } from '../../lib/documents'
function WorkspaceIllustration() {
return (
<svg viewBox="0 0 200 120" className="h-24 w-40 opacity-90" aria-hidden>
<rect x="60" y="50" width="80" height="50" rx="4" fill="#C17D65" opacity="0.2" />
<rect x="70" y="60" width="60" height="30" rx="2" fill="#6B3A2C" opacity="0.15" />
<circle cx="100" cy="35" r="12" fill="#C17D65" opacity="0.3" />
<rect x="85" y="48" width="30" height="20" rx="2" fill="#6B3A2C" opacity="0.2" />
<rect x="30" y="70" width="25" height="35" rx="3" fill="#7BAF8E" opacity="0.25" />
<rect x="145" y="65" width="30" height="40" rx="3" fill="#6B8CAE" opacity="0.25" />
<line x1="55" y1="85" x2="70" y2="75" stroke="#C17D65" strokeWidth="1.5" />
<line x1="140" y1="80" x2="145" y2="75" stroke="#C17D65" strokeWidth="1.5" />
</svg>
)
}
function getGreeting() {
const hour = new Date().getHours()
if (hour < 12) return 'Good morning'
if (hour < 17) return 'Good afternoon'
return 'Good evening'
}
interface DashboardFeedProps {
recent: DocumentRecord[]
loading?: boolean
error?: string | null
onOpenRecent?: () => void
}
export default function DashboardFeed({
recent,
loading,
error,
onOpenRecent,
}: DashboardFeedProps) {
const [activeTab, setActiveTab] = useState<'feed' | 'discover'>('feed')
const user = getStoredUser()
const firstName = user?.full_name?.split(' ')[0] || 'Alex'
return (
<div className="flex-1 overflow-y-auto bg-silos-cream/60 p-6 lg:p-8">
<div className="mx-auto max-w-6xl">
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-bold text-silos-text sm:text-3xl">
{getGreeting()}, {firstName} 👋
</h1>
<p className="mt-1 text-silos-muted">
Here&apos;s what&apos;s happening in your workspace today.
</p>
</div>
<div className="hidden lg:block">
<WorkspaceIllustration />
</div>
</div>
{error && (
<div className="mt-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
{error}
</div>
)}
{/* Recent pages grid */}
<div className="mt-6">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-sm font-semibold text-silos-text">Recent</h2>
{recent.length > 0 && (
<button
type="button"
onClick={onOpenRecent}
className="text-xs font-medium text-silos-brown hover:underline"
>
View all
</button>
)}
</div>
<RecentProjectsGrid documents={recent} loading={loading} />
</div>
{/* Tabs */}
<div className="mt-8 flex gap-6 border-b border-silos-cream-dark">
{(['feed', 'discover'] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`border-b-2 pb-3 text-sm font-medium capitalize transition-colors ${
activeTab === tab
? 'border-silos-brown text-silos-brown'
: 'border-transparent text-silos-muted hover:text-silos-text'
}`}
>
{tab}
</button>
))}
</div>
{activeTab === 'feed' ? (
<div className="mt-6 grid gap-6 lg:grid-cols-5">
<div className="lg:col-span-3">
<RecentActivities documents={recent} loading={loading} onViewAll={onOpenRecent} />
</div>
<div className="space-y-6 lg:col-span-2">
<ActivityStatus documentCount={recent.length} />
<DiscoverPanel />
</div>
</div>
) : (
<div className="mt-6 max-w-2xl">
<DiscoverPanel />
</div>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,115 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import {
Menu,
Search,
Bell,
HelpCircle,
Settings,
ChevronDown,
} from 'lucide-react'
import { logos } from '../../data/brandAssets'
import { getStoredUser } from '../../lib/api'
import CreateDropdown from '../create/CreateDropdown'
import SearchOverlay from '../search/SearchOverlay'
interface DashboardHeaderProps {
onToggleSidebar: () => void
sidebarCollapsed: boolean
}
export default function DashboardHeader({
onToggleSidebar,
}: DashboardHeaderProps) {
const user = getStoredUser()
const displayName = user?.full_name?.split(' ')[0] || 'Alex'
const [searchOpen, setSearchOpen] = useState(false)
const [draft, setDraft] = useState('')
useEffect(() => {
function onKey(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault()
setSearchOpen(true)
}
if (e.key === '/' && !['INPUT', 'TEXTAREA'].includes((e.target as HTMLElement).tagName)) {
e.preventDefault()
setSearchOpen(true)
}
if (e.key === 'Escape') setSearchOpen(false)
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [])
return (
<>
<header className="sticky top-0 z-30 flex h-14 items-center gap-4 border-b border-silos-cream-dark bg-white px-4">
<div className="flex items-center gap-3">
<button
onClick={onToggleSidebar}
className="rounded-lg p-2 text-silos-muted transition-colors hover:bg-silos-cream hover:text-silos-text"
aria-label="Toggle sidebar"
>
<Menu className="h-5 w-5" />
</button>
<Link to="/dashboard" className="hidden sm:block">
<img src={logos.rustyBrown} alt="Silos" className="h-7 w-auto" />
</Link>
<Link
to="/"
className="hidden text-sm text-silos-muted transition-colors hover:text-silos-brown md:block"
>
Home
</Link>
</div>
<div className="flex flex-1 items-center justify-center gap-3">
<div className="relative hidden w-full max-w-xl sm:block">
<Search className="absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-silos-muted" />
<input
type="text"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onFocus={() => setSearchOpen(true)}
placeholder="Search projects, docs, people..."
className="w-full rounded-full border border-silos-cream-dark bg-silos-cream/50 py-2 pl-10 pr-16 text-sm text-silos-text outline-none transition-colors focus:border-silos-tan focus:ring-2 focus:ring-silos-tan/20"
/>
<kbd className="absolute right-3 top-1/2 hidden -translate-y-1/2 rounded border border-silos-cream-dark bg-white px-1.5 py-0.5 text-[10px] text-silos-muted lg:inline">
⌘ K
</kbd>
</div>
<CreateDropdown />
</div>
<div className="flex items-center gap-1">
<button
className="relative rounded-lg p-2 text-silos-muted transition-colors hover:bg-silos-cream hover:text-silos-text"
aria-label="Notifications"
>
<Bell className="h-5 w-5" />
</button>
<button
className="rounded-lg p-2 text-silos-muted transition-colors hover:bg-silos-cream hover:text-silos-text"
aria-label="Help"
>
<HelpCircle className="h-5 w-5" />
</button>
<button
className="rounded-lg p-2 text-silos-muted transition-colors hover:bg-silos-cream hover:text-silos-text"
aria-label="Settings"
>
<Settings className="h-5 w-5" />
</button>
<button className="ml-1 flex items-center gap-1.5 rounded-full py-1 pl-1 pr-2 transition-colors hover:bg-silos-cream">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-silos-tan text-sm font-semibold text-white">
{displayName[0]}
</div>
<ChevronDown className="h-4 w-4 text-silos-muted" />
</button>
</div>
</header>
<SearchOverlay open={searchOpen} onClose={() => setSearchOpen(false)} initialQuery={draft} />
</>
)
}

View file

@ -0,0 +1,85 @@
import { useState } from 'react'
import DashboardHeader from './DashboardHeader'
import DashboardSidebar from './DashboardSidebar'
import DashboardFeed from './DashboardFeed'
import DocumentFlyout, { type FlyoutMode } from './DocumentFlyout'
import TemplatesFlyout from './TemplatesFlyout'
import { useDashboardDocuments } from '../../hooks/useDashboardDocuments'
import type { NavItemId } from '../../data/dashboardData'
const flyoutMap: Partial<Record<NavItemId, FlyoutMode | 'templates'>> = {
recent: 'recent',
favourites: 'favourites',
'all-projects': 'all-projects',
templates: 'templates',
}
export default function DashboardLayout() {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const [activeNav, setActiveNav] = useState<NavItemId>('for-you')
const [flyout, setFlyout] = useState<FlyoutMode | 'templates' | null>(null)
const { recent, projects, starredProjects, loading, error, refresh } =
useDashboardDocuments()
function handleNavChange(id: NavItemId) {
const mode = flyoutMap[id]
if (mode) {
setFlyout((current) => (current === mode ? null : mode))
setActiveNav(id)
} else {
setFlyout(null)
setActiveNav(id)
}
}
const flyoutDocuments =
flyout === 'recent'
? recent
: flyout === 'favourites'
? starredProjects
: flyout === 'all-projects'
? projects
: []
return (
<div className="flex h-screen flex-col bg-silos-cream">
<DashboardHeader
onToggleSidebar={() => setSidebarCollapsed(!sidebarCollapsed)}
sidebarCollapsed={sidebarCollapsed}
/>
<div className="relative flex flex-1 overflow-hidden">
<DashboardSidebar
collapsed={sidebarCollapsed}
activeNav={activeNav}
onNavChange={handleNavChange}
flyoutOpen={flyout !== null}
spaces={projects}
/>
{flyout && flyout !== 'templates' && (
<DocumentFlyout
mode={flyout}
documents={flyoutDocuments}
sidebarCollapsed={sidebarCollapsed}
onClose={() => setFlyout(null)}
onStarChange={refresh}
/>
)}
{flyout === 'templates' && (
<TemplatesFlyout sidebarCollapsed={sidebarCollapsed} onClose={() => setFlyout(null)} />
)}
<DashboardFeed
recent={recent}
loading={loading}
error={error}
onOpenRecent={() => {
setActiveNav('recent')
setFlyout('recent')
}}
/>
</div>
</div>
)
}

View file

@ -0,0 +1,158 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import {
Home,
LayoutTemplate,
Clock,
Star,
FolderOpen,
ChevronDown,
ChevronRight,
Plus,
Palette,
UserPlus,
type LucideIcon,
} from 'lucide-react'
import { navItems, type NavItemId } from '../../data/dashboardData'
import type { DocumentRecord } from '../../lib/documents'
const iconMap: Record<string, LucideIcon> = {
Home,
LayoutTemplate,
Clock,
Star,
FolderOpen,
}
const flyoutNavIds: NavItemId[] = ['recent', 'favourites', 'all-projects', 'templates']
interface DashboardSidebarProps {
collapsed: boolean
activeNav: NavItemId
onNavChange: (id: NavItemId) => void
flyoutOpen?: boolean
spaces?: DocumentRecord[]
}
export default function DashboardSidebar({
collapsed,
activeNav,
onNavChange,
flyoutOpen = false,
spaces = [],
}: DashboardSidebarProps) {
const [personalOpen, setPersonalOpen] = useState(true)
if (collapsed) {
return (
<aside className="relative z-30 flex w-14 shrink-0 flex-col items-center border-r border-silos-cream-dark bg-white py-4">
{navItems.map((item) => {
const Icon = iconMap[item.icon]
const hasFlyout = flyoutNavIds.includes(item.id)
const isActive = activeNav === item.id && (hasFlyout ? flyoutOpen : true)
return (
<button
key={item.id}
onClick={() => onNavChange(item.id)}
title={item.label}
className={`mb-1 rounded-lg p-2.5 transition-colors ${
isActive
? 'bg-silos-brown/10 text-silos-brown'
: 'text-silos-muted hover:bg-silos-cream hover:text-silos-text'
}`}
>
{Icon && <Icon className="h-5 w-5" />}
</button>
)
})}
</aside>
)
}
return (
<aside className="relative z-30 flex w-60 shrink-0 flex-col border-r border-silos-cream-dark bg-white">
<nav className="flex-1 overflow-y-auto p-3">
{navItems.map((item) => {
const Icon = iconMap[item.icon]
const hasFlyout = flyoutNavIds.includes(item.id)
const isActive = activeNav === item.id && (hasFlyout ? flyoutOpen : true)
return (
<button
key={item.id}
onClick={() => onNavChange(item.id)}
className={`mb-0.5 flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm transition-colors ${
isActive
? 'bg-silos-brown/10 font-medium text-silos-brown'
: 'text-silos-text hover:bg-silos-cream'
}`}
>
{Icon && <Icon className="h-4 w-4 shrink-0" />}
<span className="flex-1 text-left">{item.label}</span>
{hasFlyout && (
<ChevronRight
className={`h-4 w-4 shrink-0 text-silos-muted transition-transform ${
isActive && flyoutOpen ? 'rotate-90' : ''
}`}
/>
)}
</button>
)
})}
<div className="my-4 border-t border-dashed border-silos-cream-dark" />
<button
onClick={() => setPersonalOpen(!personalOpen)}
className="flex w-full items-center justify-between rounded-lg px-3 py-2 text-sm font-medium text-silos-text hover:bg-silos-cream"
>
Personal Space
{personalOpen ? (
<ChevronDown className="h-4 w-4 text-silos-muted" />
) : (
<ChevronRight className="h-4 w-4 text-silos-muted" />
)}
</button>
{personalOpen && (
<div className="mt-1 space-y-0.5 pl-1">
{spaces.length === 0 ? (
<p className="px-3 py-2 text-xs text-silos-muted">No spaces yet</p>
) : (
spaces.map((space) => (
<Link
key={space.id}
to={`/workspace/stead/${space.id}`}
className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-silos-text transition-colors hover:bg-silos-cream"
>
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-silos-tan/20 text-xs">
{(space.content.icon as string) || '📁'}
</span>
<span className="truncate">{space.title}</span>
</Link>
))
)}
<Link
to="/workspace/stead/new"
className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-silos-muted transition-colors hover:bg-silos-cream hover:text-silos-brown"
>
<Plus className="h-4 w-4" />
Add space
</Link>
</div>
)}
</nav>
<div className="space-y-2 border-t border-silos-cream-dark p-3">
<button className="flex w-full items-center justify-center gap-2 rounded-xl border border-silos-cream-dark bg-silos-cream/40 py-2.5 text-sm font-medium text-silos-text transition-colors hover:bg-silos-cream">
<Palette className="h-4 w-4" />
Customize
</button>
<button className="flex w-full items-center justify-center gap-2 rounded-xl border border-silos-cream-dark bg-silos-cream/40 py-2.5 text-sm font-medium text-silos-text transition-colors hover:bg-silos-cream">
<UserPlus className="h-4 w-4" />
Invite Fellow
</button>
</div>
</aside>
)
}

View file

@ -0,0 +1,52 @@
import {
LayoutTemplate,
FolderOpen,
BookOpen,
ChevronRight,
type LucideIcon,
} from 'lucide-react'
import { discoverItems } from '../../data/dashboardData'
const iconMap: Record<string, LucideIcon> = {
LayoutTemplate,
FolderOpen,
BookOpen,
}
export default function DiscoverPanel() {
return (
<div className="space-y-6">
<div className="rounded-2xl border border-silos-cream-dark bg-white p-5">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-silos-text">Discover something new</h3>
<button className="text-sm font-medium text-silos-tan hover:text-silos-brown">
See all
</button>
</div>
<ul className="mt-4 space-y-1">
{discoverItems.map((item) => {
const Icon = iconMap[item.icon]
return (
<li key={item.id}>
<button className="flex w-full items-center gap-3 rounded-xl p-3 text-left transition-colors hover:bg-silos-cream/50">
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg"
style={{ backgroundColor: `${item.color}22` }}
>
{Icon && <Icon className="h-5 w-5" style={{ color: item.color }} />}
</div>
<div className="min-w-0 flex-1">
<p className="font-medium text-silos-text">{item.title}</p>
<p className="text-xs text-silos-muted">{item.description}</p>
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-silos-muted" />
</button>
</li>
)
})}
</ul>
</div>
</div>
)
}

View file

@ -0,0 +1,185 @@
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { X, Search, FileText, Zap, PenTool, LayoutGrid, Star } from 'lucide-react'
import type { CreateType } from '../../data/createData'
import type { DocumentRecord } from '../../lib/documents'
import {
groupByTimePeriod,
formatRelativeTime,
getActivityDate,
typeColors,
typeLabels,
} from '../../lib/documentUtils'
import { toggleStar } from '../../lib/documents'
const typeIcons: Record<CreateType, typeof FileText> = {
silo: FileText,
shed: Zap,
sketch: PenTool,
stead: LayoutGrid,
}
export type FlyoutMode = 'recent' | 'favourites' | 'all-projects'
interface DocumentFlyoutProps {
mode: FlyoutMode
documents: DocumentRecord[]
sidebarCollapsed: boolean
onClose: () => void
onStarChange?: () => void
}
const titles: Record<FlyoutMode, string> = {
recent: 'Recent',
favourites: 'Favourite Projects',
'all-projects': 'All Projects',
}
export default function DocumentFlyout({
mode,
documents,
sidebarCollapsed,
onClose,
onStarChange,
}: DocumentFlyoutProps) {
const [filter, setFilter] = useState('')
const filtered = useMemo(() => {
const q = filter.toLowerCase().trim()
if (!q) return documents
return documents.filter(
(d) =>
d.title.toLowerCase().includes(q) ||
d.createdBy.toLowerCase().includes(q) ||
typeLabels[d.type].toLowerCase().includes(q),
)
}, [documents, filter])
const groups = useMemo(
() => (mode === 'recent' ? groupByTimePeriod(filtered) : [{ label: '', items: filtered }]),
[mode, filtered],
)
async function handleToggleStar(doc: DocumentRecord) {
await toggleStar(doc.id, !doc.starred)
onStarChange?.()
}
return (
<>
<div className="fixed inset-0 top-12 z-40 bg-black/10" onClick={onClose} />
<aside
className={`fixed top-12 z-50 flex h-[calc(100vh-3rem)] w-80 flex-col border-r border-silos-cream-dark bg-white shadow-xl sm:w-96 ${
sidebarCollapsed ? 'left-14' : 'left-60'
}`}
>
<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">{titles[mode]}</h2>
<button
type="button"
onClick={onClose}
className="rounded-lg p-1.5 text-silos-muted hover:bg-silos-cream"
aria-label="Close"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="border-b border-silos-cream-dark px-4 py-3">
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-silos-muted" />
<input
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder={
mode === 'recent' ? 'Filter recent items' : 'Filter projects'
}
className="w-full rounded-lg border border-silos-cream-dark bg-silos-cream/30 py-2 pl-9 pr-3 text-sm outline-none focus:border-silos-brown"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{filtered.length === 0 ? (
<div className="px-4 py-12 text-center text-sm text-silos-muted">
{mode === 'favourites'
? 'No starred projects yet. Star a Stead space to add it here.'
: mode === 'all-projects'
? 'No projects yet. Create a Stead space to get started.'
: 'No recent items. Create a page, whiteboard, or space.'}
</div>
) : (
groups.map((group) => (
<div key={group.label || 'all'}>
{group.label && (
<p className="px-4 pb-1 pt-4 text-xs font-semibold uppercase tracking-wide text-silos-muted">
{group.label}
</p>
)}
<ul>
{group.items.map((doc) => {
const Icon = typeIcons[doc.type]
return (
<li key={doc.id} className="group">
<div className="flex items-center hover:bg-silos-cream/50">
<Link
to={`/workspace/${doc.type}/${doc.id}`}
onClick={onClose}
className="flex min-w-0 flex-1 items-center gap-3 px-4 py-3"
>
<div
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg"
style={{ backgroundColor: `${typeColors[doc.type]}22` }}
>
<Icon className="h-4 w-4" style={{ color: typeColors[doc.type] }} />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-silos-text">
{doc.title}
</p>
<p className="truncate text-xs text-silos-muted">
{doc.createdBy} · {formatRelativeTime(getActivityDate(doc))}
</p>
</div>
</Link>
{doc.type === 'stead' && (
<button
type="button"
onClick={() => void handleToggleStar(doc)}
className="mr-3 rounded p-1.5 opacity-0 transition-opacity group-hover:opacity-100"
title={doc.starred ? 'Unstar' : 'Star'}
>
<Star
className={`h-4 w-4 ${
doc.starred
? 'fill-amber-400 text-amber-400'
: 'text-silos-muted'
}`}
/>
</button>
)}
</div>
</li>
)
})}
</ul>
</div>
))
)}
</div>
{mode === 'recent' && filtered.length > 0 && (
<div className="border-t border-silos-cream-dark p-3">
<button
type="button"
onClick={onClose}
className="w-full text-center text-sm font-medium text-silos-brown hover:underline"
>
View all recent items
</button>
</div>
)}
</aside>
</>
)
}

View file

@ -0,0 +1,129 @@
import { Link } from 'react-router-dom'
import { FileText, Zap, PenTool, LayoutGrid, ChevronRight } from 'lucide-react'
import type { CreateType } from '../../data/createData'
import type { DocumentRecord } from '../../lib/documents'
import {
formatRelativeTime,
getActivityDate,
getProjectColor,
getProjectIcon,
typeColors,
typeLabels,
} from '../../lib/documentUtils'
const typeIcons: Record<CreateType, typeof FileText> = {
silo: FileText,
shed: Zap,
sketch: PenTool,
stead: LayoutGrid,
}
function getActionLabel(doc: DocumentRecord): string {
const created = new Date(doc.createdAt).getTime()
const updated = new Date(doc.updatedAt).getTime()
if (Math.abs(updated - created) < 5000) {
return `Created by ${doc.createdBy}`
}
return `Updated by ${doc.createdBy}`
}
interface RecentActivitiesProps {
documents: DocumentRecord[]
loading?: boolean
onViewAll?: () => void
}
export default function RecentActivities({
documents,
loading,
onViewAll,
}: RecentActivitiesProps) {
const items = documents.slice(0, 8)
return (
<div className="rounded-2xl border border-silos-cream-dark bg-white">
<div className="flex items-center justify-between border-b border-silos-cream-dark px-5 py-4">
<h3 className="font-semibold text-silos-text">Recent Activities</h3>
{items.length > 0 && (
<button
type="button"
onClick={onViewAll}
className="text-sm font-medium text-silos-tan hover:text-silos-brown"
>
View all
</button>
)}
</div>
{loading ? (
<ul className="divide-y divide-silos-cream-dark">
{Array.from({ length: 4 }).map((_, i) => (
<li key={i} className="px-5 py-4">
<div className="h-10 animate-pulse rounded-lg bg-silos-cream" />
</li>
))}
</ul>
) : items.length === 0 ? (
<div className="px-5 py-12 text-center text-sm text-silos-muted">
No activity yet. Create a Silo, Sketch, Shed, or Stead to get started.
</div>
) : (
<ul className="divide-y divide-silos-cream-dark">
{items.map((doc) => {
const Icon = typeIcons[doc.type]
const color = doc.type === 'stead' ? getProjectColor(doc) : typeColors[doc.type]
const initial = doc.createdBy[0]?.toUpperCase() ?? '?'
return (
<li key={doc.id}>
<Link
to={`/workspace/${doc.type}/${doc.id}`}
className="flex w-full items-center gap-4 px-5 py-4 text-left transition-colors hover:bg-silos-cream/40"
>
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg"
style={{ backgroundColor: `${color}22` }}
>
{doc.type === 'stead' ? (
<span className="text-lg">{getProjectIcon(doc)}</span>
) : (
<Icon className="h-5 w-5" style={{ color }} />
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-silos-text">{doc.title}</p>
<p className="mt-0.5 truncate text-sm text-silos-muted">
{getActionLabel(doc)} · {typeLabels[doc.type]}
</p>
</div>
<div className="flex shrink-0 items-center gap-3">
<span className="hidden text-xs text-silos-muted sm:inline">
{formatRelativeTime(getActivityDate(doc))}
</span>
<div
className="flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold text-white"
style={{ backgroundColor: color }}
>
{initial}
</div>
</div>
</Link>
</li>
)
})}
</ul>
)}
{items.length > 0 && (
<button
type="button"
onClick={onViewAll}
className="flex w-full items-center justify-center gap-1 border-t border-silos-cream-dark py-3.5 text-sm font-medium text-silos-brown transition-colors hover:bg-silos-cream/40"
>
View all recent activities
<ChevronRight className="h-4 w-4" />
</button>
)}
</div>
)
}

View file

@ -0,0 +1,90 @@
import { Link } from 'react-router-dom'
import { FileText, Zap, PenTool, LayoutGrid } from 'lucide-react'
import type { CreateType } from '../../data/createData'
import type { DocumentRecord } from '../../lib/documents'
import {
formatVisitedDate,
getActivityDate,
getProjectColor,
getProjectIcon,
typeColors,
typeLabels,
} from '../../lib/documentUtils'
const typeIcons: Record<CreateType, typeof FileText> = {
silo: FileText,
shed: Zap,
sketch: PenTool,
stead: LayoutGrid,
}
interface RecentProjectsGridProps {
documents: DocumentRecord[]
loading?: boolean
}
export default function RecentProjectsGrid({ documents, loading }: RecentProjectsGridProps) {
const items = documents.slice(0, 6)
if (loading) {
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<div
key={i}
className="h-36 animate-pulse rounded-2xl border border-silos-cream-dark bg-white"
/>
))}
</div>
)
}
if (items.length === 0) {
return (
<div className="rounded-2xl border-2 border-dashed border-silos-cream-dark bg-white py-16 text-center">
<p className="text-sm text-silos-muted">
No recent pages yet. Use Create to start a Silo, Sketch, Shed, or Stead.
</p>
</div>
)
}
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{items.map((doc) => {
const Icon = typeIcons[doc.type]
const color = doc.type === 'stead' ? getProjectColor(doc) : typeColors[doc.type]
return (
<Link
key={doc.id}
to={`/workspace/${doc.type}/${doc.id}`}
className="group rounded-2xl border border-silos-cream-dark bg-white p-5 transition-all hover:border-silos-tan hover:shadow-md"
>
<div className="flex items-start gap-3">
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-lg"
style={{ backgroundColor: color + '33' }}
>
{doc.type === 'stead' ? (
<span>{getProjectIcon(doc)}</span>
) : (
<Icon className="h-5 w-5" style={{ color }} />
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-semibold text-silos-text group-hover:text-silos-brown">
{doc.title}
</p>
<p className="mt-1 text-xs text-silos-muted">{typeLabels[doc.type]}</p>
</div>
</div>
<p className="mt-4 text-xs text-silos-muted">
{doc.createdBy} · {formatVisitedDate(getActivityDate(doc))}
</p>
</Link>
)
})}
</div>
)
}

View file

@ -0,0 +1,108 @@
import { useNavigate } from 'react-router-dom'
import { X } from 'lucide-react'
import { shedTemplates } from '../../data/createData'
import {
createDocument,
getDefaultShedContent,
getDefaultTitle,
} from '../../lib/documents'
import { getStoredUser } from '../../lib/api'
interface TemplatesFlyoutProps {
sidebarCollapsed: boolean
onClose: () => void
}
const siloStarters = [
{ id: 'blank', label: 'Blank page', description: 'Empty Silo for your own content', html: '' },
{
id: 'meeting',
label: 'Meeting notes',
description: 'Silo starter for meetings',
html: '<h2>Meeting notes</h2><p>Date:</p><ul><li>Attendees</li><li>Agenda</li><li>Actions</li></ul>',
},
{
id: 'plan',
label: 'Project plan',
description: 'Silo starter for a project plan',
html: '<h2>Project plan</h2><p>Goals</p><ul><li>Milestone 1</li><li>Milestone 2</li></ul>',
},
]
export default function TemplatesFlyout({ sidebarCollapsed, onClose }: TemplatesFlyoutProps) {
const navigate = useNavigate()
const author = getStoredUser()?.full_name ?? 'You'
async function createShed(templateId: (typeof shedTemplates)[number]['id']) {
if (templateId === 'templates') return
const doc = await createDocument(
'shed',
getDefaultTitle('shed', templateId),
getDefaultShedContent(templateId),
templateId,
author,
)
onClose()
navigate(`/workspace/shed/${doc.id}`)
}
async function createSilo(html: string, label: string) {
const doc = await createDocument(
'silo',
`${label} — ${new Date().toLocaleDateString()}`,
{ body: '', bodyHtml: html, status: 'draft' },
undefined,
author,
)
onClose()
navigate(`/workspace/silo/${doc.id}`)
}
return (
<>
<div className="fixed inset-0 top-12 z-40 bg-black/10" onClick={onClose} />
<aside
className={`fixed top-12 z-50 flex h-[calc(100vh-3rem)] w-80 flex-col border-r border-silos-cream-dark bg-white shadow-xl sm:w-96 ${
sidebarCollapsed ? 'left-14' : 'left-60'
}`}
>
<div className="flex items-center justify-between border-b border-silos-cream-dark px-4 py-3">
<h2 className="text-lg font-semibold text-silos-text">Templates</h2>
<button type="button" onClick={onClose} className="rounded-lg p-1.5 hover:bg-silos-cream">
<X className="h-4 w-4 text-silos-muted" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-3">
<p className="px-1 pb-2 text-xs font-semibold uppercase text-silos-muted">Shed — Live docs</p>
{shedTemplates.map((t) => (
<button
key={t.id}
type="button"
disabled={!t.available}
onClick={() => void createShed(t.id)}
className="mb-1 flex w-full items-start gap-3 rounded-lg px-3 py-3 text-left hover:bg-silos-cream disabled:opacity-50"
>
<span className="text-lg">{t.emoji}</span>
<div>
<p className="text-sm font-medium text-silos-text">{t.label}</p>
<p className="text-xs text-silos-muted">{t.description}</p>
</div>
</button>
))}
<p className="mt-4 px-1 pb-2 text-xs font-semibold uppercase text-silos-muted">Silo — Pages</p>
{siloStarters.map((t) => (
<button
key={t.id}
type="button"
onClick={() => void createSilo(t.html, t.label)}
className="mb-1 flex w-full flex-col rounded-lg px-3 py-3 text-left hover:bg-silos-cream"
>
<p className="text-sm font-medium text-silos-text">{t.label}</p>
<p className="text-xs text-silos-muted">{t.description}</p>
</button>
))}
</div>
</aside>
</>
)
}

View file

@ -0,0 +1,120 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { FileText, Zap, PenTool, LayoutGrid, Search, Loader2 } from 'lucide-react'
import { searchDocuments, type DocumentRecord } from '../../lib/documents'
import { formatRelativeTime, getActivityDate, typeColors, typeLabels } from '../../lib/documentUtils'
import type { CreateType } from '../../data/createData'
const typeIcons: Record<CreateType, typeof FileText> = {
silo: FileText,
shed: Zap,
sketch: PenTool,
stead: LayoutGrid,
}
interface SearchOverlayProps {
open: boolean
onClose: () => void
initialQuery?: string
}
export default function SearchOverlay({ open, onClose, initialQuery = '' }: SearchOverlayProps) {
const [query, setQuery] = useState(initialQuery)
const [results, setResults] = useState<DocumentRecord[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
const navigate = useNavigate()
useEffect(() => {
if (open) {
setQuery(initialQuery)
setTimeout(() => inputRef.current?.focus(), 0)
}
}, [open, initialQuery])
const runSearch = useCallback(async (value: string) => {
if (!value.trim()) {
setResults([])
setError(null)
return
}
setLoading(true)
setError(null)
try {
const docs = await searchDocuments(value)
setResults(docs)
} catch {
setError('Could not search. Is MongoDB running?')
setResults([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
if (!open) return
const timer = setTimeout(() => void runSearch(query), 250)
return () => clearTimeout(timer)
}, [query, open, runSearch])
if (!open) return null
return (
<div className="fixed inset-0 z-[80] flex items-start justify-center bg-black/30 pt-24 px-4">
<button type="button" className="absolute inset-0 cursor-default" onClick={onClose} aria-label="Close search" />
<div className="relative z-10 w-full max-w-xl overflow-hidden rounded-2xl border border-silos-cream-dark bg-white shadow-2xl">
<div className="flex items-center gap-2 border-b border-silos-cream-dark px-4">
<Search className="h-4 w-4 text-silos-muted" />
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search pages, spaces, whiteboards..."
className="w-full bg-transparent py-3 text-sm text-silos-text outline-none"
/>
{loading && <Loader2 className="h-4 w-4 animate-spin text-silos-muted" />}
<kbd className="rounded border border-silos-cream-dark px-1.5 py-0.5 text-[10px] text-silos-muted">Esc</kbd>
</div>
<div className="max-h-80 overflow-y-auto">
{error && <p className="px-4 py-6 text-center text-sm text-amber-700">{error}</p>}
{!error && query.trim() && !loading && results.length === 0 && (
<p className="px-4 py-6 text-center text-sm text-silos-muted">No matches for “{query}”.</p>
)}
{!query.trim() && (
<p className="px-4 py-6 text-center text-sm text-silos-muted">
Type to search your documents.
</p>
)}
{results.map((doc) => {
const Icon = typeIcons[doc.type]
return (
<button
key={doc.id}
type="button"
onClick={() => {
onClose()
navigate(`/workspace/${doc.type}/${doc.id}`)
}}
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-silos-cream/60"
>
<div
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg"
style={{ backgroundColor: `${typeColors[doc.type]}22` }}
>
<Icon className="h-4 w-4" style={{ color: typeColors[doc.type] }} />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-silos-text">{doc.title}</p>
<p className="text-xs text-silos-muted">
{typeLabels[doc.type]} · {formatRelativeTime(getActivityDate(doc))}
</p>
</div>
</button>
)
})}
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,105 @@
interface SectionProps {
icon: string
title: string
children: React.ReactNode
}
export function DocSection({ icon, title, children }: SectionProps) {
return (
<section className="mb-8">
<h2 className="mb-3 flex items-center gap-2 text-lg font-semibold text-silos-text">
<span>{icon}</span>
{title}
</h2>
{children}
</section>
)
}
export function InfoBanner({ children }: { children: React.ReactNode }) {
return (
<div className="mb-6 flex gap-3 rounded-lg border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-900">
<span className="shrink-0 font-bold">ℹ️</span>
<div>{children}</div>
</div>
)
}
export function FieldLabel({ children }: { children: React.ReactNode }) {
return <label className="mb-1.5 block text-sm font-medium text-silos-text">{children}</label>
}
export function TextInput({
value,
onChange,
placeholder,
className = '',
}: {
value: string
onChange: (v: string) => void
placeholder?: string
className?: string
}) {
return (
<input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className={`w-full rounded-lg border border-silos-cream-dark bg-white px-3 py-2 text-sm text-silos-text outline-none focus:border-silos-tan focus:ring-2 focus:ring-silos-tan/20 ${className}`}
/>
)
}
export function TextArea({
value,
onChange,
placeholder,
rows = 3,
}: {
value: string
onChange: (v: string) => void
placeholder?: string
rows?: number
}) {
return (
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
rows={rows}
className="w-full resize-y rounded-lg border border-silos-cream-dark bg-white px-3 py-2 text-sm text-silos-text outline-none focus:border-silos-tan focus:ring-2 focus:ring-silos-tan/20"
/>
)
}
export function MetaTable({ rows }: { rows: { label: string; value: React.ReactNode }[] }) {
return (
<table className="mb-6 w-full border-collapse overflow-hidden rounded-lg border border-silos-cream-dark">
<tbody>
{rows.map((row) => (
<tr key={row.label} className="border-b border-silos-cream-dark last:border-0">
<td className="w-48 bg-silos-cream/40 px-4 py-3 text-sm font-medium text-silos-text">
{row.label}
</td>
<td className="px-4 py-3">{row.value}</td>
</tr>
))}
</tbody>
</table>
)
}
export function PriorityBadge({ priority }: { priority: string }) {
const colors: Record<string, string> = {
HIGH: 'bg-red-100 text-red-700',
MEDIUM: 'bg-yellow-100 text-yellow-700',
LOW: 'bg-green-100 text-green-700',
}
return (
<span
className={`inline-block rounded px-2 py-0.5 text-xs font-semibold ${colors[priority] ?? colors.MEDIUM}`}
>
{priority}
</span>
)
}

View file

@ -0,0 +1,326 @@
import {
DocSection,
InfoBanner,
MetaTable,
TextInput,
TextArea,
PriorityBadge,
} from '../DocBlocks'
import type { DocumentRecord } from '../../../lib/documents'
type ArchContent = {
reviewDate: string
projectLead: string
overview: string
architectureIssues: { issue: string; impact: string; priority: string; notes: string }[]
stakeholders: { name: string; role: string }[]
qualityAttributes: { attribute: string; definition: string; metrics: string; notes: string }[]
goals: string
nextSteps: {
project: string
description: string
estimate: string
documentation: string
releaseDate: string
}[]
}
interface Props {
doc: DocumentRecord
onUpdate: (u: Partial<DocumentRecord>) => void
}
export default function ArchitectureReviewTemplate({ doc, onUpdate }: Props) {
const content = doc.content as ArchContent
function saveContent(next: ArchContent) {
onUpdate({ content: next })
}
function patch<K extends keyof ArchContent>(key: K, value: ArchContent[K]) {
saveContent({ ...content, [key]: value })
}
return (
<>
<InfoBanner>
Reflect on architecture decisions, identify issues, and align stakeholders on quality
attributes and next steps.
</InfoBanner>
<MetaTable
rows={[
{
label: 'Architecture review date',
value: (
<TextInput
value={content.reviewDate}
onChange={(v) => patch('reviewDate', v)}
placeholder="Type // to add a date"
/>
),
},
{
label: 'Project lead',
value: (
<TextInput
value={content.projectLead}
onChange={(v) => patch('projectLead', v)}
placeholder="@ mention lead"
/>
),
},
]}
/>
<DocSection icon="📋" title="Overview">
<TextArea
value={content.overview}
onChange={(v) => patch('overview', v)}
placeholder="Type /image to add an architectural diagram that explains how the structural components of your architecture work together."
rows={4}
/>
</DocSection>
<DocSection icon="🚩" title="Architecture issues">
<p className="mb-3 text-sm text-silos-muted">
Document known architecture issues and their business impact.
</p>
<div className="overflow-x-auto rounded-lg border border-silos-cream-dark">
<table className="w-full min-w-[600px] text-sm">
<thead>
<tr className="bg-silos-cream/60 text-left">
<th className="px-3 py-2 font-medium">Architecture issue</th>
<th className="px-3 py-2 font-medium">Business impact</th>
<th className="px-3 py-2 font-medium">Priority</th>
<th className="px-3 py-2 font-medium">Notes</th>
</tr>
</thead>
<tbody>
{content.architectureIssues.map((row, i) => (
<tr key={i} className="border-t border-silos-cream-dark">
<td className="px-3 py-2">
<TextInput
value={row.issue}
onChange={(v) => {
const next = [...content.architectureIssues]
next[i] = { ...row, issue: v }
patch('architectureIssues', next)
}}
placeholder="e.g., Monolithic deployment"
/>
</td>
<td className="px-3 py-2">
<TextInput
value={row.impact}
onChange={(v) => {
const next = [...content.architectureIssues]
next[i] = { ...row, impact: v }
patch('architectureIssues', next)
}}
placeholder="Business impact"
/>
</td>
<td className="px-3 py-2">
<select
value={row.priority}
onChange={(e) => {
const next = [...content.architectureIssues]
next[i] = { ...row, priority: e.target.value }
patch('architectureIssues', next)
}}
className="rounded border border-silos-cream-dark px-2 py-1 text-xs"
>
<option value="HIGH">HIGH</option>
<option value="MEDIUM">MEDIUM</option>
<option value="LOW">LOW</option>
</select>
<div className="mt-1">
<PriorityBadge priority={row.priority} />
</div>
</td>
<td className="px-3 py-2">
<TextInput
value={row.notes}
onChange={(v) => {
const next = [...content.architectureIssues]
next[i] = { ...row, notes: v }
patch('architectureIssues', next)
}}
placeholder="Notes"
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
<button
onClick={() =>
patch('architectureIssues', [
...content.architectureIssues,
{ issue: '', impact: '', priority: 'MEDIUM', notes: '' },
])
}
className="mt-2 text-sm text-silos-brown hover:underline"
>
+ Add row
</button>
</DocSection>
<DocSection icon="👤" title="Stakeholders">
<div className="overflow-x-auto rounded-lg border border-silos-cream-dark">
<table className="w-full text-sm">
<thead>
<tr className="bg-silos-cream/60 text-left">
<th className="px-3 py-2 font-medium">Name</th>
<th className="px-3 py-2 font-medium">Role</th>
</tr>
</thead>
<tbody>
{content.stakeholders.map((row, i) => (
<tr key={i} className="border-t border-silos-cream-dark">
<td className="px-3 py-2">
<TextInput
value={row.name}
onChange={(v) => {
const next = [...content.stakeholders]
next[i] = { ...row, name: v }
patch('stakeholders', next)
}}
placeholder="@ stakeholder"
/>
</td>
<td className="px-3 py-2">
<TextInput
value={row.role}
onChange={(v) => {
const next = [...content.stakeholders]
next[i] = { ...row, role: v }
patch('stakeholders', next)
}}
placeholder="e.g., Lead Architect"
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
<button
onClick={() => patch('stakeholders', [...content.stakeholders, { name: '', role: '' }])}
className="mt-2 text-sm text-silos-brown hover:underline"
>
+ Add stakeholder
</button>
</DocSection>
<DocSection icon="✏️" title="Software quality attributes">
<p className="mb-3 text-sm text-silos-muted">
Select 1–2 attributes your team will focus on during the architecture review.
</p>
<div className="overflow-x-auto rounded-lg border border-silos-cream-dark">
<table className="w-full min-w-[700px] text-sm">
<thead>
<tr className="bg-yellow-50 text-left">
<th className="px-3 py-2 font-medium">Attribute</th>
<th className="px-3 py-2 font-medium">Definition</th>
<th className="px-3 py-2 font-medium">Key success metrics</th>
<th className="px-3 py-2 font-medium">Notes</th>
</tr>
</thead>
<tbody>
{content.qualityAttributes.map((row, i) => (
<tr key={i} className="border-t border-silos-cream-dark">
{(['attribute', 'definition', 'metrics', 'notes'] as const).map((field) => (
<td key={field} className="px-3 py-2">
<TextInput
value={row[field]}
onChange={(v) => {
const next = [...content.qualityAttributes]
next[i] = { ...row, [field]: v }
patch('qualityAttributes', next)
}}
placeholder={
field === 'attribute'
? 'e.g., Availability'
: field === 'definition'
? 'How often the system is up'
: field === 'metrics'
? 'Time to fix a failure'
: 'Links to docs'
}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</DocSection>
<DocSection icon="🎯" title="Goals">
<TextArea
value={content.goals}
onChange={(v) => patch('goals', v)}
placeholder="Type / to add an image of an architectural diagram..."
rows={3}
/>
</DocSection>
<DocSection icon="👣" title="Next steps">
<p className="mb-3 text-sm text-silos-muted">
Use this space to plan projects and estimate work.
</p>
<div className="overflow-x-auto rounded-lg border border-silos-cream-dark">
<table className="w-full min-w-[800px] text-sm">
<thead>
<tr className="bg-silos-cream/60 text-left">
<th className="px-3 py-2 font-medium">Project</th>
<th className="px-3 py-2 font-medium">Description</th>
<th className="px-3 py-2 font-medium">Estimate</th>
<th className="px-3 py-2 font-medium">Documentation</th>
<th className="px-3 py-2 font-medium">Target release</th>
</tr>
</thead>
<tbody>
{content.nextSteps.map((row, i) => (
<tr key={i} className="border-t border-silos-cream-dark">
{(['project', 'description', 'estimate', 'documentation', 'releaseDate'] as const).map(
(field) => (
<td key={field} className="px-3 py-2">
<TextInput
value={row[field]}
onChange={(v) => {
const next = [...content.nextSteps]
next[i] = { ...row, [field]: v }
patch('nextSteps', next)
}}
placeholder={
field === 'project' ? 'e.g., Frontend improvements' : ''
}
/>
</td>
),
)}
</tr>
))}
</tbody>
</table>
</div>
<button
onClick={() =>
patch('nextSteps', [
...content.nextSteps,
{ project: '', description: '', estimate: '', documentation: '', releaseDate: '' },
])
}
className="mt-2 text-sm text-silos-brown hover:underline"
>
+ Add project
</button>
</DocSection>
</>
)
}

View file

@ -0,0 +1,173 @@
import { DocSection, TextInput, TextArea } from '../DocBlocks'
import type { DocumentRecord } from '../../../lib/documents'
type MeetingContent = {
date: string
participants: string
goals: string
discussionTopics: { time: string; topic: string; presenter: string; notes: string }[]
actionItems: string[]
decisions: string[]
relatedInfo: string
}
interface Props {
doc: DocumentRecord
onUpdate: (u: Partial<DocumentRecord>) => void
}
export default function MeetingNotesTemplate({ doc, onUpdate }: Props) {
const content = doc.content as MeetingContent
function saveContent(next: MeetingContent) {
onUpdate({ content: next })
}
function patch<K extends keyof MeetingContent>(key: K, value: MeetingContent[K]) {
saveContent({ ...content, [key]: value })
}
return (
<>
<DocSection icon="📅" title="Date">
<TextInput
value={content.date}
onChange={(v) => patch('date', v)}
placeholder="30 Aug 2026"
/>
</DocSection>
<DocSection icon="👥" title="Participants">
<TextArea
value={content.participants}
onChange={(v) => patch('participants', v)}
placeholder="Type @name to mention a person and add them as a meeting attendee"
rows={2}
/>
</DocSection>
<DocSection icon="🎯" title="Goals">
<TextArea
value={content.goals}
onChange={(v) => patch('goals', v)}
placeholder="List goals for this meeting (e.g., Set design priorities for FY27)"
rows={2}
/>
</DocSection>
<DocSection icon="🎨" title="Brainstorm">
<div className="rounded-lg border border-dashed border-silos-cream-dark bg-silos-cream/30 px-4 py-6 text-center text-sm text-silos-muted">
Type /whiteboard to create an interactive canvas for icebreakers, brainstorms, and
diagramming
</div>
</DocSection>
<DocSection icon="🗣️" title="Discussion topics">
<div className="overflow-x-auto rounded-lg border border-silos-cream-dark">
<table className="w-full min-w-[600px] text-sm">
<thead>
<tr className="bg-silos-cream/60 text-left">
<th className="px-3 py-2 font-medium">Time</th>
<th className="px-3 py-2 font-medium">Topic</th>
<th className="px-3 py-2 font-medium">Presenter</th>
<th className="px-3 py-2 font-medium">Notes</th>
</tr>
</thead>
<tbody>
{content.discussionTopics.map((row, i) => (
<tr key={i} className="border-t border-silos-cream-dark">
{(['time', 'topic', 'presenter', 'notes'] as const).map((field) => (
<td key={field} className="px-3 py-2">
<TextInput
value={row[field]}
onChange={(v) => {
const next = [...content.discussionTopics]
next[i] = { ...row, [field]: v }
patch('discussionTopics', next)
}}
placeholder={
field === 'presenter' ? '@ mention teammate' : ''
}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<button
onClick={() =>
patch('discussionTopics', [
...content.discussionTopics,
{ time: '', topic: '', presenter: '', notes: '' },
])
}
className="mt-2 text-sm text-silos-brown hover:underline"
>
+ Add topic
</button>
</DocSection>
<DocSection icon="✅" title="Action items">
<p className="mb-2 text-sm text-silos-muted">
Type your action, use @ to assign to someone.
</p>
{content.actionItems.map((item, i) => (
<div key={i} className="mb-2 flex items-center gap-2">
<input type="checkbox" className="h-4 w-4 rounded" />
<TextInput
value={item}
onChange={(v) => {
const next = [...content.actionItems]
next[i] = v
patch('actionItems', next)
}}
placeholder="Type your action..."
/>
</div>
))}
<button
onClick={() => patch('actionItems', [...content.actionItems, ''])}
className="text-sm text-silos-brown hover:underline"
>
+ Add action item
</button>
</DocSection>
<DocSection icon="⤴️" title="Decisions">
<p className="mb-2 text-sm text-silos-muted">
Use /decision to record decisions made during the meeting.
</p>
{content.decisions.map((item, i) => (
<div key={i} className="mb-2">
<TextInput
value={item}
onChange={(v) => {
const next = [...content.decisions]
next[i] = v
patch('decisions', next)
}}
placeholder="Record a decision..."
/>
</div>
))}
<button
onClick={() => patch('decisions', [...content.decisions, ''])}
className="text-sm text-silos-brown hover:underline"
>
+ Add decision
</button>
</DocSection>
<DocSection icon="💼" title="Related info">
<TextArea
value={content.relatedInfo}
onChange={(v) => patch('relatedInfo', v)}
placeholder="Type /database to link assets or other meetings"
rows={2}
/>
</DocSection>
</>
)
}

View file

@ -0,0 +1,157 @@
import { DocSection, InfoBanner, TextArea } from '../DocBlocks'
import type { DocumentRecord } from '../../../lib/documents'
type RetroContent = {
overview: string
startDoing: string[]
stopDoing: string[]
keepDoing: string[]
actionItems: string[]
}
interface Props {
doc: DocumentRecord
onUpdate: (u: Partial<DocumentRecord>) => void
}
function RetroColumn({
title,
icon,
color,
items,
onChange,
placeholder,
}: {
title: string
icon: string
color: string
items: string[]
onChange: (items: string[]) => void
placeholder: string
}) {
return (
<div className="flex-1 min-w-[180px]">
<div
className="rounded-t-lg px-3 py-2 text-sm font-semibold"
style={{ backgroundColor: color }}
>
{icon} {title}
</div>
<div className="rounded-b-lg border border-t-0 border-silos-cream-dark bg-white p-3">
<ul className="space-y-2">
{items.map((item, i) => (
<li key={i} className="flex gap-2">
<span className="text-silos-muted">•</span>
<input
value={item}
onChange={(e) => {
const next = [...items]
next[i] = e.target.value
onChange(next)
}}
placeholder={placeholder}
className="flex-1 border-none bg-transparent text-sm outline-none placeholder:text-silos-muted"
/>
</li>
))}
</ul>
<button
onClick={() => onChange([...items, ''])}
className="mt-2 text-xs text-silos-brown hover:underline"
>
+ Add item
</button>
</div>
</div>
)
}
export default function RetrospectiveTemplate({ doc, onUpdate }: Props) {
const content = doc.content as RetroContent
function saveContent(next: RetroContent) {
onUpdate({ content: next })
}
function patch<K extends keyof RetroContent>(key: K, value: RetroContent[K]) {
saveContent({ ...content, [key]: value })
}
return (
<>
<InfoBanner>
Reflect on past work and identify opportunities for improvement by following the
instructions for the Retrospective Play.
</InfoBanner>
<DocSection icon="📋" title="Overview">
<TextArea
value={content.overview}
onChange={(v) => patch('overview', v)}
placeholder="Add context for this retrospective — team, sprint, or project scope"
rows={2}
/>
</DocSection>
<DocSection icon="💭" title="Retrospective">
<div className="flex flex-col gap-4 lg:flex-row">
<RetroColumn
title="Start doing"
icon="▶️"
color="#dbeafe"
items={content.startDoing}
onChange={(v) => patch('startDoing', v)}
placeholder="e.g., Sparring work earlier in the cycle"
/>
<RetroColumn
title="Stop doing"
icon="⏹️"
color="#fee2e2"
items={content.stopDoing}
onChange={(v) => patch('stopDoing', v)}
placeholder="e.g., Meetings without a clear agenda"
/>
<RetroColumn
title="Keep doing"
icon="🏆"
color="#fef9c3"
items={content.keepDoing}
onChange={(v) => patch('keepDoing', v)}
placeholder="e.g., Sharing progress at daily standups"
/>
</div>
</DocSection>
<div className="mb-6 rounded-lg border border-purple-200 bg-purple-50 px-4 py-3 text-sm text-purple-900">
💡 Pro tip: You can also run a retro on a Silos whiteboard by typing /retro
</div>
<DocSection icon="✅" title="Action items">
<p className="mb-2 text-sm text-silos-muted">
Add 1–2 follow-up action items to help the team apply what they learned:
</p>
{content.actionItems.map((item, i) => (
<div key={i} className="mb-2 flex items-center gap-2">
<input type="checkbox" className="h-4 w-4 rounded" />
<input
value={item}
onChange={(e) => {
const next = [...content.actionItems]
next[i] = e.target.value
patch('actionItems', next)
}}
placeholder="Type your action, use @ to assign to someone"
className="flex-1 rounded-lg border border-silos-cream-dark px-3 py-2 text-sm outline-none focus:border-silos-tan"
/>
</div>
))}
<button
onClick={() => patch('actionItems', [...content.actionItems, ''])}
className="text-sm text-silos-brown hover:underline"
>
+ Add action item
</button>
</DocSection>
</>
)
}

View file

@ -0,0 +1,192 @@
import { useEffect, useRef, useState } from 'react'
import {
Bold,
Italic,
Underline,
AlignLeft,
Image,
Link,
AtSign,
Smile,
Table,
Plus,
List,
Heading1,
} from 'lucide-react'
const toolbarGroups = [
[
{ icon: Heading1, label: 'Heading', cmd: 'formatBlock', value: 'h2' },
{ icon: Bold, label: 'Bold', cmd: 'bold' },
{ icon: Italic, label: 'Italic', cmd: 'italic' },
{ icon: Underline, label: 'Underline', cmd: 'underline' },
],
[
{ icon: AlignLeft, label: 'Align', cmd: 'justifyLeft' },
{ icon: List, label: 'List', cmd: 'insertUnorderedList' },
],
]
const slashItems = [
{ label: 'Paragraph', html: '<p>Paragraph</p>' },
{ label: 'Heading', html: '<h2>Heading</h2>' },
{ label: 'Bullet list', html: '<ul><li>Item</li></ul>' },
{ label: 'Table', html: '<table class="w-full border"><tr><th>A</th><th>B</th></tr><tr><td> </td><td> </td></tr></table>' },
{ label: 'Info panel', html: '<div class="rounded-lg border border-silos-tan bg-silos-cream/60 p-3 my-2">Info: add a note here.</div>' },
]
const starterHtml: Record<string, string> = {
'End of week status report': '<h2>End of week status</h2><p>Wins</p><ul><li></li></ul><p>Blockers</p><ul><li></li></ul>',
'Meeting notes': '<h2>Meeting notes</h2><p>Date:</p><ul><li>Attendees</li><li>Agenda</li><li>Actions</li></ul>',
'Project plan': '<h2>Project plan</h2><p>Goals</p><ul><li>Milestone 1</li></ul>',
}
interface SiloEditorProps {
html: string
onChange: (html: string) => void
}
export default function SiloEditor({ html, onChange }: SiloEditorProps) {
const editorRef = useRef<HTMLDivElement>(null)
const [slashOpen, setSlashOpen] = useState(false)
const primed = useRef(false)
useEffect(() => {
if (!primed.current && editorRef.current) {
editorRef.current.innerHTML = html || ''
primed.current = true
}
}, [html])
function emit() {
if (editorRef.current) onChange(editorRef.current.innerHTML)
}
function run(cmd: string, value?: string) {
document.execCommand(cmd, false, value)
editorRef.current?.focus()
emit()
}
function insertHtml(snippet: string) {
document.execCommand('insertHTML', false, snippet)
setSlashOpen(false)
emit()
}
function handleInput() {
const text = editorRef.current?.innerText ?? ''
if (text.endsWith('/')) setSlashOpen(true)
else setSlashOpen(false)
emit()
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Escape') setSlashOpen(false)
}
return (
<div className="flex min-h-[calc(100vh-8rem)] flex-col">
<div className="sticky top-0 z-10 flex flex-wrap items-center gap-1 border-b border-silos-cream-dark bg-white py-2">
{toolbarGroups.map((group, gi) => (
<div key={gi} className="flex items-center gap-0.5">
{group.map(({ icon: Icon, label, cmd, value }) => (
<button
key={label}
type="button"
title={label}
onClick={() => run(cmd, value)}
className="rounded p-1.5 text-silos-muted transition-colors hover:bg-silos-cream hover:text-silos-text"
>
<Icon className="h-4 w-4" />
</button>
))}
{gi < toolbarGroups.length - 1 && <div className="mx-1 h-5 w-px bg-silos-cream-dark" />}
</div>
))}
<div className="mx-1 h-5 w-px bg-silos-cream-dark" />
<button
type="button"
title="Link"
onClick={() => {
const url = window.prompt('Link URL')
if (url) run('createLink', url)
}}
className="rounded p-1.5 text-silos-muted hover:bg-silos-cream"
>
<Link className="h-4 w-4" />
</button>
<button type="button" title="Image" className="rounded p-1.5 text-silos-muted hover:bg-silos-cream">
<Image className="h-4 w-4" />
</button>
<button type="button" title="Mention" className="rounded p-1.5 text-silos-muted hover:bg-silos-cream">
<AtSign className="h-4 w-4" />
</button>
<button type="button" title="Emoji" className="rounded p-1.5 text-silos-muted hover:bg-silos-cream">
<Smile className="h-4 w-4" />
</button>
<button
type="button"
title="Table"
onClick={() => insertHtml('<table class="w-full border"><tr><th>A</th><th>B</th></tr><tr><td></td><td></td></tr></table>')}
className="rounded p-1.5 text-silos-muted hover:bg-silos-cream"
>
<Table className="h-4 w-4" />
</button>
<button
type="button"
title="Insert"
onClick={() => setSlashOpen((v) => !v)}
className="rounded p-1.5 text-silos-muted hover:bg-silos-cream"
>
<Plus className="h-4 w-4" />
</button>
</div>
<div className="relative flex-1 py-6">
<div
ref={editorRef}
contentEditable
suppressContentEditableWarning
onInput={handleInput}
onKeyDown={handleKeyDown}
data-placeholder="Press / to insert elements, or just start writing..."
className="prose min-h-[50vh] w-full max-w-none text-base leading-relaxed text-silos-text outline-none empty:before:text-silos-muted/60 empty:before:content-[attr(data-placeholder)]"
/>
{slashOpen && (
<div className="absolute left-0 top-16 z-20 w-64 rounded-xl border border-silos-cream-dark bg-white p-1 shadow-lg">
{slashItems.map((item) => (
<button
key={item.label}
type="button"
onClick={() => insertHtml(item.html)}
className="w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-silos-cream"
>
{item.label}
</button>
))}
</div>
)}
</div>
<div className="sticky bottom-0 border-t border-silos-cream-dark bg-white py-4">
<div className="flex flex-wrap gap-2">
{Object.keys(starterHtml).map((t) => (
<button
key={t}
type="button"
onClick={() => insertHtml(starterHtml[t])}
className="rounded-full border border-silos-cream-dark bg-silos-cream/40 px-3 py-1.5 text-xs text-silos-text hover:border-silos-tan"
>
{t}
</button>
))}
<button type="button" className="rounded-full border border-silos-cream-dark px-3 py-1.5 text-xs text-silos-muted">
All templates
</button>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,894 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import {
MousePointer2,
Hand,
StickyNote,
Pencil,
Type,
Minus,
Circle,
Square,
Grid3x3,
Plus,
Undo2,
Redo2,
ZoomIn,
ZoomOut,
Triangle,
} from 'lucide-react'
import {
type SketchElement,
type SketchTool,
type Point,
type SketchViewport,
DEFAULT_COLOR,
STICKY_COLORS,
createId,
getElementBounds,
hitTestElement,
translateElement,
} from '../../types/sketch'
interface SketchCanvasProps {
elements: SketchElement[]
viewport?: SketchViewport
onChange: (elements: SketchElement[], viewport?: SketchViewport) => void
}
type DragMode =
| { kind: 'pan'; startX: number; startY: number; origPanX: number; origPanY: number }
| { kind: 'draw-pen'; points: Point[] }
| { kind: 'draw-line'; x1: number; y1: number; x2: number; y2: number }
| { kind: 'draw-rect'; x: number; y: number; width: number; height: number }
| { kind: 'draw-ellipse'; x: number; y: number; width: number; height: number }
| { kind: 'draw-triangle'; x: number; y: number; width: number; height: number }
| { kind: 'move'; elementId: string; startX: number; startY: number }
const MAIN_TOOLS: { id: SketchTool; icon: typeof MousePointer2; label: string }[] = [
{ id: 'select', icon: MousePointer2, label: 'Select' },
{ id: 'hand', icon: Hand, label: 'Pan' },
{ id: 'sticky', icon: StickyNote, label: 'Sticky note' },
{ id: 'pen', icon: Pencil, label: 'Draw' },
{ id: 'text', icon: Type, label: 'Text' },
{ id: 'line', icon: Minus, label: 'Line' },
{ id: 'ellipse', icon: Circle, label: 'Ellipse' },
{ id: 'table', icon: Grid3x3, label: 'Table' },
]
function penToPath(points: Point[]): string {
if (points.length < 2) return ''
return points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ')
}
function normalizeRect(x1: number, y1: number, x2: number, y2: number) {
return {
x: Math.min(x1, x2),
y: Math.min(y1, y2),
width: Math.abs(x2 - x1),
height: Math.abs(y2 - y1),
}
}
export default function SketchCanvas({ elements, viewport, onChange }: SketchCanvasProps) {
const [activeTool, setActiveTool] = useState<SketchTool>('pen')
const [selectedId, setSelectedId] = useState<string | null>(null)
const [pan, setPan] = useState({ x: viewport?.panX ?? 0, y: viewport?.panY ?? 0 })
const [zoom, setZoom] = useState(viewport?.zoom ?? 100)
const [drag, setDrag] = useState<DragMode | null>(null)
const [showMore, setShowMore] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [history, setHistory] = useState<{ elements: SketchElement[]; pan: typeof pan; zoom: number }[]>([])
const [future, setFuture] = useState<{ elements: SketchElement[]; pan: typeof pan; zoom: number }[]>([])
const viewportRef = useRef<HTMLDivElement>(null)
const stickyColorIndex = useRef(0)
const scale = zoom / 100
const pushHistory = useCallback(() => {
setHistory((h) => [...h.slice(-49), { elements, pan, zoom }])
setFuture([])
}, [elements, pan, zoom])
const commit = useCallback(
(nextElements: SketchElement[], nextPan = pan, nextZoom = zoom, saveHistory = true) => {
if (saveHistory) pushHistory()
onChange(nextElements, { panX: nextPan.x, panY: nextPan.y, zoom: nextZoom })
setPan(nextPan)
setZoom(nextZoom)
},
[onChange, pan, zoom, pushHistory],
)
const getPoint = useCallback(
(e: React.MouseEvent | MouseEvent): Point => {
const rect = viewportRef.current?.getBoundingClientRect()
if (!rect) return { x: 0, y: 0 }
return {
x: (e.clientX - rect.left - pan.x) / scale,
y: (e.clientY - rect.top - pan.y) / scale,
}
},
[pan, scale],
)
const undo = () => {
if (history.length === 0) return
const prev = history[history.length - 1]
setFuture((f) => [{ elements, pan, zoom }, ...f])
setHistory((h) => h.slice(0, -1))
onChange(prev.elements, { panX: prev.pan.x, panY: prev.pan.y, zoom: prev.zoom })
setPan(prev.pan)
setZoom(prev.zoom)
setSelectedId(null)
}
const redo = () => {
if (future.length === 0) return
const next = future[0]
setHistory((h) => [...h, { elements, pan, zoom }])
setFuture((f) => f.slice(1))
onChange(next.elements, { panX: next.pan.x, panY: next.pan.y, zoom: next.zoom })
setPan(next.pan)
setZoom(next.zoom)
}
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Delete' || e.key === 'Backspace') {
if (editingId) return
if (selectedId) {
e.preventDefault()
pushHistory()
onChange(
elements.filter((el) => el.id !== selectedId),
{ panX: pan.x, panY: pan.y, zoom },
)
setSelectedId(null)
}
}
if ((e.ctrlKey || e.metaKey) && e.key === 'z') {
e.preventDefault()
if (e.shiftKey) redo()
else undo()
}
if (e.key === 'Escape') {
setSelectedId(null)
setEditingId(null)
setShowMore(false)
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [selectedId, editingId, elements, pan, zoom, onChange, pushHistory])
function findElementAt(point: Point): SketchElement | null {
for (let i = elements.length - 1; i >= 0; i--) {
if (hitTestElement(elements[i], point)) return elements[i]
}
return null
}
function handleMouseDown(e: React.MouseEvent) {
if (e.button !== 0) return
const point = getPoint(e)
if (activeTool === 'hand') {
setDrag({ kind: 'pan', startX: e.clientX, startY: e.clientY, origPanX: pan.x, origPanY: pan.y })
return
}
if (activeTool === 'select') {
const hit = findElementAt(point)
if (hit) {
setSelectedId(hit.id)
setDrag({ kind: 'move', elementId: hit.id, startX: point.x, startY: point.y })
} else {
setSelectedId(null)
}
return
}
if (activeTool === 'sticky') {
const color = STICKY_COLORS[stickyColorIndex.current % STICKY_COLORS.length]
stickyColorIndex.current += 1
const sticky: SketchElement = {
id: createId(),
type: 'sticky',
x: point.x,
y: point.y,
width: 180,
height: 140,
text: '',
color,
}
commit([...elements, sticky])
setSelectedId(sticky.id)
setEditingId(sticky.id)
return
}
if (activeTool === 'text') {
const textEl: SketchElement = {
id: createId(),
type: 'text',
x: point.x,
y: point.y,
text: 'Text',
fontSize: 18,
color: DEFAULT_COLOR,
}
commit([...elements, textEl])
setSelectedId(textEl.id)
setEditingId(textEl.id)
return
}
if (activeTool === 'table') {
const rows = 3
const cols = 3
const table: SketchElement = {
id: createId(),
type: 'table',
x: point.x,
y: point.y,
rows,
cols,
cellWidth: 80,
cellHeight: 32,
color: DEFAULT_COLOR,
cells: Array.from({ length: rows }, () => Array(cols).fill('')),
}
commit([...elements, table])
setSelectedId(table.id)
return
}
if (activeTool === 'pen') {
setDrag({ kind: 'draw-pen', points: [point] })
return
}
if (activeTool === 'line') {
setDrag({ kind: 'draw-line', x1: point.x, y1: point.y, x2: point.x, y2: point.y })
return
}
if (activeTool === 'rect') {
setDrag({ kind: 'draw-rect', x: point.x, y: point.y, width: 0, height: 0 })
return
}
if (activeTool === 'ellipse') {
setDrag({ kind: 'draw-ellipse', x: point.x, y: point.y, width: 0, height: 0 })
return
}
if (activeTool === 'triangle') {
setDrag({ kind: 'draw-triangle', x: point.x, y: point.y, width: 0, height: 0 })
}
}
function handleMouseMove(e: React.MouseEvent) {
if (!drag) return
const point = getPoint(e)
if (drag.kind === 'pan') {
setPan({
x: drag.origPanX + (e.clientX - drag.startX),
y: drag.origPanY + (e.clientY - drag.startY),
})
return
}
if (drag.kind === 'move') {
const el = elements.find((item) => item.id === drag.elementId)
if (!el) return
const dx = point.x - drag.startX
const dy = point.y - drag.startY
onChange(
elements.map((item) => (item.id === drag.elementId ? translateElement(el, dx, dy) : item)),
{ panX: pan.x, panY: pan.y, zoom },
)
setDrag({ ...drag, startX: point.x, startY: point.y })
return
}
if (drag.kind === 'draw-pen') {
setDrag({ ...drag, points: [...drag.points, point] })
return
}
if (drag.kind === 'draw-line') {
setDrag({ ...drag, x2: point.x, y2: point.y })
return
}
if (drag.kind === 'draw-rect' || drag.kind === 'draw-ellipse' || drag.kind === 'draw-triangle') {
const rect = normalizeRect(drag.x, drag.y, point.x, point.y)
setDrag({ ...drag, ...rect })
}
}
function handleMouseUp(e?: React.MouseEvent) {
if (!drag) return
if (drag.kind === 'pan' && e) {
const finalPan = {
x: drag.origPanX + (e.clientX - drag.startX),
y: drag.origPanY + (e.clientY - drag.startY),
}
setPan(finalPan)
onChange(elements, { panX: finalPan.x, panY: finalPan.y, zoom })
setDrag(null)
return
}
if (drag.kind === 'move') {
pushHistory()
setDrag(null)
return
}
if (drag.kind === 'draw-pen' && drag.points.length > 1) {
const stroke: SketchElement = {
id: createId(),
type: 'pen',
points: drag.points,
color: DEFAULT_COLOR,
strokeWidth: 2,
}
commit([...elements, stroke])
}
if (drag.kind === 'draw-line') {
const { x1, y1, x2, y2 } = drag
if (Math.hypot(x2 - x1, y2 - y1) > 4) {
commit([
...elements,
{
id: createId(),
type: 'line',
x1,
y1,
x2,
y2,
color: DEFAULT_COLOR,
strokeWidth: 2,
},
])
}
}
if (drag.kind === 'draw-rect' && drag.width > 4 && drag.height > 4) {
commit([
...elements,
{
id: createId(),
type: 'rect',
x: drag.x,
y: drag.y,
width: drag.width,
height: drag.height,
color: DEFAULT_COLOR,
strokeWidth: 2,
fill: 'transparent',
},
])
}
if (drag.kind === 'draw-ellipse' && drag.width > 4 && drag.height > 4) {
commit([
...elements,
{
id: createId(),
type: 'ellipse',
x: drag.x,
y: drag.y,
width: drag.width,
height: drag.height,
color: DEFAULT_COLOR,
strokeWidth: 2,
fill: 'transparent',
},
])
}
if (drag.kind === 'draw-triangle' && drag.width > 4 && drag.height > 4) {
commit([
...elements,
{
id: createId(),
type: 'triangle',
x: drag.x,
y: drag.y,
width: drag.width,
height: drag.height,
color: DEFAULT_COLOR,
strokeWidth: 2,
fill: 'transparent',
},
])
}
setDrag(null)
}
function updateElement(id: string, patch: Partial<SketchElement>) {
onChange(
elements.map((el) => (el.id === id ? { ...el, ...patch } as SketchElement : el)),
{ panX: pan.x, panY: pan.y, zoom },
)
}
const cursorClass =
activeTool === 'hand'
? 'cursor-grab active:cursor-grabbing'
: activeTool === 'select'
? 'cursor-default'
: 'cursor-crosshair'
const previewElement = drag?.kind.startsWith('draw-') ? drag : null
return (
<div className="relative flex h-full flex-col">
<div
ref={viewportRef}
className={`relative flex-1 overflow-hidden ${cursorClass}`}
style={{ backgroundColor: '#faf8f6' }}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={(e) => handleMouseUp(e)}
onMouseLeave={(e) => handleMouseUp(e)}
>
<div
className="absolute origin-top-left"
style={{
transform: `translate(${pan.x}px, ${pan.y}px) scale(${scale})`,
backgroundImage: 'radial-gradient(circle, #d4c4b8 1px, transparent 1px)',
backgroundSize: `${20 * scale}px ${20 * scale}px`,
width: '4000px',
height: '3000px',
}}
>
<svg className="absolute inset-0 h-full w-full overflow-visible">
{elements.map((el) => {
const selected = el.id === selectedId
if (el.type === 'pen') {
return (
<g key={el.id}>
<path
d={penToPath(el.points)}
fill="none"
stroke={el.color}
strokeWidth={el.strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
{selected && (
<rect
{...getElementBounds(el)}
fill="none"
stroke="#3B82F6"
strokeWidth={1 / scale}
strokeDasharray="4 2"
/>
)}
</g>
)
}
if (el.type === 'line') {
return (
<g key={el.id}>
<line
x1={el.x1}
y1={el.y1}
x2={el.x2}
y2={el.y2}
stroke={el.color}
strokeWidth={el.strokeWidth}
strokeLinecap="round"
/>
{selected && (
<rect
{...getElementBounds(el)}
fill="none"
stroke="#3B82F6"
strokeWidth={1 / scale}
strokeDasharray="4 2"
/>
)}
</g>
)
}
if (el.type === 'rect') {
return (
<g key={el.id}>
<rect
x={el.x}
y={el.y}
width={el.width}
height={el.height}
stroke={el.color}
strokeWidth={el.strokeWidth}
fill={el.fill}
/>
{selected && (
<rect
x={el.x - 2}
y={el.y - 2}
width={el.width + 4}
height={el.height + 4}
fill="none"
stroke="#3B82F6"
strokeWidth={1 / scale}
strokeDasharray="4 2"
/>
)}
</g>
)
}
if (el.type === 'ellipse') {
return (
<g key={el.id}>
<ellipse
cx={el.x + el.width / 2}
cy={el.y + el.height / 2}
rx={el.width / 2}
ry={el.height / 2}
stroke={el.color}
strokeWidth={el.strokeWidth}
fill={el.fill}
/>
{selected && (
<rect
x={el.x - 2}
y={el.y - 2}
width={el.width + 4}
height={el.height + 4}
fill="none"
stroke="#3B82F6"
strokeWidth={1 / scale}
strokeDasharray="4 2"
/>
)}
</g>
)
}
if (el.type === 'triangle') {
const x1 = el.x + el.width / 2
const y1 = el.y
const x2 = el.x
const y2 = el.y + el.height
const x3 = el.x + el.width
const y3 = el.y + el.height
return (
<g key={el.id}>
<polygon
points={`${x1},${y1} ${x2},${y2} ${x3},${y3}`}
stroke={el.color}
strokeWidth={el.strokeWidth}
fill={el.fill}
/>
{selected && (
<rect
x={el.x - 2}
y={el.y - 2}
width={el.width + 4}
height={el.height + 4}
fill="none"
stroke="#3B82F6"
strokeWidth={1 / scale}
strokeDasharray="4 2"
/>
)}
</g>
)
}
return null
})}
{/* Preview while drawing */}
{previewElement?.kind === 'draw-pen' && previewElement.points.length > 1 && (
<path
d={penToPath(previewElement.points)}
fill="none"
stroke={DEFAULT_COLOR}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
opacity={0.7}
/>
)}
{previewElement?.kind === 'draw-line' && (
<line
x1={previewElement.x1}
y1={previewElement.y1}
x2={previewElement.x2}
y2={previewElement.y2}
stroke={DEFAULT_COLOR}
strokeWidth={2}
opacity={0.7}
/>
)}
{previewElement?.kind === 'draw-rect' && (
<rect
x={previewElement.x}
y={previewElement.y}
width={previewElement.width}
height={previewElement.height}
stroke={DEFAULT_COLOR}
strokeWidth={2}
fill="transparent"
opacity={0.7}
/>
)}
{previewElement?.kind === 'draw-ellipse' && (
<ellipse
cx={previewElement.x + previewElement.width / 2}
cy={previewElement.y + previewElement.height / 2}
rx={previewElement.width / 2}
ry={previewElement.height / 2}
stroke={DEFAULT_COLOR}
strokeWidth={2}
fill="transparent"
opacity={0.7}
/>
)}
{previewElement?.kind === 'draw-triangle' && (
<polygon
points={`${previewElement.x + previewElement.width / 2},${previewElement.y} ${previewElement.x},${previewElement.y + previewElement.height} ${previewElement.x + previewElement.width},${previewElement.y + previewElement.height}`}
stroke={DEFAULT_COLOR}
strokeWidth={2}
fill="transparent"
opacity={0.7}
/>
)}
</svg>
{/* HTML overlays: sticky, text, table */}
{elements.map((el) => {
if (el.type === 'sticky') {
const isEditing = editingId === el.id
return (
<div
key={el.id}
className={`absolute rounded-lg shadow-md ${selectedId === el.id ? 'ring-2 ring-blue-500' : ''}`}
style={{
left: el.x,
top: el.y,
width: el.width,
height: el.height,
backgroundColor: el.color,
}}
onMouseDown={(e) => {
if (activeTool === 'select') {
e.stopPropagation()
setSelectedId(el.id)
}
}}
onDoubleClick={(e) => {
e.stopPropagation()
setEditingId(el.id)
setActiveTool('select')
}}
>
<textarea
value={el.text}
readOnly={!isEditing}
onChange={(e) => updateElement(el.id, { text: e.target.value })}
onBlur={() => setEditingId(null)}
onMouseDown={(e) => isEditing && e.stopPropagation()}
placeholder="Type a note..."
className="h-full w-full resize-none rounded-lg border-none bg-transparent p-3 text-sm text-silos-text outline-none"
/>
</div>
)
}
if (el.type === 'text') {
const isEditing = editingId === el.id
return (
<div
key={el.id}
className={`absolute ${selectedId === el.id ? 'ring-2 ring-blue-500 rounded' : ''}`}
style={{ left: el.x, top: el.y }}
onMouseDown={(e) => {
if (activeTool === 'select') {
e.stopPropagation()
setSelectedId(el.id)
}
}}
onDoubleClick={(e) => {
e.stopPropagation()
setEditingId(el.id)
setActiveTool('select')
}}
>
<input
value={el.text}
readOnly={!isEditing}
onChange={(e) => updateElement(el.id, { text: e.target.value })}
onBlur={() => setEditingId(null)}
onMouseDown={(e) => isEditing && e.stopPropagation()}
style={{ fontSize: el.fontSize, color: el.color }}
className="min-w-[60px] border-none bg-transparent font-medium outline-none"
/>
</div>
)
}
if (el.type === 'table') {
return (
<div
key={el.id}
className={`absolute overflow-hidden rounded border-2 bg-white ${selectedId === el.id ? 'ring-2 ring-blue-500' : ''}`}
style={{
left: el.x,
top: el.y,
borderColor: el.color,
}}
onMouseDown={(e) => {
if (activeTool === 'select') {
e.stopPropagation()
setSelectedId(el.id)
}
}}
>
<table className="border-collapse">
<tbody>
{el.cells.map((row, ri) => (
<tr key={ri}>
{row.map((cell, ci) => (
<td
key={ci}
className="border border-silos-cream-dark p-0"
style={{
width: el.cellWidth,
height: el.cellHeight,
}}
>
<input
value={cell}
onChange={(e) => {
const cells = el.cells.map((r) => [...r])
cells[ri][ci] = e.target.value
updateElement(el.id, { cells })
}}
onMouseDown={(e) => e.stopPropagation()}
className="h-full w-full border-none bg-transparent px-2 text-xs outline-none"
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}
return null
})}
</div>
</div>
{/* Undo / Redo */}
<div className="absolute bottom-20 left-4 flex gap-1">
<button
type="button"
onClick={undo}
disabled={history.length === 0}
className="rounded-lg border border-silos-cream-dark bg-white p-2 shadow-sm hover:bg-silos-cream disabled:opacity-40"
title="Undo (Ctrl+Z)"
>
<Undo2 className="h-4 w-4 text-silos-muted" />
</button>
<button
type="button"
onClick={redo}
disabled={future.length === 0}
className="rounded-lg border border-silos-cream-dark bg-white p-2 shadow-sm hover:bg-silos-cream disabled:opacity-40"
title="Redo (Ctrl+Shift+Z)"
>
<Redo2 className="h-4 w-4 text-silos-muted" />
</button>
</div>
{/* Bottom toolbar — 10 tools */}
<div className="absolute bottom-4 left-1/2 flex -translate-x-1/2 items-center gap-1 rounded-xl border border-silos-cream-dark bg-white px-2 py-1.5 shadow-lg">
{MAIN_TOOLS.map(({ id, icon: Icon, label }) => (
<button
key={id}
type="button"
title={label}
onClick={() => {
setActiveTool(id)
setShowMore(false)
}}
className={`rounded-lg p-2 transition-colors ${
activeTool === id
? 'bg-silos-brown/10 text-silos-brown'
: 'text-silos-muted hover:bg-silos-cream'
}`}
>
<Icon className="h-4 w-4" />
</button>
))}
<div className="mx-1 h-6 w-px bg-silos-cream-dark" />
{/* Tool 9: Rectangle */}
<button
type="button"
title="Rectangle"
onClick={() => {
setActiveTool('rect')
setShowMore(false)
}}
className={`rounded-lg p-2 transition-colors ${
activeTool === 'rect'
? 'bg-silos-brown/10 text-silos-brown'
: 'text-silos-muted hover:bg-silos-cream'
}`}
>
<Square className="h-4 w-4" />
</button>
{/* Tool 10: More shapes */}
<div className="relative">
<button
type="button"
title="More shapes"
onClick={() => setShowMore(!showMore)}
className={`rounded-lg p-2 transition-colors ${
activeTool === 'triangle' || showMore
? 'bg-silos-brown/10 text-silos-brown'
: 'text-silos-muted hover:bg-silos-cream'
}`}
>
<Plus className="h-4 w-4" />
</button>
{showMore && (
<div className="absolute bottom-full left-1/2 mb-2 -translate-x-1/2 rounded-lg border border-silos-cream-dark bg-white p-1 shadow-lg">
<button
type="button"
title="Triangle"
onClick={() => {
setActiveTool('triangle')
setShowMore(false)
}}
className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm text-silos-text hover:bg-silos-cream"
>
<Triangle className="h-4 w-4" />
Triangle
</button>
</div>
)}
</div>
</div>
{/* Zoom */}
<div className="absolute bottom-4 right-4 flex items-center gap-1 rounded-lg border border-silos-cream-dark bg-white px-2 py-1 shadow-sm">
<button
type="button"
onClick={() => {
const next = Math.max(50, zoom - 10)
setZoom(next)
onChange(elements, { panX: pan.x, panY: pan.y, zoom: next })
}}
>
<ZoomOut className="h-4 w-4 text-silos-muted" />
</button>
<span className="w-10 text-center text-xs text-silos-muted">{zoom}%</span>
<button
type="button"
onClick={() => {
const next = Math.min(200, zoom + 10)
setZoom(next)
onChange(elements, { panX: pan.x, panY: pan.y, zoom: next })
}}
>
<ZoomIn className="h-4 w-4 text-silos-muted" />
</button>
</div>
</div>
)
}

View file

@ -0,0 +1,215 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Plus, FileText, PenTool, Zap, LayoutGrid } from 'lucide-react'
import type { SteadContent } from '../../data/steadData'
import { spaceNavItems } from '../../data/steadData'
import {
listDocumentsByStead,
createDocument,
getDefaultTitle,
type DocumentRecord,
} from '../../lib/documents'
interface SteadHubProps {
doc: DocumentRecord
content: SteadContent
onSectionChange: (section: string) => void
onAddPlan: () => void
onUpdatePlan: (index: number, field: string, value: string) => void
}
const typeIcons: Record<string, typeof FileText> = {
shed: Zap,
silo: FileText,
sketch: PenTool,
stead: LayoutGrid,
}
export default function SteadHub({
doc,
content,
onSectionChange,
onAddPlan,
onUpdatePlan,
}: SteadHubProps) {
const navigate = useNavigate()
const [linkedDocs, setLinkedDocs] = useState<DocumentRecord[]>([])
useEffect(() => {
void listDocumentsByStead(doc.id).then(setLinkedDocs)
}, [doc.id, doc.updatedAt])
async function createInSpace(type: 'shed' | 'silo' | 'sketch') {
const defaults: Record<string, Record<string, unknown>> = {
silo: { body: '' },
sketch: { elements: [] },
shed: { overview: '' },
}
const newDoc = await createDocument(
type,
getDefaultTitle(type),
defaults[type],
undefined,
doc.createdBy,
doc.id,
)
navigate(`/workspace/${type}/${newDoc.id}`)
}
return (
<div className="flex h-full">
<aside className="w-52 shrink-0 border-r border-silos-cream-dark bg-silos-cream/30 p-4">
<div className="flex items-center gap-2">
<span
className="flex h-8 w-8 items-center justify-center rounded-lg text-lg"
style={{ backgroundColor: content.iconColor + '44' }}
>
{content.icon}
</span>
<span className="truncate text-sm font-semibold text-silos-text">{doc.title}</span>
</div>
<nav className="mt-6 space-y-0.5">
{spaceNavItems.map((item) => (
<button
key={item}
type="button"
onClick={() => onSectionChange(item)}
className={`w-full rounded-lg px-3 py-2 text-left text-sm transition-colors ${
content.activeSection === item
? 'bg-white font-medium text-silos-text shadow-sm'
: 'text-silos-muted hover:bg-white/60'
}`}
>
{item}
</button>
))}
</nav>
</aside>
<div className="flex-1 overflow-y-auto p-8">
{content.activeSection === 'Content' && (
<div>
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-silos-text">Content</h2>
<div className="flex gap-2">
<button
type="button"
onClick={() => void createInSpace('silo')}
className="flex items-center gap-1 rounded-lg border border-silos-cream-dark px-3 py-1.5 text-xs text-silos-text hover:bg-silos-cream"
>
<Plus className="h-3 w-3" /> Silo
</button>
<button
type="button"
onClick={() => void createInSpace('sketch')}
className="flex items-center gap-1 rounded-lg border border-silos-cream-dark px-3 py-1.5 text-xs text-silos-text hover:bg-silos-cream"
>
<Plus className="h-3 w-3" /> Sketch
</button>
<button
type="button"
onClick={() => void createInSpace('shed')}
className="flex items-center gap-1 rounded-lg bg-silos-brown px-3 py-1.5 text-xs text-white hover:bg-silos-brown-dark"
>
<Plus className="h-3 w-3" /> Shed
</button>
</div>
</div>
{linkedDocs.length === 0 ? (
<div className="mt-8 rounded-xl border-2 border-dashed border-silos-cream-dark py-16 text-center">
<p className="text-sm text-silos-muted">
No documents yet. Create a Silo, Sketch, or Shed to get started.
</p>
</div>
) : (
<div className="mt-6 space-y-2">
{linkedDocs.map((d) => {
const Icon = typeIcons[d.type] ?? FileText
return (
<button
key={d.id}
type="button"
onClick={() => navigate(`/workspace/${d.type}/${d.id}`)}
className="flex w-full items-center gap-3 rounded-lg border border-silos-cream-dark px-4 py-3 text-left transition-colors hover:bg-silos-cream/40"
>
<Icon className="h-4 w-4 text-silos-tan" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-silos-text">{d.title}</p>
<p className="text-xs capitalize text-silos-muted">{d.type}</p>
</div>
<span className="text-xs text-silos-muted">
{new Date(d.updatedAt).toLocaleDateString()}
</span>
</button>
)
})}
</div>
)}
</div>
)}
{content.activeSection === 'Execution plans' && (
<div>
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-silos-text">Execution plans</h2>
<button
type="button"
onClick={onAddPlan}
className="flex items-center gap-1 rounded-lg bg-silos-brown px-3 py-1.5 text-xs text-white hover:bg-silos-brown-dark"
>
<Plus className="h-3 w-3" /> Add plan
</button>
</div>
{content.executionPlans.length === 0 ? (
<div className="mt-8 rounded-xl border-2 border-dashed border-silos-cream-dark py-16 text-center">
<p className="text-sm text-silos-muted">
Track milestones, deliverables, and execution timelines here.
</p>
</div>
) : (
<div className="mt-6 space-y-3">
{content.executionPlans.map((plan, i) => (
<div key={i} className="rounded-lg border border-silos-cream-dark p-4">
<input
value={plan.title}
onChange={(e) => onUpdatePlan(i, 'title', e.target.value)}
placeholder="Plan title"
className="w-full border-none bg-transparent text-sm font-medium text-silos-text outline-none"
/>
<div className="mt-2 flex gap-3">
<select
value={plan.status}
onChange={(e) => onUpdatePlan(i, 'status', e.target.value)}
className="rounded border border-silos-cream-dark px-2 py-1 text-xs text-silos-text"
>
<option value="planned">Planned</option>
<option value="in-progress">In progress</option>
<option value="done">Done</option>
</select>
<input
type="date"
value={plan.dueDate}
onChange={(e) => onUpdatePlan(i, 'dueDate', e.target.value)}
className="rounded border border-silos-cream-dark px-2 py-1 text-xs text-silos-text"
/>
</div>
</div>
))}
</div>
)}
</div>
)}
{!['Content', 'Execution plans'].includes(content.activeSection) && (
<div className="flex min-h-[40vh] flex-col items-center justify-center text-center">
<p className="text-lg font-medium text-silos-text">{content.activeSection}</p>
<p className="mt-2 max-w-sm text-sm text-silos-muted">
This section is ready for your team. Add content from the Content tab or create new
documents directly in this space.
</p>
</div>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,125 @@
import { useState } from 'react'
import { X } from 'lucide-react'
import { shareDocument, unshareDocument, type DocumentRecord } from '../../lib/documents'
import { getStoredUser } from '../../lib/api'
interface ShareModalProps {
doc: DocumentRecord
onClose: () => void
onDocChange: (doc: DocumentRecord) => void
}
export default function ShareModal({ doc, onClose, onDocChange }: ShareModalProps) {
const user = getStoredUser()
const isOwner = user?.id === doc.ownerId
const [identifier, setIdentifier] = useState('')
const [role, setRole] = useState<'editor' | 'viewer'>('editor')
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
async function handleInvite(e: React.FormEvent) {
e.preventDefault()
if (!identifier.trim()) return
setBusy(true)
setError(null)
try {
const updated = await shareDocument(doc.id, identifier.trim(), role)
if (updated) {
onDocChange(updated)
setIdentifier('')
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not share')
} finally {
setBusy(false)
}
}
async function handleRemove(userId: number) {
setBusy(true)
setError(null)
try {
const updated = await unshareDocument(doc.id, userId)
if (updated) onDocChange(updated)
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not update sharing')
} finally {
setBusy(false)
}
}
return (
<div className="fixed inset-0 z-[90] flex items-center justify-center bg-black/30 px-4">
<button type="button" className="absolute inset-0" onClick={onClose} aria-label="Close" />
<div className="relative w-full max-w-md rounded-2xl border border-silos-cream-dark bg-white p-5 shadow-xl">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-silos-text">Share</h2>
<button type="button" onClick={onClose} className="rounded-lg p-1 hover:bg-silos-cream">
<X className="h-4 w-4 text-silos-muted" />
</button>
</div>
<p className="mt-1 text-xs text-silos-muted">
Sharing this item does not automatically share documents inside a Stead. Invite people to
each page they should see.
</p>
{isOwner ? (
<form onSubmit={handleInvite} className="mt-4 flex gap-2">
<input
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
placeholder="Email or username"
className="flex-1 rounded-lg border border-silos-cream-dark px-3 py-2 text-sm outline-none focus:border-silos-brown"
/>
<select
value={role}
onChange={(e) => setRole(e.target.value as 'editor' | 'viewer')}
className="rounded-lg border border-silos-cream-dark px-2 text-sm"
>
<option value="editor">Editor</option>
<option value="viewer">Viewer</option>
</select>
<button
type="submit"
disabled={busy}
className="rounded-lg bg-silos-brown px-3 py-2 text-sm text-white disabled:opacity-50"
>
Invite
</button>
</form>
) : (
<p className="mt-4 text-sm text-silos-muted">Only the owner can invite people.</p>
)}
{error && <p className="mt-2 text-sm text-red-600">{error}</p>}
<ul className="mt-4 space-y-2">
<li className="flex items-center justify-between rounded-lg bg-silos-cream/50 px-3 py-2 text-sm">
<span>{doc.createdBy} (you)</span>
<span className="text-xs text-silos-muted">Owner</span>
</li>
{doc.members.map((member) => (
<li
key={member.user_id}
className="flex items-center justify-between rounded-lg border border-silos-cream-dark px-3 py-2 text-sm"
>
<div>
<p className="text-silos-text">{member.name || member.email}</p>
<p className="text-xs capitalize text-silos-muted">{member.role}</p>
</div>
{isOwner && (
<button
type="button"
onClick={() => void handleRemove(member.user_id)}
className="text-xs text-silos-brown hover:underline"
>
Remove
</button>
)}
</li>
))}
</ul>
</div>
</div>
)
}

View file

@ -0,0 +1,199 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Menu, Share2, Link2, MoreHorizontal, Users, Search } from 'lucide-react'
import { logos } from '../../data/brandAssets'
import { getStoredUser } from '../../lib/api'
import { getDocument, updateDocument, type DocumentRecord } from '../../lib/documents'
import CreateDropdown from '../create/CreateDropdown'
import WorkspaceSidebar from './WorkspaceSidebar'
import ShareModal from './ShareModal'
import SearchOverlay from '../search/SearchOverlay'
type WorkspaceLayout = 'default' | 'page' | 'canvas' | 'space'
interface WorkspaceShellProps {
doc: DocumentRecord
onDocChange: (doc: DocumentRecord) => void
children: React.ReactNode
layout?: WorkspaceLayout
headerActions?: React.ReactNode
hideTitle?: boolean
presence?: { name: string; initial: string }[]
}
export default function WorkspaceShell({
doc,
onDocChange,
children,
layout = 'default',
headerActions,
hideTitle = false,
presence = [],
}: WorkspaceShellProps) {
const user = getStoredUser()
const [shareOpen, setShareOpen] = useState(false)
const [searchOpen, setSearchOpen] = useState(false)
const [copied, setCopied] = useState(false)
const [parentStead, setParentStead] = useState<DocumentRecord | null>(null)
useEffect(() => {
if (!doc.steadId) {
setParentStead(null)
return
}
void getDocument(doc.steadId)
.then((found) => setParentStead(found))
.catch(() => setParentStead(null))
}, [doc.steadId])
useEffect(() => {
function onKey(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault()
setSearchOpen(true)
}
if (e.key === 'Escape') setSearchOpen(false)
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [])
function handleTitleChange(title: string) {
void updateDocument(doc.id, { title }).then((updated) => {
if (updated) onDocChange(updated)
})
}
async function copyLink() {
await navigator.clipboard.writeText(window.location.href)
setCopied(true)
setTimeout(() => setCopied(false), 1600)
}
const isCanvas = layout === 'canvas'
const isSpace = layout === 'space'
const isPage = layout === 'page'
return (
<div className="flex h-screen flex-col bg-silos-cream">
<header className="flex h-12 shrink-0 items-center gap-3 border-b border-silos-cream-dark bg-white px-3">
<Link to="/dashboard" className="rounded-lg p-2 text-silos-muted hover:bg-silos-cream">
<Menu className="h-4 w-4" />
</Link>
<Link to="/dashboard">
<img src={logos.rustyBrown} alt="Silos" className="h-6 w-auto" />
</Link>
<div className="hidden min-w-0 flex-1 items-center gap-1 truncate text-xs text-silos-muted sm:flex">
<Link to="/dashboard" className="hover:text-silos-brown">
Dashboard
</Link>
{parentStead && (
<>
<span>/</span>
<Link to={`/workspace/stead/${parentStead.id}`} className="truncate hover:text-silos-brown">
{parentStead.title}
</Link>
</>
)}
<span>/</span>
<span className="truncate text-silos-text">{doc.title}</span>
</div>
<button
type="button"
onClick={() => setSearchOpen(true)}
className="rounded-lg p-1.5 text-silos-muted hover:bg-silos-cream"
aria-label="Search"
>
<Search className="h-3.5 w-3.5" />
</button>
<CreateDropdown steadId={doc.type === 'stead' ? doc.id : doc.steadId} />
<div className="flex items-center gap-1 border-l border-silos-cream-dark pl-2">
<span className="hidden text-xs text-green-600 sm:inline">Saved</span>
{headerActions}
<div className="flex items-center -space-x-1">
{presence.map((p) => (
<div
key={p.name}
title={p.name}
className="flex h-6 w-6 items-center justify-center rounded-full bg-silos-brown text-[10px] font-bold text-white ring-2 ring-white"
>
{p.initial}
</div>
))}
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-silos-tan text-[10px] font-bold text-white ring-2 ring-white">
{user?.full_name?.[0] ?? 'Y'}
</div>
</div>
<Users className="ml-1 hidden h-3.5 w-3.5 text-silos-muted sm:block" />
<button
type="button"
onClick={() => setShareOpen(true)}
className="rounded-lg p-1.5 text-silos-muted hover:bg-silos-cream"
aria-label="Share"
>
<Share2 className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => void copyLink()}
className="rounded-lg p-1.5 text-silos-muted hover:bg-silos-cream"
aria-label="Copy link"
title={copied ? 'Copied' : 'Copy link'}
>
<Link2 className="h-3.5 w-3.5" />
</button>
<button className="rounded-lg p-1.5 text-silos-muted hover:bg-silos-cream" aria-label="More">
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
</div>
</header>
{copied && (
<div className="absolute right-4 top-14 z-50 rounded-lg bg-silos-text px-3 py-1.5 text-xs text-white">
Link copied
</div>
)}
<div className="flex flex-1 overflow-hidden">
{!isSpace && (
<WorkspaceSidebar collapsed={false} activeDocId={doc.id} steadId={doc.steadId} />
)}
<main className={`flex-1 bg-white ${isCanvas || isSpace ? 'overflow-hidden' : 'overflow-y-auto'}`}>
{isCanvas || isSpace ? (
children
) : (
<div className={`mx-auto ${isPage ? 'max-w-5xl' : 'max-w-4xl'} px-6 sm:px-10 ${isPage ? 'py-4' : 'py-8'}`}>
{!hideTitle && (
<>
<input
value={doc.title}
onChange={(e) => handleTitleChange(e.target.value)}
className={`w-full border-none bg-transparent font-bold text-silos-text outline-none ${
isPage ? 'text-3xl' : 'text-2xl'
}`}
placeholder={isPage ? 'Give this page a title' : 'Start with a title...'}
/>
<div className="mt-2 flex items-center gap-3 text-sm text-silos-muted">
<Users className="h-3.5 w-3.5" />
<span>By {doc.createdBy}</span>
{!isPage && (
<>
<span>·</span>
<span className="capitalize">{doc.type} — Live collaboration</span>
</>
)}
</div>
</>
)}
<div className={hideTitle ? 'h-full' : 'mt-8'}>{children}</div>
</div>
)}
</main>
</div>
{shareOpen && (
<ShareModal doc={doc} onClose={() => setShareOpen(false)} onDocChange={onDocChange} />
)}
<SearchOverlay open={searchOpen} onClose={() => setSearchOpen(false)} />
</div>
)
}

View file

@ -0,0 +1,133 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { FileText, Zap, PenTool, LayoutGrid, Plus } from 'lucide-react'
import { listDocuments, listDocumentsByStead, type DocumentRecord } from '../../lib/documents'
import type { CreateType } from '../../data/createData'
const typeIcons: Record<CreateType, typeof FileText> = {
silo: FileText,
shed: Zap,
sketch: PenTool,
stead: LayoutGrid,
}
const typeColors: Record<CreateType, string> = {
silo: '#6B8CAE',
shed: '#C17D65',
sketch: '#7BAF8E',
stead: '#9B7BB8',
}
interface WorkspaceSidebarProps {
collapsed: boolean
activeDocId: string
steadId?: string
}
export default function WorkspaceSidebar({ collapsed, activeDocId, steadId }: WorkspaceSidebarProps) {
const [docs, setDocs] = useState<DocumentRecord[]>([])
const [steads, setSteads] = useState<DocumentRecord[]>([])
useEffect(() => {
if (steadId) {
void Promise.all([listDocumentsByStead(steadId), listDocuments('stead')]).then(
([siblings, spaces]) => {
setDocs(siblings)
setSteads(spaces)
},
)
} else {
void listDocuments().then((all) => {
setDocs(all.filter((d) => d.type !== 'stead'))
setSteads(all.filter((d) => d.type === 'stead'))
})
}
}, [activeDocId, steadId])
const parent = steads.find((s) => s.id === steadId)
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>
)
}
return (
<aside className="flex w-56 shrink-0 flex-col border-r border-silos-cream-dark bg-white">
<div className="border-b border-silos-cream-dark p-3">
<Link to="/dashboard" className="text-xs font-medium text-silos-muted hover:text-silos-brown">
← Back to Dashboard
</Link>
</div>
<div className="flex-1 overflow-y-auto p-2">
{parent && (
<>
<Link
to={`/workspace/stead/${parent.id}`}
className="mb-2 flex items-center gap-2 rounded-lg px-2 py-2 text-sm font-medium text-silos-text hover:bg-silos-cream"
>
<LayoutGrid className="h-3.5 w-3.5 text-silos-tan" />
<span className="truncate">{parent.title}</span>
</Link>
<p className="px-2 py-1.5 text-xs font-semibold uppercase tracking-wide text-silos-muted">
In this space
</p>
</>
)}
{!parent && (
<p className="px-2 py-1.5 text-xs font-semibold uppercase tracking-wide text-silos-muted">
Content
</p>
)}
{docs.length === 0 ? (
<p className="px-2 py-4 text-xs text-silos-muted">No documents yet</p>
) : (
docs.map((doc) => {
const Icon = typeIcons[doc.type]
return (
<Link
key={doc.id}
to={`/workspace/${doc.type}/${doc.id}`}
className={`mb-0.5 flex items-center gap-2 rounded-lg px-2 py-2 text-sm transition-colors ${
doc.id === activeDocId
? 'bg-silos-brown/10 font-medium text-silos-brown'
: 'text-silos-text hover:bg-silos-cream'
}`}
>
<Icon className="h-3.5 w-3.5 shrink-0" style={{ color: typeColors[doc.type] }} />
<span className="truncate">{doc.title}</span>
</Link>
)
})
)}
</div>
<div 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>
</aside>
)
}

View file

@ -0,0 +1,33 @@
export type AuthTab = 'login' | 'signup' | 'sso'
export type AuthView = AuthTab | 'forgot-password' | 'reset-password'
export interface UserInfo {
fullName: string
username: string
employeeId: string
phone: string
occupation: string
}
export const emptyUserInfo = (): UserInfo => ({
fullName: '',
username: '',
employeeId: '',
phone: '',
occupation: '',
})
export const ssoProviders = [
{ id: 'google', name: 'Google', logo: '/Assets/sso/google.svg' },
{ id: 'microsoft', name: 'Microsoft', logo: '/Assets/sso/microsoft.svg' },
{ id: 'slack', name: 'Slack', logo: '/Assets/sso/slack.svg' },
{ id: 'okta', name: 'Okta', logo: '/Assets/sso/okta.svg' },
{ id: 'apple', name: 'Apple', logo: '/Assets/sso/apple.svg' },
{ id: 'github', name: 'GitHub', logo: '/Assets/sso/github.svg' },
] as const
export const authTabs: { id: AuthTab; label: string }[] = [
{ id: 'login', label: 'Login' },
{ id: 'signup', label: 'Sign Up' },
{ id: 'sso', label: 'SSO Login' },
]

View file

@ -0,0 +1,5 @@
export const logos = {
white: '/Assets/White logo.png',
black: '/Assets/Black logo.png',
rustyBrown: '/Assets/Rusty brown logo.png',
} as const

View file

@ -0,0 +1,90 @@
export type CreateType = 'shed' | 'silo' | 'sketch' | 'stead'
export type ShedTemplateId =
| 'architecture-review'
| 'meeting-notes'
| 'retrospective'
| 'templates'
export interface CreateOption {
id: CreateType
label: string
product: string
description: string
icon: string
color: string
}
export interface ShedTemplate {
id: ShedTemplateId
label: string
description: string
emoji: string
available: boolean
}
export const createOptions: CreateOption[] = [
{
id: 'shed',
label: 'Live Doc',
product: 'Shed',
description: 'Collaborate in real time with your team',
icon: 'Zap',
color: '#C17D65',
},
{
id: 'silo',
label: 'Page',
product: 'Silo',
description: 'Document and store information',
icon: 'FileText',
color: '#6B8CAE',
},
{
id: 'sketch',
label: 'Whiteboard',
product: 'Sketch',
description: 'Rough diagrams and visual plans',
icon: 'PenTool',
color: '#7BAF8E',
},
{
id: 'stead',
label: 'Space',
product: 'Stead',
description: 'Organize project docs in one area',
icon: 'LayoutGrid',
color: '#9B7BB8',
},
]
export const shedTemplates: ShedTemplate[] = [
{
id: 'architecture-review',
label: 'Software architecture review',
description: 'Review architecture, stakeholders, and quality attributes',
emoji: '🏗️',
available: true,
},
{
id: 'meeting-notes',
label: 'Meeting Notes',
description: 'Capture goals, discussion topics, and action items',
emoji: '📝',
available: true,
},
{
id: 'retrospective',
label: 'Retrospective',
description: 'Reflect on what to start, stop, and keep doing',
emoji: '💭',
available: true,
},
{
id: 'templates',
label: 'Templates',
description: 'More templates coming soon (5–9 planned)',
emoji: '📋',
available: false,
},
]

View file

@ -0,0 +1,110 @@
export const navItems = [
{ id: 'for-you', label: 'For you', icon: 'Home' },
{ id: 'templates', label: 'Templates', icon: 'LayoutTemplate' },
{ id: 'recent', label: 'Recent', icon: 'Clock' },
{ id: 'favourites', label: 'Favourite Projects', icon: 'Star' },
{ id: 'all-projects', label: 'All Projects', icon: 'FolderOpen' },
] as const
export type NavItemId = (typeof navItems)[number]['id']
export const personalProjects = [
{ id: '1', name: 'Product Roadmap', color: '#C17D65', letter: 'P' },
{ id: '2', name: 'Design System', color: '#D4956A', letter: 'D' },
{ id: '3', name: 'Marketing Plan', color: '#9B7BB8', letter: 'M' },
{ id: '4', name: 'Engineering Docs', color: '#6B8CAE', letter: 'E' },
{ id: '5', name: 'Research', color: '#7BAF8E', letter: 'R' },
] as const
export const recentActivities = [
{
id: '1',
title: 'Project Roadmap',
action: 'Updated by Priya',
project: 'Product Roadmap',
time: '2h ago',
iconColor: '#6B8CAE',
userInitial: 'P',
userColor: '#C17D65',
},
{
id: '2',
title: 'Sprint Planning Notes',
action: 'Commented by James',
project: 'Engineering Docs',
time: '4h ago',
iconColor: '#C17D65',
userInitial: 'J',
userColor: '#6B8CAE',
},
{
id: '3',
title: 'Brand Guidelines v2',
action: 'Created by Sarah',
project: 'Design System',
time: 'Yesterday',
iconColor: '#D4956A',
userInitial: 'S',
userColor: '#9B7BB8',
},
{
id: '4',
title: 'Q1 Campaign Brief',
action: 'Shared by Mike',
project: 'Marketing Plan',
time: 'Yesterday',
iconColor: '#9B7BB8',
userInitial: 'M',
userColor: '#7BAF8E',
},
{
id: '5',
title: 'User Research Findings',
action: 'Updated by Alex',
project: 'Research',
time: '2 days ago',
iconColor: '#7BAF8E',
userInitial: 'A',
userColor: '#C17D65',
},
{
id: '6',
title: 'API Documentation',
action: 'Reviewed by David',
project: 'Engineering Docs',
time: '3 days ago',
iconColor: '#6B8CAE',
userInitial: 'D',
userColor: '#D4956A',
},
] as const
export const activityStats = [
{ label: 'Pages Created', value: 12, change: '+20% from last week' },
{ label: 'Comments', value: 28, change: '+15% from last week' },
{ label: 'Reactions', value: 56, change: '+32% from last week' },
] as const
export const discoverItems = [
{
id: '1',
title: 'Popular Templates',
description: 'Get started faster with ready-made templates',
icon: 'LayoutTemplate',
color: '#C17D65',
},
{
id: '2',
title: 'Recommended Projects',
description: 'Projects your team is working on',
icon: 'FolderOpen',
color: '#6B8CAE',
},
{
id: '3',
title: 'Knowledge Hub',
description: 'Company-wide docs and resources',
icon: 'BookOpen',
color: '#7BAF8E',
},
] as const

View file

@ -0,0 +1,142 @@
export const products = [
{
name: 'Silos Docs',
description: 'Create rich documents with powerful editing tools',
icon: 'FileText',
},
{
name: 'Silos Boards',
description: 'Kanban boards and task management for your team',
icon: 'LayoutGrid',
},
{
name: 'Silos Base',
description: 'Structured databases to organize your knowledge',
icon: 'Database',
},
{
name: 'Silos Whiteboard',
description: 'Visual collaboration with infinite canvas',
icon: 'PenTool',
},
{
name: 'Silos Integrations',
description: 'Connect with Slack, GitHub, Google Drive & more',
icon: 'Plug',
},
] as const
export const resources = [
{
name: 'Documentation',
description: 'Guides and API references to get you started',
icon: 'BookOpen',
},
{
name: 'Templates',
description: 'Ready-made templates for common workflows',
icon: 'LayoutTemplate',
},
{
name: 'Community',
description: 'Join discussions with other Silos users',
icon: 'Users',
},
{
name: 'Blog',
description: 'Product updates, tips, and best practices',
icon: 'Newspaper',
},
{
name: 'Help Center',
description: 'FAQs and support articles',
icon: 'HelpCircle',
},
] as const
export const productDetails = [
{
title: 'Enterprise Security',
description: 'SSO, audit logs, and role-based access control',
},
{
title: 'Cloud & On-Premise',
description: 'Deploy in the cloud or on your own infrastructure',
},
{
title: 'API & Webhooks',
description: 'Build custom integrations with our REST API',
},
{
title: 'Analytics',
description: 'Insights into team productivity and content usage',
},
] as const
export const stats = [
{ label: '10K+ Teams', icon: 'Users' },
{ label: '2M+ Pages created', icon: 'FileText' },
{ label: '50M+ Collaborations', icon: 'Handshake' },
{ label: '99.9% Uptime SLA', icon: 'Shield' },
] as const
export const features = [
{
title: 'Rich Editor',
description: 'Write with headings, lists, code blocks, and embeds',
preview: 'editor',
},
{
title: 'Databases',
description: 'Tables, filters, and views for structured data',
preview: 'database',
},
{
title: 'Whiteboards',
description: 'Sketch ideas and map workflows visually',
preview: 'whiteboard',
},
{
title: 'Tasks & Boards',
description: 'Kanban boards to track work from start to finish',
preview: 'kanban',
},
{
title: 'Integrations',
description: 'Connect your favorite tools in one workspace',
preview: 'integrations',
},
{
title: 'Real-time Collaboration',
description: 'Edit together with live cursors and comments',
preview: 'collaboration',
},
] as const
export const workflowSteps = [
{
title: 'Create',
description: 'Start with docs, boards, or whiteboards',
icon: 'FilePlus',
},
{
title: 'Organize',
description: 'Structure content in spaces and folders',
icon: 'FolderOpen',
},
{
title: 'Collaborate',
description: 'Work together in real time with your team',
icon: 'Users',
},
{
title: 'Share',
description: 'Publish and share with the right people',
icon: 'Share2',
},
{
title: 'Achieve',
description: 'Track goals and celebrate milestones',
icon: 'Flag',
},
] as const

View file

@ -0,0 +1,76 @@
export const spaceIcons = [
{ id: 'sliders', emoji: '🎛️', color: '#E57373' },
{ id: 'flag', emoji: '🚩', color: '#EF5350' },
{ id: 'cat', emoji: '🐱', color: '#FF7043' },
{ id: 'lifebuoy', emoji: '🛟', color: '#42A5F5' },
{ id: 'code', emoji: '💻', color: '#66BB6A' },
{ id: 'rocket', emoji: '🚀', color: '#AB47BC' },
{ id: 'folder', emoji: '📁', color: '#FFA726' },
{ id: 'star', emoji: '⭐', color: '#FFCA28' },
] as const
export const spacePurposes = [
{
id: 'collaboration' as const,
title: 'Collaboration',
description: 'Explore and iterate on work with your team in real time.',
},
{
id: 'knowledge' as const,
title: 'Knowledge base',
description: 'Create, publish, and share important documentation.',
},
{
id: 'custom' as const,
title: 'Custom',
description: 'Manually configure your space.',
},
]
export const spaceFeatures = [
{ id: 'blogs' as const, label: 'Blogs', icon: '💬', defaultOn: false },
{ id: 'liveDocs' as const, label: 'Live docs', icon: '📄', defaultOn: true },
{ id: 'calendars' as const, label: 'Calendars', icon: '📅', defaultOn: true },
{ id: 'whiteboards' as const, label: 'Whiteboards', icon: '✏️', defaultOn: true },
]
export const spaceNavItems = [
'Content',
'Team goals',
'Ideas',
'Brainstorm',
'Research',
'Slides',
'Calendar',
'Execution plans',
] as const
export type SpacePurpose = 'collaboration' | 'knowledge' | 'custom'
export type SpaceFeatureId = 'blogs' | 'liveDocs' | 'calendars' | 'whiteboards'
export interface SteadContent {
icon: string
iconColor: string
purpose: SpacePurpose
features: Record<SpaceFeatureId, boolean>
description: string
setupComplete: boolean
executionPlans: { title: string; status: 'planned' | 'in-progress' | 'done'; dueDate: string }[]
activeSection: string
}
export const defaultSteadContent = (): SteadContent => ({
icon: '🎛️',
iconColor: '#E57373',
purpose: 'collaboration',
features: {
blogs: false,
liveDocs: true,
calendars: true,
whiteboards: true,
},
description: '',
setupComplete: false,
executionPlans: [],
activeSection: 'Content',
})

View file

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

View file

@ -0,0 +1,65 @@
import { useEffect, useRef, useState } from 'react'
export interface CollabPeer {
id: number
name: string
initial: string
}
export function useCollab(
docId: string | undefined,
enabled: boolean,
onRemoteContent: (content: Record<string, unknown>) => void,
) {
const [peers, setPeers] = useState<CollabPeer[]>([])
const socketRef = useRef<WebSocket | null>(null)
const onRemote = useRef(onRemoteContent)
onRemote.current = onRemoteContent
useEffect(() => {
if (!enabled || !docId) return
const token = localStorage.getItem('silos_access_token')
if (!token) return
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
const ws = new WebSocket(
`${protocol}://${window.location.host}/api/v1/ws/collab/${docId}?token=${encodeURIComponent(token)}`,
)
socketRef.current = ws
ws.onmessage = (event) => {
const payload = JSON.parse(event.data) as {
type: string
content?: Record<string, unknown>
peers?: { id: number; name: string }[]
from?: number
}
if (payload.type === 'presence' && payload.peers) {
setPeers(
payload.peers.map((p) => ({
id: p.id,
name: p.name,
initial: p.name[0]?.toUpperCase() ?? '?',
})),
)
}
if (payload.type === 'content' && payload.content) {
onRemote.current(payload.content)
}
}
return () => {
ws.close()
socketRef.current = null
setPeers([])
}
}, [docId, enabled])
function sendContent(content: Record<string, unknown>) {
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(JSON.stringify({ type: 'content', content }))
}
}
return { peers, sendContent }
}

View file

@ -0,0 +1,47 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { listDocuments, type DocumentRecord } from '../lib/documents'
import { sortByRecent } from '../lib/documentUtils'
export function useDashboardDocuments() {
const [documents, setDocuments] = useState<DocumentRecord[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const refresh = useCallback(async () => {
setLoading(true)
setError(null)
try {
const docs = await listDocuments(undefined, { sort: 'last_visited_at' })
setDocuments(docs)
} catch {
setError('Could not load documents. Is MongoDB running?')
setDocuments([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void refresh()
}, [refresh])
const recent = useMemo(() => sortByRecent(documents), [documents])
const projects = useMemo(
() => documents.filter((d) => d.type === 'stead'),
[documents],
)
const starredProjects = useMemo(
() => projects.filter((d) => d.starred),
[projects],
)
return {
documents,
recent,
projects,
starredProjects,
loading,
error,
refresh,
}
}

View file

@ -0,0 +1,9 @@
import { useEffect } from 'react'
import { recordVisit } from '../lib/documents'
export function useRecordVisit(docId: string | undefined) {
useEffect(() => {
if (!docId) return
void recordVisit(docId)
}, [docId])
}

50
frontend/src/index.css Normal file
View file

@ -0,0 +1,50 @@
@import "tailwindcss";
@theme {
--color-silos-brown: #6b3a2c;
--color-silos-brown-dark: #4a281f;
--color-silos-brown-light: #8b4d3b;
--color-silos-tan: #c17d65;
--color-silos-cream: #f5ede4;
--color-silos-cream-dark: #ebe0d4;
--color-silos-text: #3d2b24;
--color-silos-muted: #7a6a62;
--font-sans: "Inter", system-ui, sans-serif;
--font-display: "Playfair Display", Georgia, serif;
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
font-family: var(--font-sans);
color: var(--color-silos-text);
background-color: var(--color-silos-cream);
-webkit-font-smoothing: antialiased;
}
@keyframes workflow-pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.08); opacity: 0.85; }
}
@keyframes workflow-flow {
0% { stroke-dashoffset: 24; }
100% { stroke-dashoffset: 0; }
}
.workflow-step-active {
animation: workflow-pulse 2s ease-in-out infinite;
}
.workflow-arrow-animate {
stroke-dasharray: 8 4;
animation: workflow-flow 1.5s linear infinite;
}

261
frontend/src/lib/api.ts Normal file
View file

@ -0,0 +1,261 @@
const API_BASE = import.meta.env.VITE_API_URL || '/api/v1'
export interface User {
id: number
email: string
username: string
full_name: string
employee_id: string | null
phone: string | null
occupation: string | null
is_active: boolean
created_at: string
updated_at: string
}
export interface AuthTokens {
access_token: string
refresh_token: string
token_type: string
user: User
}
export interface RegisterPayload {
email: string
username: string
password: string
full_name: string
employee_id?: string
phone?: string
occupation?: string
}
export interface LoginPayload {
username_or_email: string
password: string
remember_me?: boolean
}
export interface UserUpdatePayload {
full_name?: string
employee_id?: string
phone?: string
occupation?: string
}
class ApiError extends Error {
status: number
constructor(message: string, status: number) {
super(message)
this.status = status
}
}
function parseErrorMessage(data: unknown, status: number): string {
if (!data || typeof data !== 'object') {
if (status >= 500) return 'Server error. Please ensure the backend is running.'
return 'Something went wrong'
}
const record = data as Record<string, unknown>
const detail = record.detail
if (typeof detail === 'string') return detail
if (Array.isArray(detail)) {
return detail
.map((item) => {
if (item && typeof item === 'object' && 'msg' in item) {
return String((item as { msg: unknown }).msg)
}
return JSON.stringify(item)
})
.join(', ')
}
if (typeof record.message === 'string') return record.message
if (status >= 500) return 'Server error. Please ensure the backend is running.'
return 'Something went wrong'
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = localStorage.getItem('silos_access_token')
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>),
}
if (token) {
headers.Authorization = `Bearer ${token}`
}
let response: Response
try {
response = await fetch(`${API_BASE}${path}`, {
...options,
headers,
})
} catch {
throw new ApiError('Cannot reach the server. Is the backend running on port 8000?', 0)
}
const data = await response.json().catch(() => ({}))
if (!response.ok) {
throw new ApiError(parseErrorMessage(data, response.status), response.status)
}
if (response.status === 204) {
return undefined as T
}
return data as T
}
export const authApi = {
register: (payload: RegisterPayload) =>
request<User>('/auth/register', {
method: 'POST',
body: JSON.stringify(payload),
}),
login: (payload: LoginPayload) =>
request<AuthTokens>('/auth/login', {
method: 'POST',
body: JSON.stringify(payload),
}),
me: () => request<User>('/auth/me'),
updateMe: (payload: UserUpdatePayload) =>
request<User>('/auth/me', {
method: 'PATCH',
body: JSON.stringify(payload),
}),
forgotPassword: (email: string) =>
request<{ message: string; reset_token?: string }>('/auth/forgot-password', {
method: 'POST',
body: JSON.stringify({ email }),
}),
resetPassword: (token: string, new_password: string) =>
request<{ message: string }>('/auth/reset-password', {
method: 'POST',
body: JSON.stringify({ token, new_password }),
}),
}
export function saveAuth(tokens: AuthTokens) {
localStorage.setItem('silos_access_token', tokens.access_token)
localStorage.setItem('silos_refresh_token', tokens.refresh_token)
localStorage.setItem('silos_user', JSON.stringify(tokens.user))
}
export function clearAuth() {
localStorage.removeItem('silos_access_token')
localStorage.removeItem('silos_refresh_token')
localStorage.removeItem('silos_user')
}
export function getStoredUser(): User | null {
const raw = localStorage.getItem('silos_user')
if (!raw) return null
try {
return JSON.parse(raw) as User
} catch {
return null
}
}
export { ApiError }
export interface DocumentMember {
user_id: number
role: 'editor' | 'viewer'
name: string
email: string
}
export interface ApiDocument {
id: string
type: 'shed' | 'silo' | 'sketch' | 'stead'
title: string
template_id?: string | null
stead_id?: string | null
content: Record<string, unknown>
created_by: string
created_at: string
updated_at: string
starred: boolean
last_visited_at: string | null
owner_id?: number | null
visibility?: 'private' | 'members'
members?: DocumentMember[]
}
export interface DocumentCreatePayload {
type: 'shed' | 'silo' | 'sketch' | 'stead'
title: string
content?: Record<string, unknown>
template_id?: string
stead_id?: string
}
export interface DocumentUpdatePayload {
title?: string
content?: Record<string, unknown>
starred?: boolean
}
export const documentsApi = {
list: (params?: {
type?: string
stead_id?: string
starred?: boolean
sort?: 'updated_at' | 'last_visited_at'
}) => {
const search = new URLSearchParams()
if (params?.type) search.set('type', params.type)
if (params?.stead_id) search.set('stead_id', params.stead_id)
if (params?.starred !== undefined) search.set('starred', String(params.starred))
if (params?.sort) search.set('sort', params.sort)
const qs = search.toString()
return request<ApiDocument[]>(`/documents${qs ? `?${qs}` : ''}`)
},
search: (q: string) =>
request<ApiDocument[]>(`/documents/search?q=${encodeURIComponent(q)}`),
get: (id: string) => request<ApiDocument>(`/documents/${id}`),
create: (payload: DocumentCreatePayload) =>
request<ApiDocument>('/documents', {
method: 'POST',
body: JSON.stringify(payload),
}),
update: (id: string, payload: DocumentUpdatePayload) =>
request<ApiDocument>(`/documents/${id}`, {
method: 'PATCH',
body: JSON.stringify(payload),
}),
visit: (id: string) =>
request<ApiDocument>(`/documents/${id}/visit`, { method: 'POST' }),
share: (id: string, email_or_username: string, role: 'editor' | 'viewer' = 'editor') =>
request<ApiDocument>(`/documents/${id}/share`, {
method: 'POST',
body: JSON.stringify({ email_or_username, role }),
}),
unshare: (id: string, userId: number) =>
request<ApiDocument>(`/documents/${id}/share/${userId}`, { method: 'DELETE' }),
delete: (id: string) =>
request<void>(`/documents/${id}`, { method: 'DELETE' }),
}

View file

@ -0,0 +1,95 @@
import type { CreateType } from '../data/createData'
import type { DocumentRecord } from './documents'
export const typeColors: Record<CreateType, string> = {
silo: '#6B8CAE',
shed: '#C17D65',
sketch: '#7BAF8E',
stead: '#9B7BB8',
}
export const typeLabels: Record<CreateType, string> = {
silo: 'Silo',
shed: 'Shed',
sketch: 'Sketch',
stead: 'Stead',
}
export function getActivityDate(doc: DocumentRecord): string {
return doc.lastVisitedAt ?? doc.updatedAt
}
export function formatRelativeTime(iso: string): string {
const date = new Date(iso)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMs / 3600000)
const diffDays = Math.floor(diffMs / 86400000)
if (diffMins < 1) return 'Just now'
if (diffMins < 60) return `${diffMins}m ago`
if (diffHours < 24) return `${diffHours}h ago`
if (diffDays === 1) return 'Yesterday'
if (diffDays < 7) return `${diffDays} days ago`
return date.toLocaleDateString('en-US', {
day: 'numeric',
month: 'long',
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
})
}
export function formatVisitedDate(iso: string): string {
const date = new Date(iso)
const now = new Date()
const diffDays = Math.floor((now.getTime() - date.getTime()) / 86400000)
if (diffDays === 0) return 'Visited today'
if (diffDays === 1) return 'Visited yesterday'
return `Visited ${date.toLocaleDateString('en-US', { day: 'numeric', month: 'long', year: 'numeric' })}`
}
export function groupByTimePeriod(docs: DocumentRecord[]): { label: string; items: DocumentRecord[] }[] {
const now = new Date()
const groups: { label: string; items: DocumentRecord[] }[] = [
{ label: 'Today', items: [] },
{ label: 'Yesterday', items: [] },
{ label: 'In the last week', items: [] },
{ label: 'Earlier', items: [] },
]
for (const doc of docs) {
const date = new Date(getActivityDate(doc))
const diffDays = Math.floor((now.getTime() - date.getTime()) / 86400000)
if (diffDays === 0) groups[0].items.push(doc)
else if (diffDays === 1) groups[1].items.push(doc)
else if (diffDays < 7) groups[2].items.push(doc)
else groups[3].items.push(doc)
}
return groups.filter((g) => g.items.length > 0)
}
export function sortByRecent(docs: DocumentRecord[]): DocumentRecord[] {
return [...docs].sort(
(a, b) => new Date(getActivityDate(b)).getTime() - new Date(getActivityDate(a)).getTime(),
)
}
export function getProjectIcon(doc: DocumentRecord): string {
if (doc.type === 'stead') {
const content = doc.content as { icon?: string }
return content.icon ?? '📁'
}
return '📄'
}
export function getProjectColor(doc: DocumentRecord): string {
if (doc.type === 'stead') {
const content = doc.content as { iconColor?: string }
return content.iconColor ?? typeColors.stead
}
return typeColors[doc.type]
}

View file

@ -0,0 +1,215 @@
import type { CreateType, ShedTemplateId } from '../data/createData'
import { documentsApi, ApiError, type ApiDocument, type DocumentMember } from './api'
export interface DocumentMeta {
id: string
type: CreateType
title: string
templateId?: ShedTemplateId
steadId?: string
createdAt: string
updatedAt: string
createdBy: string
starred: boolean
lastVisitedAt?: string
ownerId?: number
visibility?: 'private' | 'members'
members: DocumentMember[]
}
export interface DocumentRecord extends DocumentMeta {
content: Record<string, unknown>
}
export interface ListDocumentsOptions {
sort?: 'updated_at' | 'last_visited_at'
starred?: boolean
}
function fromApi(doc: ApiDocument): DocumentRecord {
return {
id: doc.id,
type: doc.type,
title: doc.title,
templateId: doc.template_id as ShedTemplateId | undefined,
steadId: doc.stead_id ?? undefined,
content: doc.content,
createdBy: doc.created_by,
createdAt: doc.created_at,
updatedAt: doc.updated_at,
starred: doc.starred,
lastVisitedAt: doc.last_visited_at ?? undefined,
ownerId: doc.owner_id ?? undefined,
visibility: doc.visibility,
members: doc.members ?? [],
}
}
export async function listDocuments(
type?: CreateType,
options?: ListDocumentsOptions,
): Promise<DocumentRecord[]> {
const docs = await documentsApi.list({
type,
starred: options?.starred,
sort: options?.sort,
})
return docs.map(fromApi)
}
export async function listDocumentsByStead(steadId: string): Promise<DocumentRecord[]> {
const docs = await documentsApi.list({ stead_id: steadId })
return docs.filter((d) => d.type !== 'stead').map(fromApi)
}
export async function searchDocuments(q: string): Promise<DocumentRecord[]> {
if (!q.trim()) return []
const docs = await documentsApi.search(q)
return docs.map(fromApi)
}
export async function getDocument(id: string): Promise<DocumentRecord | null> {
try {
const doc = await documentsApi.get(id)
return fromApi(doc)
} catch (error) {
if (error instanceof ApiError && error.status === 403) {
throw error
}
return null
}
}
export async function createDocument(
type: CreateType,
title: string,
content: Record<string, unknown>,
templateId?: ShedTemplateId,
_createdBy = 'You',
steadId?: string,
): Promise<DocumentRecord> {
const doc = await documentsApi.create({
type,
title,
content,
template_id: templateId,
stead_id: steadId,
})
return fromApi(doc)
}
export async function updateDocument(
id: string,
updates: Partial<Pick<DocumentRecord, 'title' | 'content'>>,
): Promise<DocumentRecord | null> {
try {
const doc = await documentsApi.update(id, {
title: updates.title,
content: updates.content,
})
return fromApi(doc)
} catch {
return null
}
}
export async function toggleStar(id: string, starred: boolean): Promise<DocumentRecord | null> {
try {
const doc = await documentsApi.update(id, { starred })
return fromApi(doc)
} catch {
return null
}
}
export async function recordVisit(id: string): Promise<void> {
try {
await documentsApi.visit(id)
} catch {
// Non-critical — ignore if visit tracking fails
}
}
export async function shareDocument(
id: string,
emailOrUsername: string,
role: 'editor' | 'viewer' = 'editor',
): Promise<DocumentRecord | null> {
const doc = await documentsApi.share(id, emailOrUsername, role)
return fromApi(doc)
}
export async function unshareDocument(id: string, userId: number): Promise<DocumentRecord | null> {
const doc = await documentsApi.unshare(id, userId)
return fromApi(doc)
}
export function getDefaultShedContent(templateId: ShedTemplateId): Record<string, unknown> {
switch (templateId) {
case 'architecture-review':
return {
reviewDate: '',
projectLead: '',
overview: '',
architectureIssues: [
{ issue: '', impact: '', priority: 'MEDIUM', notes: '' },
{ issue: '', impact: '', priority: 'LOW', notes: '' },
],
stakeholders: [{ name: '', role: '' }],
qualityAttributes: [
{ attribute: 'Availability', definition: '', metrics: '', notes: '' },
{ attribute: '', definition: '', metrics: '', notes: '' },
],
goals: '',
nextSteps: [
{ project: '', description: '', estimate: '', documentation: '', releaseDate: '' },
],
}
case 'meeting-notes':
return {
date: new Date().toISOString().split('T')[0],
participants: '',
goals: '',
discussionTopics: [
{ time: '', topic: '', presenter: '', notes: '' },
{ time: '', topic: '', presenter: '', notes: '' },
],
actionItems: [''],
decisions: [''],
relatedInfo: '',
}
case 'retrospective':
return {
overview: '',
startDoing: [''],
stopDoing: [''],
keepDoing: [''],
actionItems: [''],
}
default:
return {}
}
}
export function getDefaultTitle(type: CreateType, templateId?: ShedTemplateId): string {
const date = new Date().toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
if (type === 'shed' && templateId) {
const names: Record<string, string> = {
'architecture-review': 'Software architecture review',
'meeting-notes': 'Meeting Notes',
retrospective: 'Retrospective',
}
return `${names[templateId] ?? 'Live Doc'} — ${date}`
}
const typeNames: Record<CreateType, string> = {
shed: 'Untitled Live Doc',
silo: 'Untitled Page',
sketch: 'Untitled Whiteboard',
stead: 'Untitled Space',
}
return `${typeNames[type]} — ${date}`
}

13
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import './index.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
)

View file

@ -0,0 +1,5 @@
import DashboardLayout from '../components/dashboard/DashboardLayout'
export default function DashboardPage() {
return <DashboardLayout />
}

View file

@ -0,0 +1,19 @@
import { Link } from 'react-router-dom'
export default function ForbiddenPage() {
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-silos-cream px-6 text-center">
<p className="text-sm font-semibold uppercase tracking-wide text-silos-tan">403</p>
<h1 className="mt-2 text-2xl font-bold text-silos-text">You don’t have access</h1>
<p className="mt-2 max-w-md text-sm text-silos-muted">
This document is private. Ask the owner to invite you by email or username.
</p>
<Link
to="/dashboard"
className="mt-6 rounded-lg bg-silos-brown px-4 py-2 text-sm font-medium text-white hover:bg-silos-brown-dark"
>
Back to dashboard
</Link>
</div>
)
}

View file

@ -0,0 +1,19 @@
import Header from '../components/Header'
import Hero from '../components/Hero'
import Features from '../components/Features'
import Workflow from '../components/Workflow'
import Footer from '../components/Footer'
export default function LandingPage() {
return (
<div className="min-h-screen">
<Header />
<main>
<Hero />
<Features />
<Workflow />
</main>
<Footer />
</div>
)
}

View file

@ -0,0 +1,367 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion'
import { logos } from '../data/brandAssets'
import {
type AuthTab,
type AuthView,
type UserInfo,
emptyUserInfo,
authTabs,
} from '../data/authData'
import { authApi, saveAuth, ApiError } from '../lib/api'
import AuthSidebar from '../components/auth/AuthSidebar'
import AuthFooter from '../components/auth/AuthFooter'
import LoginForm from '../components/auth/LoginForm'
import SignUpForm from '../components/auth/SignUpForm'
import SSOForm from '../components/auth/SSOForm'
import ForgotPasswordForm from '../components/auth/ForgotPasswordForm'
import ResetPasswordForm from '../components/auth/ResetPasswordForm'
import QuickActions from '../components/auth/QuickActions'
function StatusMessage({ message, type }: { message: string; type: 'success' | 'error' }) {
return (
<div
className={`rounded-xl px-4 py-3 text-sm ${
type === 'success'
? 'bg-green-50 text-green-800 border border-green-200'
: 'bg-red-50 text-red-800 border border-red-200'
}`}
>
{message}
</div>
)
}
export default function LoginPage() {
const navigate = useNavigate()
const [activeTab, setActiveTab] = useState<AuthTab>('login')
const [view, setView] = useState<AuthView>('login')
const [status, setStatus] = useState<{ message: string; type: 'success' | 'error' } | null>(null)
const [loginUsername, setLoginUsername] = useState('')
const [loginPassword, setLoginPassword] = useState('')
const [rememberMe, setRememberMe] = useState(false)
const [signupEmail, setSignupEmail] = useState('')
const [signupPassword, setSignupPassword] = useState('')
const [signupConfirmPassword, setSignupConfirmPassword] = useState('')
const [forgotEmail, setForgotEmail] = useState('')
const [resetPassword, setResetPassword] = useState('')
const [resetConfirmPassword, setResetConfirmPassword] = useState('')
const [selectedSSO, setSelectedSSO] = useState<string | null>(null)
const [userInfo, setUserInfo] = useState<UserInfo>(emptyUserInfo())
const [resetToken, setResetToken] = useState('')
const [loading, setLoading] = useState(false)
const showStatus = (message: string, type: 'success' | 'error' = 'success') => {
setStatus({ message, type })
if (!loading) {
setTimeout(() => setStatus(null), 4000)
}
}
const switchTab = (tab: AuthTab) => {
setActiveTab(tab)
setView(tab)
setStatus(null)
}
const handleUserInfoChange = (field: keyof UserInfo, value: string) => {
setUserInfo((prev) => ({ ...prev, [field]: value }))
}
const handleLogin = async () => {
if (!loginUsername || !loginPassword) {
showStatus('Please enter your username and password.', 'error')
return
}
setLoading(true)
try {
const tokens = await authApi.login({
username_or_email: loginUsername,
password: loginPassword,
remember_me: rememberMe,
})
saveAuth(tokens)
showStatus(`Welcome back, ${tokens.user.full_name}! Login successful.`)
setTimeout(() => navigate('/dashboard'), 800)
} catch (err) {
showStatus(err instanceof ApiError ? err.message : 'Login failed.', 'error')
} finally {
setLoading(false)
}
}
const handleSignUp = async () => {
if (!signupEmail || !signupPassword || !signupConfirmPassword) {
showStatus('Please fill in all required fields.', 'error')
return
}
if (signupPassword !== signupConfirmPassword) {
showStatus('Passwords do not match.', 'error')
return
}
if (!userInfo.fullName || !userInfo.username) {
showStatus('Please complete your user information.', 'error')
return
}
setLoading(true)
try {
await authApi.register({
email: signupEmail,
username: userInfo.username,
password: signupPassword,
full_name: userInfo.fullName,
employee_id: userInfo.employeeId || undefined,
phone: userInfo.phone || undefined,
occupation: userInfo.occupation || undefined,
})
const tokens = await authApi.login({
username_or_email: signupEmail,
password: signupPassword,
})
saveAuth(tokens)
showStatus(`Welcome, ${tokens.user.full_name}! Account created.`)
setTimeout(() => navigate('/dashboard'), 800)
} catch (err) {
showStatus(err instanceof ApiError ? err.message : 'Registration failed.', 'error')
} finally {
setLoading(false)
}
}
const handleSSO = () => {
if (!selectedSSO) {
showStatus('Please select an SSO provider.', 'error')
return
}
showStatus(`Redirecting to ${selectedSSO} authentication...`)
}
const handleSSOSelect = (providerId: string) => {
setSelectedSSO(providerId)
setActiveTab('sso')
setView('sso')
}
const handleForgotPassword = async () => {
if (!forgotEmail) {
showStatus('Please enter your email address.', 'error')
return
}
setLoading(true)
try {
const result = await authApi.forgotPassword(forgotEmail)
if (result.reset_token) {
setResetToken(result.reset_token)
}
showStatus('Reset link sent. Check your inbox.')
setView('reset-password')
} catch (err) {
showStatus(err instanceof ApiError ? err.message : 'Request failed.', 'error')
} finally {
setLoading(false)
}
}
const handleResetPassword = async () => {
if (!resetToken) {
showStatus('Reset token missing. Please request a new reset link.', 'error')
return
}
if (!resetPassword || !resetConfirmPassword) {
showStatus('Please enter and confirm your new password.', 'error')
return
}
if (resetPassword !== resetConfirmPassword) {
showStatus('Passwords do not match.', 'error')
return
}
setLoading(true)
try {
await authApi.resetPassword(resetToken, resetPassword)
showStatus('Password reset successfully! You can now log in.')
setResetPassword('')
setResetConfirmPassword('')
setResetToken('')
switchTab('login')
} catch (err) {
showStatus(err instanceof ApiError ? err.message : 'Reset failed.', 'error')
} finally {
setLoading(false)
}
}
const renderMainForm = () => {
switch (view) {
case 'forgot-password':
return (
<ForgotPasswordForm
email={forgotEmail}
onEmailChange={setForgotEmail}
onSubmit={handleForgotPassword}
onBack={() => switchTab('login')}
/>
)
case 'reset-password':
return (
<ResetPasswordForm
newPassword={resetPassword}
confirmPassword={resetConfirmPassword}
onNewPasswordChange={setResetPassword}
onConfirmPasswordChange={setResetConfirmPassword}
onSubmit={handleResetPassword}
onBack={() => switchTab('login')}
/>
)
case 'signup':
return (
<SignUpForm
email={signupEmail}
password={signupPassword}
confirmPassword={signupConfirmPassword}
userInfo={userInfo}
onEmailChange={setSignupEmail}
onPasswordChange={setSignupPassword}
onConfirmPasswordChange={setSignupConfirmPassword}
onUserInfoChange={handleUserInfoChange}
onSubmit={handleSignUp}
/>
)
case 'sso':
return (
<SSOForm
selectedProvider={selectedSSO}
userInfo={userInfo}
onProviderSelect={setSelectedSSO}
onUserInfoChange={handleUserInfoChange}
onSubmit={handleSSO}
/>
)
default:
return (
<LoginForm
username={loginUsername}
password={loginPassword}
rememberMe={rememberMe}
onUsernameChange={setLoginUsername}
onPasswordChange={setLoginPassword}
onRememberMeChange={setRememberMe}
onSubmit={handleLogin}
onForgotPassword={() => setView('forgot-password')}
onSSOSelect={handleSSOSelect}
/>
)
}
}
const isSubView = view === 'forgot-password' || view === 'reset-password'
return (
<div className="flex min-h-screen flex-col bg-silos-cream">
<div className="flex flex-1">
<AuthSidebar />
<main className="flex flex-1 flex-col items-center px-4 py-6 sm:px-6 lg:py-8">
<div className="mb-6 w-full max-w-5xl xl:hidden">
<Link to="/">
<img src={logos.rustyBrown} alt="Silos" className="h-8 w-auto" />
</Link>
</div>
<div className="w-full max-w-5xl overflow-hidden rounded-2xl border border-silos-cream-dark bg-white shadow-xl">
{/* Tab bar */}
{!isSubView && (
<div className="flex flex-col gap-3 border-b border-silos-cream-dark px-6 py-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex gap-1">
{authTabs.map((tab) => (
<button
key={tab.id}
onClick={() => switchTab(tab.id)}
className={`rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
activeTab === tab.id
? 'bg-silos-brown text-white'
: 'text-silos-muted hover:bg-silos-cream hover:text-silos-text'
}`}
>
{tab.label}
</button>
))}
</div>
{activeTab === 'login' && (
<p className="text-sm text-silos-muted">
New to Silos?{' '}
<button
onClick={() => switchTab('signup')}
className="font-semibold text-silos-brown hover:underline"
>
Sign up
</button>
</p>
)}
{activeTab === 'signup' && (
<p className="text-sm text-silos-muted">
Already have an account?{' '}
<button
onClick={() => switchTab('login')}
className="font-semibold text-silos-brown hover:underline"
>
Login
</button>
</p>
)}
</div>
)}
{/* Content */}
<div className="grid lg:grid-cols-5">
<div className="border-b border-silos-cream-dark p-6 lg:col-span-3 lg:border-b-0 lg:border-r lg:p-8">
<AnimatePresence mode="wait">
<motion.div
key={view}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 10 }}
transition={{ duration: 0.2 }}
>
{(status || loading) && (
<div className="mb-5">
{loading ? (
<div className="rounded-xl border border-silos-cream-dark bg-silos-cream/50 px-4 py-3 text-sm text-silos-muted">
Processing...
</div>
) : status ? (
<StatusMessage message={status.message} type={status.type} />
) : null}
</div>
)}
{renderMainForm()}
</motion.div>
</AnimatePresence>
</div>
<div className="bg-silos-cream/30 p-6 lg:col-span-2 lg:p-8">
<QuickActions
onResetPassword={() => setView('reset-password')}
onCreateAccount={() => switchTab('signup')}
/>
</div>
</div>
</div>
<p className="mt-6">
<Link to="/" className="text-sm text-silos-muted hover:text-silos-brown">
&larr; Back to home
</Link>
</p>
</main>
</div>
<AuthFooter />
</div>
)
}

View file

@ -0,0 +1,78 @@
import { useState, useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import WorkspaceShell from '../../components/workspace/WorkspaceShell'
import ArchitectureReviewTemplate from '../../components/shed/templates/ArchitectureReviewTemplate'
import MeetingNotesTemplate from '../../components/shed/templates/MeetingNotesTemplate'
import RetrospectiveTemplate from '../../components/shed/templates/RetrospectiveTemplate'
import { getDocument, type DocumentRecord } from '../../lib/documents'
import { useAutoSave } from '../../hooks/useAutoSave'
import { useRecordVisit } from '../../hooks/useRecordVisit'
import { useCollab } from '../../hooks/useCollab'
import { ApiError } from '../../lib/api'
function ShedTemplate({
doc,
onUpdate,
}: {
doc: DocumentRecord
onUpdate: (u: Partial<DocumentRecord>) => void
}) {
switch (doc.templateId) {
case 'architecture-review':
return <ArchitectureReviewTemplate doc={doc} onUpdate={onUpdate} />
case 'meeting-notes':
return <MeetingNotesTemplate doc={doc} onUpdate={onUpdate} />
case 'retrospective':
return <RetrospectiveTemplate doc={doc} onUpdate={onUpdate} />
default:
return <p className="text-silos-muted">Unknown template</p>
}
}
export default function ShedPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const [doc, setDoc] = useState<DocumentRecord | null>(null)
useEffect(() => {
if (!id) return
void getDocument(id)
.then((found) => {
if (!found || found.type !== 'shed') {
navigate('/dashboard')
return
}
setDoc(found)
})
.catch((err) => {
if (err instanceof ApiError && err.status === 403) navigate('/forbidden')
else navigate('/dashboard')
})
}, [id, navigate])
useAutoSave(doc)
useRecordVisit(doc?.id)
const collab = useCollab(
doc?.id,
Boolean(doc && doc.templateId === 'meeting-notes'),
(content) => setDoc((prev) => (prev ? { ...prev, content } : prev)),
)
if (!doc) return null
return (
<WorkspaceShell
doc={doc}
onDocChange={setDoc}
presence={collab.peers.map((p) => ({ name: p.name, initial: p.initial }))}
>
<ShedTemplate
doc={doc}
onUpdate={(u) => {
setDoc({ ...doc, ...u })
if (u.content) collab.sendContent(u.content)
}}
/>
</WorkspaceShell>
)
}

View file

@ -0,0 +1,93 @@
import { useState, useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import WorkspaceShell from '../../components/workspace/WorkspaceShell'
import SiloEditor from '../../components/silo/SiloEditor'
import { getDocument, type DocumentRecord } from '../../lib/documents'
import { useAutoSave } from '../../hooks/useAutoSave'
import { useRecordVisit } from '../../hooks/useRecordVisit'
import { ApiError } from '../../lib/api'
function siloHtml(content: Record<string, unknown>): string {
if (typeof content.bodyHtml === 'string' && content.bodyHtml) return content.bodyHtml
if (typeof content.body === 'string') return content.body.replace(/\n/g, '<br/>')
return ''
}
export default function SiloPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const [doc, setDoc] = useState<DocumentRecord | null>(null)
useEffect(() => {
if (!id) return
void getDocument(id)
.then((found) => {
if (!found || found.type !== 'silo') {
navigate('/dashboard')
return
}
setDoc(found)
})
.catch((err) => {
if (err instanceof ApiError && err.status === 403) navigate('/forbidden')
else navigate('/dashboard')
})
}, [id, navigate])
useAutoSave(doc)
useRecordVisit(doc?.id)
if (!doc) return null
const status = (doc.content.status as string) || 'draft'
function patchHtml(bodyHtml: string) {
setDoc((prev) =>
prev
? { ...prev, content: { ...prev.content, bodyHtml, body: prev.content.body ?? '' } }
: prev,
)
}
function publish() {
setDoc((prev) =>
prev
? {
...prev,
content: {
...prev.content,
status: status === 'published' ? 'draft' : 'published',
},
}
: prev,
)
}
return (
<WorkspaceShell
doc={doc}
onDocChange={setDoc}
layout="page"
headerActions={
<>
<button
type="button"
onClick={publish}
className="hidden rounded-lg bg-silos-brown px-3 py-1 text-xs font-medium text-white hover:bg-silos-brown-dark sm:block"
>
{status === 'published' ? 'Unpublish' : 'Publish'}
</button>
<button
type="button"
onClick={() => navigate('/dashboard')}
className="hidden rounded-lg px-2 py-1 text-xs text-silos-muted hover:bg-silos-cream sm:block"
>
Close
</button>
</>
}
>
<SiloEditor html={siloHtml(doc.content)} onChange={patchHtml} />
</WorkspaceShell>
)
}

Some files were not shown because too many files have changed in this diff Show more