first commit
15
.gitignore
vendored
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1 @@
|
|||
|
||||
31
backend/app/config.py
Normal 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
|
|
@ -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()
|
||||
61
backend/app/dependencies.py
Normal 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
|
|
@ -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"}
|
||||
3
backend/app/models/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from app.models.user import PasswordResetToken, User
|
||||
|
||||
__all__ = ["User", "PasswordResetToken"]
|
||||
39
backend/app/models/user.py
Normal 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
|
|
@ -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
|
||||
1
backend/app/routers/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
116
backend/app/routers/auth.py
Normal 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.")
|
||||
97
backend/app/routers/collab.py
Normal 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)
|
||||
159
backend/app/routers/documents.py
Normal 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")
|
||||
40
backend/app/routers/health.py
Normal 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",
|
||||
}
|
||||
21
backend/app/schemas/__init__.py
Normal 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",
|
||||
]
|
||||
51
backend/app/schemas/document.py
Normal 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)
|
||||
59
backend/app/schemas/user.py
Normal 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
|
||||
1
backend/app/services/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
256
backend/app/services/document_service.py
Normal 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
|
||||
80
backend/app/services/user_service.py
Normal 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
|
||||
1
backend/app/utils/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
46
backend/app/utils/security.py
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
27
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
frontend/public/Assets/Black logo.png
Normal file
|
After Width: | Height: | Size: 680 KiB |
BIN
frontend/public/Assets/Rusty brown logo.png
Normal file
|
After Width: | Height: | Size: 980 KiB |
BIN
frontend/public/Assets/White logo.png
Normal file
|
After Width: | Height: | Size: 925 KiB |
3
frontend/public/Assets/sso/apple.svg
Normal 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 |
3
frontend/public/Assets/sso/github.svg
Normal 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 |
6
frontend/public/Assets/sso/google.svg
Normal 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 |
6
frontend/public/Assets/sso/microsoft.svg
Normal 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 |
4
frontend/public/Assets/sso/okta.svg
Normal 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 |
6
frontend/public/Assets/sso/slack.svg
Normal 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
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
152
frontend/src/components/Features.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
53
frontend/src/components/Footer.tsx
Normal 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">
|
||||
© {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>
|
||||
)
|
||||
}
|
||||
290
frontend/src/components/Header.tsx
Normal 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)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
164
frontend/src/components/Hero.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
14
frontend/src/components/ProtectedRoute.tsx
Normal 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}</>
|
||||
}
|
||||
148
frontend/src/components/Workflow.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
49
frontend/src/components/auth/AuthFooter.tsx
Normal 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">
|
||||
© {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>
|
||||
)
|
||||
}
|
||||
83
frontend/src/components/auth/AuthSidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
65
frontend/src/components/auth/ForgotPasswordForm.tsx
Normal 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'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>
|
||||
)
|
||||
}
|
||||
104
frontend/src/components/auth/LoginForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
47
frontend/src/components/auth/PasswordField.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
52
frontend/src/components/auth/QuickActions.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
69
frontend/src/components/auth/ResetPasswordForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
34
frontend/src/components/auth/SSOButtons.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
64
frontend/src/components/auth/SSOForm.tsx
Normal 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'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>
|
||||
)
|
||||
}
|
||||
90
frontend/src/components/auth/SignUpForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
78
frontend/src/components/auth/UserInfoFields.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
176
frontend/src/components/create/CreateDropdown.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
50
frontend/src/components/dashboard/ActivityStatus.tsx
Normal 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're in the top 15% of contributors in your team this week
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
123
frontend/src/components/dashboard/DashboardFeed.tsx
Normal 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's what'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>
|
||||
)
|
||||
}
|
||||
115
frontend/src/components/dashboard/DashboardHeader.tsx
Normal 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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
85
frontend/src/components/dashboard/DashboardLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
158
frontend/src/components/dashboard/DashboardSidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
52
frontend/src/components/dashboard/DiscoverPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
185
frontend/src/components/dashboard/DocumentFlyout.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
129
frontend/src/components/dashboard/RecentActivities.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
90
frontend/src/components/dashboard/RecentProjectsGrid.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
108
frontend/src/components/dashboard/TemplatesFlyout.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
120
frontend/src/components/search/SearchOverlay.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
105
frontend/src/components/shed/DocBlocks.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
173
frontend/src/components/shed/templates/MeetingNotesTemplate.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
157
frontend/src/components/shed/templates/RetrospectiveTemplate.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
192
frontend/src/components/silo/SiloEditor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
894
frontend/src/components/sketch/SketchCanvas.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
215
frontend/src/components/stead/SteadHub.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
125
frontend/src/components/workspace/ShareModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
199
frontend/src/components/workspace/WorkspaceShell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
133
frontend/src/components/workspace/WorkspaceSidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
33
frontend/src/data/authData.ts
Normal 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' },
|
||||
]
|
||||
5
frontend/src/data/brandAssets.ts
Normal 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
|
||||
90
frontend/src/data/createData.ts
Normal 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,
|
||||
},
|
||||
]
|
||||
110
frontend/src/data/dashboardData.ts
Normal 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
|
||||
142
frontend/src/data/landingData.ts
Normal 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
|
||||
76
frontend/src/data/steadData.ts
Normal 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',
|
||||
})
|
||||
20
frontend/src/hooks/useAutoSave.ts
Normal 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])
|
||||
}
|
||||
65
frontend/src/hooks/useCollab.ts
Normal 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 }
|
||||
}
|
||||
47
frontend/src/hooks/useDashboardDocuments.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
9
frontend/src/hooks/useRecordVisit.ts
Normal 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
|
|
@ -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
|
|
@ -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' }),
|
||||
}
|
||||
95
frontend/src/lib/documentUtils.ts
Normal 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]
|
||||
}
|
||||
215
frontend/src/lib/documents.ts
Normal 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
|
|
@ -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>,
|
||||
)
|
||||
5
frontend/src/pages/DashboardPage.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import DashboardLayout from '../components/dashboard/DashboardLayout'
|
||||
|
||||
export default function DashboardPage() {
|
||||
return <DashboardLayout />
|
||||
}
|
||||
19
frontend/src/pages/ForbiddenPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
19
frontend/src/pages/LandingPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
367
frontend/src/pages/LoginPage.tsx
Normal 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">
|
||||
← Back to home
|
||||
</Link>
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<AuthFooter />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
78
frontend/src/pages/workspace/ShedPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
93
frontend/src/pages/workspace/SiloPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||