commit 6e354ba041e6a72100b1d26b19300f5682dfe9a8 Author: AnuragJangid18 Date: Thu Oct 8 02:44:15 2026 +0530 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b1ef8c5 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..352ece0 --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..6057d01 --- /dev/null +++ b/backend/.env.example @@ -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 diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..25a009f --- /dev/null +++ b/backend/README.md @@ -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 +``` diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..119df13 --- /dev/null +++ b/backend/app/config.py @@ -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() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..b7f200c --- /dev/null +++ b/backend/app/database.py @@ -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() diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py new file mode 100644 index 0000000..87e05ff --- /dev/null +++ b/backend/app/dependencies.py @@ -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) diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..8a2373b --- /dev/null +++ b/backend/app/main.py @@ -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"} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..78d79d6 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,3 @@ +from app.models.user import PasswordResetToken, User + +__all__ = ["User", "PasswordResetToken"] diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..c429292 --- /dev/null +++ b/backend/app/models/user.py @@ -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 + ) diff --git a/backend/app/mongo.py b/backend/app/mongo.py new file mode 100644 index 0000000..ed229cc --- /dev/null +++ b/backend/app/mongo.py @@ -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 diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/routers/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..cdb31b8 --- /dev/null +++ b/backend/app/routers/auth.py @@ -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.") diff --git a/backend/app/routers/collab.py b/backend/app/routers/collab.py new file mode 100644 index 0000000..ab660b9 --- /dev/null +++ b/backend/app/routers/collab.py @@ -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) diff --git a/backend/app/routers/documents.py b/backend/app/routers/documents.py new file mode 100644 index 0000000..cde06c1 --- /dev/null +++ b/backend/app/routers/documents.py @@ -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") diff --git a/backend/app/routers/health.py b/backend/app/routers/health.py new file mode 100644 index 0000000..3077478 --- /dev/null +++ b/backend/app/routers/health.py @@ -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", + } diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..5c61c2d --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -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", +] diff --git a/backend/app/schemas/document.py b/backend/app/schemas/document.py new file mode 100644 index 0000000..69204eb --- /dev/null +++ b/backend/app/schemas/document.py @@ -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) diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..b30f683 --- /dev/null +++ b/backend/app/schemas/user.py @@ -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 diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/services/document_service.py b/backend/app/services/document_service.py new file mode 100644 index 0000000..e8c85c0 --- /dev/null +++ b/backend/app/services/document_service.py @@ -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 diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py new file mode 100644 index 0000000..ced1551 --- /dev/null +++ b/backend/app/services/user_service.py @@ -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 diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/utils/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/utils/security.py b/backend/app/utils/security.py new file mode 100644 index 0000000..449d1d7 --- /dev/null +++ b/backend/app/utils/security.py @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..c662bd4 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9295ba0 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..ccaafb7 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,19 @@ + + + + + + + Silos — Your knowledge. Your team. One place. + + + + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..4b1bf46 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2565 @@ +{ + "name": "silos-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "silos-frontend", + "version": "0.1.0", + "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" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", + "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", + "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", + "integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.422", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.422.tgz", + "integrity": "sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/framer-motion": { + "version": "12.43.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.43.0.tgz", + "integrity": "sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.43.0", + "motion-utils": "^12.39.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.475.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.475.0.tgz", + "integrity": "sha512-NJzvVu1HwFVeZ+Gwq2q00KygM1aBhy/ZrhY9FsAgJtpB+E4R7uxRk9M2iKvHa6/vNxZydIB59htha4c2vvwvVg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/motion-dom": { + "version": "12.43.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.43.0.tgz", + "integrity": "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.39.0" + } + }, + "node_modules/motion-utils": { + "version": "12.39.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz", + "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.3.tgz", + "integrity": "sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.3.tgz", + "integrity": "sha512-ytVbyBBM7vMfRCam25r0WMhSVSom909A8p+8m0/f1w853dz/xfFu6etAT2SEbVoSnI+ZoPRDqIsQXVT89gp7kg==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.3" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/rollup": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", + "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.1", + "@rollup/rollup-android-arm64": "4.63.1", + "@rollup/rollup-darwin-arm64": "4.63.1", + "@rollup/rollup-darwin-x64": "4.63.1", + "@rollup/rollup-freebsd-arm64": "4.63.1", + "@rollup/rollup-freebsd-x64": "4.63.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", + "@rollup/rollup-linux-arm64-gnu": "4.63.1", + "@rollup/rollup-linux-arm64-musl": "4.63.1", + "@rollup/rollup-linux-loong64-gnu": "4.63.1", + "@rollup/rollup-linux-loong64-musl": "4.63.1", + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", + "@rollup/rollup-linux-ppc64-musl": "4.63.1", + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", + "@rollup/rollup-linux-riscv64-musl": "4.63.1", + "@rollup/rollup-linux-s390x-gnu": "4.63.1", + "@rollup/rollup-linux-x64-gnu": "4.63.1", + "@rollup/rollup-linux-x64-musl": "4.63.1", + "@rollup/rollup-openbsd-x64": "4.63.1", + "@rollup/rollup-openharmony-arm64": "4.63.1", + "@rollup/rollup-win32-arm64-msvc": "4.63.1", + "@rollup/rollup-win32-ia32-msvc": "4.63.1", + "@rollup/rollup-win32-x64-gnu": "4.63.1", + "@rollup/rollup-win32-x64-msvc": "4.63.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..77ed838 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/public/Assets/Black logo.png b/frontend/public/Assets/Black logo.png new file mode 100644 index 0000000..27eb0d5 Binary files /dev/null and b/frontend/public/Assets/Black logo.png differ diff --git a/frontend/public/Assets/Rusty brown logo.png b/frontend/public/Assets/Rusty brown logo.png new file mode 100644 index 0000000..5a28aec Binary files /dev/null and b/frontend/public/Assets/Rusty brown logo.png differ diff --git a/frontend/public/Assets/White logo.png b/frontend/public/Assets/White logo.png new file mode 100644 index 0000000..3bc988b Binary files /dev/null and b/frontend/public/Assets/White logo.png differ diff --git a/frontend/public/Assets/sso/apple.svg b/frontend/public/Assets/sso/apple.svg new file mode 100644 index 0000000..7c922d5 --- /dev/null +++ b/frontend/public/Assets/sso/apple.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/Assets/sso/github.svg b/frontend/public/Assets/sso/github.svg new file mode 100644 index 0000000..03e7259 --- /dev/null +++ b/frontend/public/Assets/sso/github.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/Assets/sso/google.svg b/frontend/public/Assets/sso/google.svg new file mode 100644 index 0000000..742bce7 --- /dev/null +++ b/frontend/public/Assets/sso/google.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/Assets/sso/microsoft.svg b/frontend/public/Assets/sso/microsoft.svg new file mode 100644 index 0000000..8374905 --- /dev/null +++ b/frontend/public/Assets/sso/microsoft.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/Assets/sso/okta.svg b/frontend/public/Assets/sso/okta.svg new file mode 100644 index 0000000..e4c4c3c --- /dev/null +++ b/frontend/public/Assets/sso/okta.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/Assets/sso/slack.svg b/frontend/public/Assets/sso/slack.svg new file mode 100644 index 0000000..c23feba --- /dev/null +++ b/frontend/public/Assets/sso/slack.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..2f2338d --- /dev/null +++ b/frontend/src/App.tsx @@ -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 ( + + } /> + } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + ) +} diff --git a/frontend/src/components/Features.tsx b/frontend/src/components/Features.tsx new file mode 100644 index 0000000..5369cb4 --- /dev/null +++ b/frontend/src/components/Features.tsx @@ -0,0 +1,152 @@ +import { motion } from 'framer-motion' +import { features } from '../data/landingData' + +function FeaturePreview({ type }: { type: string }) { + switch (type) { + case 'editor': + return ( +
+
+ {['H1', 'H2', 'B', '•'].map((btn) => ( + + {btn} + + ))} +
+
+
+
+
+
+
+ ) + case 'database': + return ( +
+
+ Name + Status + Owner +
+ {[1, 2, 3].map((row) => ( +
+
+
+
+
+ ))} +
+ ) + case 'whiteboard': + return ( +
+ + + + + + +
+ ) + case 'kanban': + return ( +
+
+ {['To Do', 'In Progress', 'Done'].map((col, i) => ( +
+

{col}

+
+ {Array.from({ length: i === 1 ? 2 : 1 }).map((_, j) => ( +
+ ))} +
+
+ ))} +
+
+ ) + case 'integrations': + return ( +
+ {['Slack', 'GitHub', 'Drive'].map((name) => ( +
+ {name[0]} +
+ ))} +
+ ) + case 'collaboration': + return ( +
+
+ {['#C17D65', '#6B3A2C', '#8B4D3B'].map((color, i) => ( +
+ {['S', 'M', 'A'][i]} +
+ ))} +
+ + +3 people editing + +
+ ) + default: + return null + } +} + +export default function Features() { + return ( +
+
+ +

+ Everything your team needs to work better +

+

+ From documents to databases, whiteboards to boards — all in one connected workspace. +

+
+ +
+ {features.map((feature, i) => ( + + +

+ {feature.title} +

+

{feature.description}

+
+ ))} +
+
+
+ ) +} diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx new file mode 100644 index 0000000..bb4317d --- /dev/null +++ b/frontend/src/components/Footer.tsx @@ -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 ( +
+ {/* CTA bar */} +
+
+

Ready to get started?

+ + Get Started Free + +

No credit card required

+
+
+ + {/* Bottom bar */} +
+
+

+ © {new Date().getFullYear()} Silos. All rights reserved. +

+ + +
+
+
+ ) +} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx new file mode 100644 index 0000000..ce49aa6 --- /dev/null +++ b/frontend/src/components/Header.tsx @@ -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 = { + 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(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 ( +
+ + + {isOpen && ( +
+
+ {items.map((item) => { + const Icon = iconMap[item.icon] + return ( + + ) + })} +
+
+ )} +
+ ) +} + +function ProductDetailsDropdown({ + isOpen, + onToggle, + onClose, +}: { + isOpen: boolean + onToggle: () => void + onClose: () => void +}) { + const ref = useRef(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 ( +
+ + + {isOpen && ( +
+
+ {productDetails.map((item) => ( + + ))} +
+
+ )} +
+ ) +} + +function SearchOverlay({ + isOpen, + onClose, +}: { + isOpen: boolean + onClose: () => void +}) { + const inputRef = useRef(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 ( +
+
+
+
+ + + +
+
+

+ Search for docs, spaces, templates, and help articles... +

+
+
+
+
+ ) +} + +export default function Header() { + const [openDropdown, setOpenDropdown] = useState(null) + const [searchOpen, setSearchOpen] = useState(false) + + const closeAll = () => setOpenDropdown(null) + + return ( + <> +
+
+ + Silos + + + + +
+ + + + Sign Up + +
+
+
+ + setSearchOpen(false)} /> + + ) +} diff --git a/frontend/src/components/Hero.tsx b/frontend/src/components/Hero.tsx new file mode 100644 index 0000000..002a1c0 --- /dev/null +++ b/frontend/src/components/Hero.tsx @@ -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 ( + +
+
+ {/* Sidebar */} +
+ Silos + +
+ + {/* Main content */} +
+

Welcome back, Alex

+

Here's what's happening in your workspace

+ +
+
+

Recent Pages

+
+ {['Q4 Planning', 'API Docs', 'Team Handbook'].map((page) => ( +
+
+ {page} +
+ ))} +
+
+ +
+

Team Activity

+
+ {['Sarah edited Roadmap', 'Mike added comment'].map((act) => ( +
{act}
+ ))} +
+
+ +
+

My Tasks

+
+ {['To Do', 'In Progress', 'Done'].map((col, i) => ( +
+

{col}

+
+
+ ))} +
+
+ +
+

Pinned Spaces

+
+ {['Engineering', 'Design', 'Product'].map((space) => ( + + {space} + + ))} +
+
+
+
+
+
+ +
+ + ) +} + +export default function Hero() { + return ( +
+
+ +

+ Your knowledge. +
+ Your team. +
+ One place. +

+

+ Silos is the connected workspace where teams create, organize and share + knowledge effortlessly. +

+ +
+ + Get Started + + + Learn More + +
+ +
+ {stats.map((stat, i) => { + const Icon = statIcons[stat.icon as keyof typeof statIcons] + return ( + + + {stat.label} + + ) + })} +
+
+ + +
+
+ ) +} diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..ae545e6 --- /dev/null +++ b/frontend/src/components/ProtectedRoute.tsx @@ -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 + } + + return <>{children} +} diff --git a/frontend/src/components/Workflow.tsx b/frontend/src/components/Workflow.tsx new file mode 100644 index 0000000..d9e2e11 --- /dev/null +++ b/frontend/src/components/Workflow.tsx @@ -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 = { + 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 ( +
+
+ +

+ A workflow that flows with you +

+

+ From idea to execution — Silos adapts to how your team actually works. +

+
+ +
+
+ {workflowSteps.map((step, i) => { + const Icon = stepIcons[step.icon] + const isActive = activeStep === i + const isPast = i < activeStep + + return ( +
+ {i < workflowSteps.length - 1 && ( +
+
+ +
+ )} + + 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 }} + > + + + + + +

+ {step.title} +

+

+ {step.description} +

+
+
+
+ ) + })} +
+
+ + {/* Mobile workflow */} +
+ {workflowSteps.map((step, i) => { + const Icon = stepIcons[step.icon] + const isActive = activeStep === i + + return ( + +
+ +
+
+

{step.title}

+

{step.description}

+
+
+ ) + })} +
+
+
+ ) +} diff --git a/frontend/src/components/auth/AuthFooter.tsx b/frontend/src/components/auth/AuthFooter.tsx new file mode 100644 index 0000000..cf6dc91 --- /dev/null +++ b/frontend/src/components/auth/AuthFooter.tsx @@ -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 ( +
+
+
+

+ © {new Date().getFullYear()} Silos. All rights reserved. +

+ + + Get Started + + + +
+
+
+ ) +} diff --git a/frontend/src/components/auth/AuthSidebar.tsx b/frontend/src/components/auth/AuthSidebar.tsx new file mode 100644 index 0000000..feca90e --- /dev/null +++ b/frontend/src/components/auth/AuthSidebar.tsx @@ -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 ( + + + + + + + + + + + + + + + + + + + + + ) +} + +export default function AuthSidebar() { + return ( + + ) +} diff --git a/frontend/src/components/auth/ForgotPasswordForm.tsx b/frontend/src/components/auth/ForgotPasswordForm.tsx new file mode 100644 index 0000000..a213c52 --- /dev/null +++ b/frontend/src/components/auth/ForgotPasswordForm.tsx @@ -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 ( +
{ + e.preventDefault() + onSubmit() + }} + className="space-y-5" + > + + +
+

Forgot password?

+

+ Enter your email and we'll send you a reset link. +

+
+ +
+ +
+ + 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" + /> +
+
+ + +
+ ) +} diff --git a/frontend/src/components/auth/LoginForm.tsx b/frontend/src/components/auth/LoginForm.tsx new file mode 100644 index 0000000..6626371 --- /dev/null +++ b/frontend/src/components/auth/LoginForm.tsx @@ -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 ( +
{ + e.preventDefault() + onSubmit() + }} + className="space-y-5" + > +
+

Login to your account

+

+ Welcome back! Please enter your details. +

+
+ +
+ +
+ + 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" + /> +
+
+ + + +
+ + +
+ + + +
+
+
+
+
+ or continue with +
+
+ + + + ) +} diff --git a/frontend/src/components/auth/PasswordField.tsx b/frontend/src/components/auth/PasswordField.tsx new file mode 100644 index 0000000..f4f76c7 --- /dev/null +++ b/frontend/src/components/auth/PasswordField.tsx @@ -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 ( +
+ +
+ + 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" + /> + +
+
+ ) +} diff --git a/frontend/src/components/auth/QuickActions.tsx b/frontend/src/components/auth/QuickActions.tsx new file mode 100644 index 0000000..0045ca3 --- /dev/null +++ b/frontend/src/components/auth/QuickActions.tsx @@ -0,0 +1,52 @@ +import { KeyRound, UserPlus } from 'lucide-react' + +interface QuickActionsProps { + onResetPassword: () => void + onCreateAccount: () => void +} + +export default function QuickActions({ + onResetPassword, + onCreateAccount, +}: QuickActionsProps) { + return ( +
+
+

Quick Actions

+
+ + + +
+
+
+ ) +} diff --git a/frontend/src/components/auth/ResetPasswordForm.tsx b/frontend/src/components/auth/ResetPasswordForm.tsx new file mode 100644 index 0000000..b5bc11a --- /dev/null +++ b/frontend/src/components/auth/ResetPasswordForm.tsx @@ -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 ( +
{ + e.preventDefault() + onSubmit() + }} + className="space-y-5" + > + + +
+

Reset your password

+

+ Enter a new password for your account. +

+
+ + + + + + + + ) +} diff --git a/frontend/src/components/auth/SSOButtons.tsx b/frontend/src/components/auth/SSOButtons.tsx new file mode 100644 index 0000000..98add41 --- /dev/null +++ b/frontend/src/components/auth/SSOButtons.tsx @@ -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 ( +
+ {ssoProviders.map((provider) => ( + + ))} +
+ ) +} diff --git a/frontend/src/components/auth/SSOForm.tsx b/frontend/src/components/auth/SSOForm.tsx new file mode 100644 index 0000000..bb3a5bb --- /dev/null +++ b/frontend/src/components/auth/SSOForm.tsx @@ -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 ( +
{ + e.preventDefault() + onSubmit() + }} + className="space-y-5" + > +
+

SSO Login

+

+ Sign in with your organization's identity provider. +

+
+ + + + {selectedProvider && ( +

+ Continuing with{' '} + {selectedProvider} +

+ )} + +
+

User Information

+

+ Complete your profile after SSO authentication. +

+ +
+ + + + ) +} diff --git a/frontend/src/components/auth/SignUpForm.tsx b/frontend/src/components/auth/SignUpForm.tsx new file mode 100644 index 0000000..e35f83d --- /dev/null +++ b/frontend/src/components/auth/SignUpForm.tsx @@ -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 ( +
{ + e.preventDefault() + onSubmit() + }} + className="space-y-5" + > +
+

Create your account

+

+ Join Silos and start your journey today. +

+
+ +
+ +
+ + 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" + /> +
+
+ + + + + +
+

User Information

+ +
+ + + + ) +} diff --git a/frontend/src/components/auth/UserInfoFields.tsx b/frontend/src/components/auth/UserInfoFields.tsx new file mode 100644 index 0000000..86ffdd9 --- /dev/null +++ b/frontend/src/components/auth/UserInfoFields.tsx @@ -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 ( +
+ {fields.map(({ key, label, placeholder, icon: Icon, type = 'text' }) => ( +
+ +
+ + 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' + }`} + /> +
+
+ ))} +
+ ) +} + +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 ( +
    + {items.map(({ label, value, icon: Icon }) => ( +
  • + + {label}: + + {value || '—'} + +
  • + ))} +
+ ) +} diff --git a/frontend/src/components/create/CreateDropdown.tsx b/frontend/src/components/create/CreateDropdown.tsx new file mode 100644 index 0000000..9aff0a0 --- /dev/null +++ b/frontend/src/components/create/CreateDropdown.tsx @@ -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 = { + Zap, + FileText, + PenTool, + LayoutGrid, +} + +export default function CreateDropdown({ steadId }: { steadId?: string }) { + const [open, setOpen] = useState(false) + const [shedOpen, setShedOpen] = useState(false) + const ref = useRef(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> = { + 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 ( +
+ + + {open && ( +
+
+

Create new

+

Choose what you want to build

+
+ +
+ {createOptions.map((option) => { + const Icon = iconMap[option.icon] + const isShed = option.id === 'shed' + + return ( +
+ + + {isShed && shedOpen && ( +
+ {shedTemplates.map((template) => ( + + ))} +
+ )} +
+ ) + })} +
+
+ )} +
+ ) +} diff --git a/frontend/src/components/dashboard/ActivityStatus.tsx b/frontend/src/components/dashboard/ActivityStatus.tsx new file mode 100644 index 0000000..f42fc60 --- /dev/null +++ b/frontend/src/components/dashboard/ActivityStatus.tsx @@ -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 ( +
+
+

Your Activity Status

+ +
+ +
+
+

{documentCount}

+

Documents

+
+ {activityStats.slice(1).map((stat) => ( +
+

{stat.value}

+

{stat.label}

+

+ + {stat.change} +

+
+ ))} +
+ +
+
+ Your Contribution + Top 15% +
+
+
+
+

+ You're in the top 15% of contributors in your team this week +

+
+
+ ) +} diff --git a/frontend/src/components/dashboard/DashboardFeed.tsx b/frontend/src/components/dashboard/DashboardFeed.tsx new file mode 100644 index 0000000..3a9b410 --- /dev/null +++ b/frontend/src/components/dashboard/DashboardFeed.tsx @@ -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 ( + + + + + + + + + + + ) +} + +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 ( +
+
+
+
+

+ {getGreeting()}, {firstName} 👋 +

+

+ Here's what's happening in your workspace today. +

+
+
+ +
+
+ + {error && ( +
+ {error} +
+ )} + + {/* Recent pages grid */} +
+
+

Recent

+ {recent.length > 0 && ( + + )} +
+ +
+ + {/* Tabs */} +
+ {(['feed', 'discover'] as const).map((tab) => ( + + ))} +
+ + {activeTab === 'feed' ? ( +
+
+ +
+
+ + +
+
+ ) : ( +
+ +
+ )} +
+
+ ) +} diff --git a/frontend/src/components/dashboard/DashboardHeader.tsx b/frontend/src/components/dashboard/DashboardHeader.tsx new file mode 100644 index 0000000..b25c436 --- /dev/null +++ b/frontend/src/components/dashboard/DashboardHeader.tsx @@ -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 ( + <> +
+
+ + + Silos + + + Home + +
+ +
+
+ + 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" + /> + + ⌘ K + +
+ +
+ +
+ + + + +
+
+ setSearchOpen(false)} initialQuery={draft} /> + + ) +} diff --git a/frontend/src/components/dashboard/DashboardLayout.tsx b/frontend/src/components/dashboard/DashboardLayout.tsx new file mode 100644 index 0000000..3cb6b64 --- /dev/null +++ b/frontend/src/components/dashboard/DashboardLayout.tsx @@ -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> = { + recent: 'recent', + favourites: 'favourites', + 'all-projects': 'all-projects', + templates: 'templates', +} + +export default function DashboardLayout() { + const [sidebarCollapsed, setSidebarCollapsed] = useState(false) + const [activeNav, setActiveNav] = useState('for-you') + const [flyout, setFlyout] = useState(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 ( +
+ setSidebarCollapsed(!sidebarCollapsed)} + sidebarCollapsed={sidebarCollapsed} + /> +
+ + + {flyout && flyout !== 'templates' && ( + setFlyout(null)} + onStarChange={refresh} + /> + )} + {flyout === 'templates' && ( + setFlyout(null)} /> + )} + + { + setActiveNav('recent') + setFlyout('recent') + }} + /> +
+
+ ) +} diff --git a/frontend/src/components/dashboard/DashboardSidebar.tsx b/frontend/src/components/dashboard/DashboardSidebar.tsx new file mode 100644 index 0000000..7fdf2b6 --- /dev/null +++ b/frontend/src/components/dashboard/DashboardSidebar.tsx @@ -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 = { + 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 ( + + ) + } + + return ( + + ) +} diff --git a/frontend/src/components/dashboard/DiscoverPanel.tsx b/frontend/src/components/dashboard/DiscoverPanel.tsx new file mode 100644 index 0000000..5ee1d86 --- /dev/null +++ b/frontend/src/components/dashboard/DiscoverPanel.tsx @@ -0,0 +1,52 @@ +import { + LayoutTemplate, + FolderOpen, + BookOpen, + ChevronRight, + type LucideIcon, +} from 'lucide-react' +import { discoverItems } from '../../data/dashboardData' + +const iconMap: Record = { + LayoutTemplate, + FolderOpen, + BookOpen, +} + +export default function DiscoverPanel() { + return ( +
+
+
+

Discover something new

+ +
+ +
    + {discoverItems.map((item) => { + const Icon = iconMap[item.icon] + return ( +
  • + +
  • + ) + })} +
+
+
+ ) +} diff --git a/frontend/src/components/dashboard/DocumentFlyout.tsx b/frontend/src/components/dashboard/DocumentFlyout.tsx new file mode 100644 index 0000000..df5d7b9 --- /dev/null +++ b/frontend/src/components/dashboard/DocumentFlyout.tsx @@ -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 = { + 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 = { + 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 ( + <> +
+ + + ) +} diff --git a/frontend/src/components/dashboard/RecentActivities.tsx b/frontend/src/components/dashboard/RecentActivities.tsx new file mode 100644 index 0000000..97eb88e --- /dev/null +++ b/frontend/src/components/dashboard/RecentActivities.tsx @@ -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 = { + 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 ( +
+
+

Recent Activities

+ {items.length > 0 && ( + + )} +
+ + {loading ? ( +
    + {Array.from({ length: 4 }).map((_, i) => ( +
  • +
    +
  • + ))} +
+ ) : items.length === 0 ? ( +
+ No activity yet. Create a Silo, Sketch, Shed, or Stead to get started. +
+ ) : ( +
    + {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 ( +
  • + +
    + {doc.type === 'stead' ? ( + {getProjectIcon(doc)} + ) : ( + + )} +
    +
    +

    {doc.title}

    +

    + {getActionLabel(doc)} · {typeLabels[doc.type]} +

    +
    +
    + + {formatRelativeTime(getActivityDate(doc))} + +
    + {initial} +
    +
    + +
  • + ) + })} +
+ )} + + {items.length > 0 && ( + + )} +
+ ) +} diff --git a/frontend/src/components/dashboard/RecentProjectsGrid.tsx b/frontend/src/components/dashboard/RecentProjectsGrid.tsx new file mode 100644 index 0000000..f070b18 --- /dev/null +++ b/frontend/src/components/dashboard/RecentProjectsGrid.tsx @@ -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 = { + 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 ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
+ ) + } + + if (items.length === 0) { + return ( +
+

+ No recent pages yet. Use Create to start a Silo, Sketch, Shed, or Stead. +

+
+ ) + } + + return ( +
+ {items.map((doc) => { + const Icon = typeIcons[doc.type] + const color = doc.type === 'stead' ? getProjectColor(doc) : typeColors[doc.type] + + return ( + +
+
+ {doc.type === 'stead' ? ( + {getProjectIcon(doc)} + ) : ( + + )} +
+
+

+ {doc.title} +

+

{typeLabels[doc.type]}

+
+
+

+ {doc.createdBy} · {formatVisitedDate(getActivityDate(doc))} +

+ + ) + })} +
+ ) +} diff --git a/frontend/src/components/dashboard/TemplatesFlyout.tsx b/frontend/src/components/dashboard/TemplatesFlyout.tsx new file mode 100644 index 0000000..2f0942b --- /dev/null +++ b/frontend/src/components/dashboard/TemplatesFlyout.tsx @@ -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: '

Meeting notes

Date:

  • Attendees
  • Agenda
  • Actions
', + }, + { + id: 'plan', + label: 'Project plan', + description: 'Silo starter for a project plan', + html: '

Project plan

Goals

  • Milestone 1
  • Milestone 2
', + }, +] + +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 ( + <> +
+ + + ) +} diff --git a/frontend/src/components/search/SearchOverlay.tsx b/frontend/src/components/search/SearchOverlay.tsx new file mode 100644 index 0000000..3c61653 --- /dev/null +++ b/frontend/src/components/search/SearchOverlay.tsx @@ -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 = { + 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([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const inputRef = useRef(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 ( +
+ + ) + })} +
+
+
+ ) +} diff --git a/frontend/src/components/shed/DocBlocks.tsx b/frontend/src/components/shed/DocBlocks.tsx new file mode 100644 index 0000000..7337ee1 --- /dev/null +++ b/frontend/src/components/shed/DocBlocks.tsx @@ -0,0 +1,105 @@ +interface SectionProps { + icon: string + title: string + children: React.ReactNode +} + +export function DocSection({ icon, title, children }: SectionProps) { + return ( +
+

+ {icon} + {title} +

+ {children} +
+ ) +} + +export function InfoBanner({ children }: { children: React.ReactNode }) { + return ( +
+ ℹ️ +
{children}
+
+ ) +} + +export function FieldLabel({ children }: { children: React.ReactNode }) { + return +} + +export function TextInput({ + value, + onChange, + placeholder, + className = '', +}: { + value: string + onChange: (v: string) => void + placeholder?: string + className?: string +}) { + return ( + 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 ( +