Merge pull request #743 from MODSetter/dev

feat: various fixes and ux updates
This commit is contained in:
Rohan Verma 2026-01-27 01:14:12 -08:00 committed by GitHub
commit b3f08a7aa7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
113 changed files with 3074 additions and 1325 deletions

View file

@ -228,7 +228,7 @@ COPY scripts/docker/init-postgres.sh /app/init-postgres.sh
RUN dos2unix /app/init-postgres.sh && chmod +x /app/init-postgres.sh
# Clean up build dependencies to reduce image size
RUN apt-get purge -y build-essential postgresql-server-dev-14 git \
RUN apt-get purge -y build-essential postgresql-server-dev-14 \
&& apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*

View file

@ -19,6 +19,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libxext6 \
libxrender1 \
dos2unix \
git \
&& rm -rf /var/lib/apt/lists/*
# Update certificates and install SSL tools

View file

@ -0,0 +1,97 @@
"""Add user incentive tasks table for earning free pages
Revision ID: 80
Revises: 79
Changes:
1. Create incentive_task_type enum with GITHUB_STAR value
2. Create user_incentive_tasks table to track completed tasks
"""
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "80"
down_revision: str | None = "79"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Create incentive tasks infrastructure."""
# Check if enum already exists (handles partial migration recovery)
conn = op.get_bind()
result = conn.execute(
sa.text("SELECT 1 FROM pg_type WHERE typname = 'incentivetasktype'")
)
enum_exists = result.fetchone() is not None
# Create the enum type only if it doesn't exist
if not enum_exists:
incentive_task_type_enum = postgresql.ENUM(
"GITHUB_STAR",
name="incentivetasktype",
create_type=False,
)
incentive_task_type_enum.create(op.get_bind(), checkfirst=True)
# Check if table already exists (handles partial migration recovery)
result = conn.execute(
sa.text(
"SELECT 1 FROM information_schema.tables WHERE table_name = 'user_incentive_tasks'"
)
)
table_exists = result.fetchone() is not None
if not table_exists:
# Create the user_incentive_tasks table
op.create_table(
"user_incentive_tasks",
sa.Column("id", sa.Integer(), primary_key=True, index=True),
sa.Column(
"user_id",
sa.UUID(as_uuid=True),
sa.ForeignKey("user.id", ondelete="CASCADE"),
nullable=False,
index=True,
),
sa.Column(
"task_type",
postgresql.ENUM(
"GITHUB_STAR", name="incentivetasktype", create_type=False
),
nullable=False,
index=True,
),
sa.Column("pages_awarded", sa.Integer(), nullable=False),
sa.Column(
"completed_at",
sa.TIMESTAMP(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.Column(
"created_at",
sa.TIMESTAMP(timezone=True),
nullable=False,
server_default=sa.func.now(),
index=True,
),
sa.UniqueConstraint("user_id", "task_type", name="uq_user_incentive_task"),
)
def downgrade() -> None:
"""Remove incentive tasks infrastructure."""
# Drop the table
op.drop_table("user_incentive_tasks")
# Drop the enum type
postgresql.ENUM(name="incentivetasktype").drop(op.get_bind(), checkfirst=True)

View file

@ -144,6 +144,43 @@ class LogStatus(str, Enum):
FAILED = "FAILED"
class IncentiveTaskType(str, Enum):
"""
Enum for incentive task types that users can complete to earn free pages.
Each task can only be completed once per user.
When adding new tasks:
1. Add a new enum value here
2. Add the task configuration to INCENTIVE_TASKS_CONFIG below
3. Create an Alembic migration to add the enum value to PostgreSQL
"""
GITHUB_STAR = "GITHUB_STAR"
# Future tasks can be added here:
# GITHUB_ISSUE = "GITHUB_ISSUE"
# SOCIAL_SHARE = "SOCIAL_SHARE"
# REFER_FRIEND = "REFER_FRIEND"
# Centralized configuration for incentive tasks
# This makes it easy to add new tasks without changing code in multiple places
INCENTIVE_TASKS_CONFIG = {
IncentiveTaskType.GITHUB_STAR: {
"title": "Star our GitHub repository",
"description": "Show your support by starring SurfSense on GitHub",
"pages_reward": 100,
"action_url": "https://github.com/MODSetter/SurfSense",
},
# Future tasks can be configured here:
# IncentiveTaskType.GITHUB_ISSUE: {
# "title": "Create an issue",
# "description": "Help improve SurfSense by reporting bugs or suggesting features",
# "pages_reward": 50,
# "action_url": "https://github.com/MODSetter/SurfSense/issues/new/choose",
# },
}
class Permission(str, Enum):
"""
Granular permissions for search space resources.
@ -915,6 +952,39 @@ class Notification(BaseModel, TimestampMixin):
search_space = relationship("SearchSpace", back_populates="notifications")
class UserIncentiveTask(BaseModel, TimestampMixin):
"""
Tracks completed incentive tasks for users.
Each user can only complete each task type once.
When a task is completed, the user's pages_limit is increased.
"""
__tablename__ = "user_incentive_tasks"
__table_args__ = (
UniqueConstraint(
"user_id",
"task_type",
name="uq_user_incentive_task",
),
)
user_id = Column(
UUID(as_uuid=True),
ForeignKey("user.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
task_type = Column(SQLAlchemyEnum(IncentiveTaskType), nullable=False, index=True)
pages_awarded = Column(Integer, nullable=False)
completed_at = Column(
TIMESTAMP(timezone=True),
nullable=False,
default=lambda: datetime.now(UTC),
)
user = relationship("User", back_populates="incentive_tasks")
class SearchSpaceRole(BaseModel, TimestampMixin):
"""
Custom roles that can be defined per search space.
@ -1093,6 +1163,13 @@ if config.AUTH_TYPE == "GOOGLE":
cascade="all, delete-orphan",
)
# Incentive tasks completed by this user
incentive_tasks = relationship(
"UserIncentiveTask",
back_populates="user",
cascade="all, delete-orphan",
)
# Page usage tracking for ETL services
pages_limit = Column(
Integer,
@ -1144,6 +1221,13 @@ else:
cascade="all, delete-orphan",
)
# Incentive tasks completed by this user
incentive_tasks = relationship(
"UserIncentiveTask",
back_populates="user",
cascade="all, delete-orphan",
)
# Page usage tracking for ETL services
pages_limit = Column(
Integer,

View file

@ -20,6 +20,7 @@ from .google_drive_add_connector_route import (
from .google_gmail_add_connector_route import (
router as google_gmail_add_connector_router,
)
from .incentive_tasks_routes import router as incentive_tasks_router
from .jira_add_connector_route import router as jira_add_connector_router
from .linear_add_connector_route import router as linear_add_connector_router
from .logs_routes import router as logs_router
@ -67,3 +68,4 @@ router.include_router(circleback_webhook_router) # Circleback meeting webhooks
router.include_router(surfsense_docs_router) # Surfsense documentation for citations
router.include_router(notifications_router) # Notifications with Electric SQL sync
router.include_router(composio_router) # Composio OAuth and toolkit management
router.include_router(incentive_tasks_router) # Incentive tasks for earning free pages

View file

@ -0,0 +1,131 @@
"""
Incentive Tasks API routes.
Allows users to complete tasks (like starring GitHub repo) to earn free pages.
Each task can only be completed once per user.
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import (
INCENTIVE_TASKS_CONFIG,
IncentiveTaskType,
User,
UserIncentiveTask,
get_async_session,
)
from app.schemas.incentive_tasks import (
CompleteTaskResponse,
IncentiveTaskInfo,
IncentiveTasksResponse,
TaskAlreadyCompletedResponse,
)
from app.users import current_active_user
router = APIRouter(prefix="/incentive-tasks", tags=["incentive-tasks"])
@router.get("", response_model=IncentiveTasksResponse)
async def get_incentive_tasks(
user: User = Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
) -> IncentiveTasksResponse:
"""
Get all available incentive tasks with the user's completion status.
"""
# Get all completed tasks for this user
result = await session.execute(
select(UserIncentiveTask).where(UserIncentiveTask.user_id == user.id)
)
completed_tasks = {task.task_type: task for task in result.scalars().all()}
# Build task list with completion status
tasks = []
total_pages_earned = 0
for task_type, config in INCENTIVE_TASKS_CONFIG.items():
completed_task = completed_tasks.get(task_type)
is_completed = completed_task is not None
if is_completed:
total_pages_earned += completed_task.pages_awarded
tasks.append(
IncentiveTaskInfo(
task_type=task_type,
title=config["title"],
description=config["description"],
pages_reward=config["pages_reward"],
action_url=config["action_url"],
completed=is_completed,
completed_at=completed_task.completed_at if completed_task else None,
)
)
return IncentiveTasksResponse(
tasks=tasks,
total_pages_earned=total_pages_earned,
)
@router.post(
"/{task_type}/complete",
response_model=CompleteTaskResponse | TaskAlreadyCompletedResponse,
)
async def complete_task(
task_type: IncentiveTaskType,
user: User = Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
) -> CompleteTaskResponse | TaskAlreadyCompletedResponse:
"""
Mark an incentive task as completed and award pages to the user.
Each task can only be completed once. If the task was already completed,
returns the existing completion information without awarding additional pages.
"""
# Validate task type exists in config
task_config = INCENTIVE_TASKS_CONFIG.get(task_type)
if not task_config:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unknown task type: {task_type}",
)
# Check if task was already completed
existing_task = await session.execute(
select(UserIncentiveTask).where(
UserIncentiveTask.user_id == user.id,
UserIncentiveTask.task_type == task_type,
)
)
existing = existing_task.scalar_one_or_none()
if existing:
return TaskAlreadyCompletedResponse(
success=False,
message="Task already completed",
completed_at=existing.completed_at,
)
# Create the task completion record
pages_reward = task_config["pages_reward"]
new_task = UserIncentiveTask(
user_id=user.id,
task_type=task_type,
pages_awarded=pages_reward,
)
session.add(new_task)
# Update user's pages_limit
user.pages_limit += pages_reward
await session.commit()
await session.refresh(user)
return CompleteTaskResponse(
success=True,
message=f"Task completed! You earned {pages_reward} pages.",
pages_awarded=pages_reward,
new_pages_limit=user.pages_limit,
)

View file

@ -59,6 +59,58 @@ router = APIRouter()
# ============ Permissions Endpoints ============
# Human-readable descriptions for each permission
PERMISSION_DESCRIPTIONS = {
# Documents
"documents:create": "Add new documents, files, and content to the search space",
"documents:read": "View and search documents in the search space",
"documents:update": "Edit existing documents and their metadata",
"documents:delete": "Remove documents from the search space",
# Chats
"chats:create": "Start new AI chat conversations",
"chats:read": "View chat history and conversations",
"chats:update": "Edit chat titles and settings",
"chats:delete": "Delete chat conversations",
# Comments
"comments:create": "Add comments and annotations to documents",
"comments:read": "View comments on documents",
"comments:delete": "Remove comments from documents",
# LLM Configs
"llm_configs:create": "Add new AI model configurations",
"llm_configs:read": "View AI model settings and configurations",
"llm_configs:update": "Modify AI model configurations",
"llm_configs:delete": "Remove AI model configurations",
# Podcasts
"podcasts:create": "Generate new AI podcasts from content",
"podcasts:read": "Listen to and view generated podcasts",
"podcasts:update": "Edit podcast settings and metadata",
"podcasts:delete": "Remove generated podcasts",
# Connectors
"connectors:create": "Set up new data source integrations",
"connectors:read": "View configured data sources and their status",
"connectors:update": "Modify data source configurations",
"connectors:delete": "Remove data source integrations",
# Logs
"logs:read": "View activity logs and audit trail",
"logs:delete": "Clear activity logs",
# Members
"members:invite": "Send invitations to new team members",
"members:view": "View the list of team members",
"members:remove": "Remove members from the search space",
"members:manage_roles": "Assign and change member roles",
# Roles
"roles:create": "Create new custom roles",
"roles:read": "View available roles and their permissions",
"roles:update": "Modify role permissions",
"roles:delete": "Remove custom roles",
# Settings
"settings:view": "View search space settings",
"settings:update": "Modify search space settings",
"settings:delete": "Delete the entire search space",
# Full access
"*": "Full access to all features and settings",
}
@router.get("/permissions", response_model=PermissionsListResponse)
async def list_all_permissions(
@ -71,12 +123,14 @@ async def list_all_permissions(
for perm in Permission:
# Extract category from permission value (e.g., "documents:read" -> "documents")
category = perm.value.split(":")[0] if ":" in perm.value else "general"
description = PERMISSION_DESCRIPTIONS.get(perm.value, f"Permission for {perm.value}")
permissions.append(
PermissionInfo(
value=perm.value,
name=perm.name,
category=category,
description=description,
)
)

View file

@ -129,6 +129,7 @@ async def read_search_spaces(
result = await session.execute(
select(SearchSpace)
.filter(SearchSpace.user_id == user.id)
.order_by(SearchSpace.id.asc())
.offset(skip)
.limit(limit)
)
@ -138,6 +139,7 @@ async def read_search_spaces(
select(SearchSpace)
.join(SearchSpaceMembership)
.filter(SearchSpaceMembership.user_id == user.id)
.order_by(SearchSpace.id.asc())
.offset(skip)
.limit(limit)
)

View file

@ -0,0 +1,61 @@
"""
Schemas for incentive tasks API.
"""
from datetime import datetime
from pydantic import BaseModel
from app.db import INCENTIVE_TASKS_CONFIG, IncentiveTaskType
class IncentiveTaskInfo(BaseModel):
"""Information about an available incentive task."""
task_type: IncentiveTaskType
title: str
description: str
pages_reward: int
action_url: str
completed: bool
completed_at: datetime | None = None
class IncentiveTasksResponse(BaseModel):
"""Response containing all available incentive tasks with completion status."""
tasks: list[IncentiveTaskInfo]
total_pages_earned: int
class CompleteTaskRequest(BaseModel):
"""Request to mark a task as completed."""
task_type: IncentiveTaskType
class CompleteTaskResponse(BaseModel):
"""Response after completing a task."""
success: bool
message: str
pages_awarded: int
new_pages_limit: int
class TaskAlreadyCompletedResponse(BaseModel):
"""Response when task was already completed."""
success: bool
message: str
completed_at: datetime
def get_task_info(task_type: IncentiveTaskType) -> dict | None:
"""Get task configuration by type."""
return INCENTIVE_TASKS_CONFIG.get(task_type)
def get_all_task_types() -> list[IncentiveTaskType]:
"""Get all configured task types."""
return list(INCENTIVE_TASKS_CONFIG.keys())

View file

@ -167,6 +167,7 @@ class PermissionInfo(BaseModel):
value: str
name: str
category: str
description: str
class PermissionsListResponse(BaseModel):

View file

@ -87,10 +87,14 @@ def format_mentioned_documents_as_context(documents: list[Document]) -> str:
context_parts.append("<document>")
context_parts.append("<document_metadata>")
context_parts.append(f" <document_id>{doc.id}</document_id>")
context_parts.append(f" <document_type>{doc.document_type.value}</document_type>")
context_parts.append(
f" <document_type>{doc.document_type.value}</document_type>"
)
context_parts.append(f" <title><![CDATA[{doc.title}]]></title>")
context_parts.append(f" <url><![CDATA[{url}]]></url>")
context_parts.append(f" <metadata_json><![CDATA[{metadata_json}]]></metadata_json>")
context_parts.append(
f" <metadata_json><![CDATA[{metadata_json}]]></metadata_json>"
)
context_parts.append("</document_metadata>")
context_parts.append("")
context_parts.append("<document_content>")

View file

@ -1,14 +1,18 @@
"use client";
import { usePathname } from "next/navigation";
import { FooterNew } from "@/components/homepage/footer-new";
import { Navbar } from "@/components/homepage/navbar";
export default function HomePageLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const isAuthPage = pathname === "/login" || pathname === "/register";
return (
<main className="min-h-screen bg-linear-to-b from-gray-50 to-gray-100 text-gray-900 dark:from-black dark:to-gray-900 dark:text-white overflow-x-hidden">
<Navbar />
{children}
<FooterNew />
{!isAuthPage && <FooterNew />}
</main>
);
}

View file

@ -8,6 +8,7 @@ import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { loginMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
import { Spinner } from "@/components/ui/spinner";
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
import { AUTH_TYPE } from "@/lib/env-config";
import { ValidationError } from "@/lib/error";
@ -42,9 +43,6 @@ export function LocalLoginForm() {
// Track login attempt
trackLoginAttempt("local");
// Show loading toast
const loadingToast = toast.loading(tCommon("loading"));
try {
const data = await login({
username,
@ -62,8 +60,7 @@ export function LocalLoginForm() {
// Success toast
toast.success(t("login_success"), {
id: loadingToast,
description: "Redirecting to dashboard...",
description: "Redirecting to dashboard",
duration: 2000,
});
@ -76,7 +73,6 @@ export function LocalLoginForm() {
trackLoginFailure("local", err.message);
setError({ title: err.name, message: err.message });
toast.error(err.name, {
id: loadingToast,
description: err.message,
duration: 6000,
});
@ -106,7 +102,6 @@ export function LocalLoginForm() {
// Show error toast with conditional retry action
const toastOptions: any = {
id: loadingToast,
description: errorDetails.description,
duration: 6000,
};
@ -244,9 +239,16 @@ export function LocalLoginForm() {
<button
type="submit"
disabled={isLoggingIn}
className="w-full rounded-md bg-blue-600 px-4 py-1.5 md:py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-all text-sm md:text-base"
className="w-full rounded-md bg-blue-600 px-4 py-1.5 md:py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-all text-sm md:text-base flex items-center justify-center gap-2"
>
{isLoggingIn ? tCommon("loading") : t("sign_in")}
{isLoggingIn ? (
<>
<Spinner size="sm" className="text-white" />
<span>{t("signing_in")}</span>
</>
) : (
t("sign_in")
)}
</button>
</form>

View file

@ -1,12 +1,12 @@
"use client";
import { Loader2 } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { Suspense, useEffect, useState } from "react";
import { toast } from "sonner";
import { Logo } from "@/components/Logo";
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
import { getAuthErrorDetails, shouldRetry } from "@/lib/auth-errors";
import { AUTH_TYPE } from "@/lib/env-config";
import { AmbientBackground } from "./AmbientBackground";
@ -59,7 +59,11 @@ function LoginContent() {
});
// Show toast with conditional retry action
const toastOptions: any = {
const toastOptions: {
description: string;
duration: number;
action?: { label: string; onClick: () => void };
} = {
description: errorDescription,
duration: 6000,
};
@ -88,20 +92,12 @@ function LoginContent() {
setIsLoading(false);
}, [searchParams, t, tCommon]);
// Show loading state while determining auth type
// Use global loading screen for auth type determination - spinner animation won't reset
useGlobalLoadingEffect(isLoading, tCommon("loading"), "login");
// Show nothing while loading - the GlobalLoadingProvider handles the loading UI
if (isLoading) {
return (
<div className="relative w-full overflow-hidden">
<AmbientBackground />
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
<Logo className="rounded-md" />
<div className="mt-8 flex items-center space-x-2">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<span className="text-muted-foreground">{tCommon("loading")}</span>
</div>
</div>
</div>
);
return null;
}
if (authType === "GOOGLE") {
@ -182,23 +178,10 @@ function LoginContent() {
);
}
// Loading fallback for Suspense
const LoadingFallback = () => (
<div className="relative w-full overflow-hidden">
<AmbientBackground />
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
<Logo className="rounded-md" />
<div className="mt-8 flex items-center space-x-2">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<span className="text-muted-foreground">Loading...</span>
</div>
</div>
</div>
);
export default function LoginPage() {
// Suspense fallback returns null - the GlobalLoadingProvider handles the loading UI
return (
<Suspense fallback={<LoadingFallback />}>
<Suspense fallback={null}>
<LoginContent />
</Suspense>
);

View file

@ -9,6 +9,7 @@ import { useEffect, useState } from "react";
import { toast } from "sonner";
import { registerMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
import { Logo } from "@/components/Logo";
import { Spinner } from "@/components/ui/spinner";
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
import { AUTH_TYPE } from "@/lib/env-config";
import { AppError, ValidationError } from "@/lib/error";
@ -60,9 +61,6 @@ export default function RegisterPage() {
// Track registration attempt
trackRegistrationAttempt();
// Show loading toast
const loadingToast = toast.loading(t("creating_account"));
try {
await register({
email,
@ -77,7 +75,6 @@ export default function RegisterPage() {
// Success toast
toast.success(t("register_success"), {
id: loadingToast,
description: t("redirecting_login"),
duration: 2000,
});
@ -95,7 +92,6 @@ export default function RegisterPage() {
trackRegistrationFailure("Registration disabled");
setError({ title: "Registration is disabled", message: friendlyMessage });
toast.error("Registration is disabled", {
id: loadingToast,
description: friendlyMessage,
duration: 6000,
});
@ -109,7 +105,6 @@ export default function RegisterPage() {
trackRegistrationFailure(err.message);
setError({ title: err.name, message: err.message });
toast.error(err.name, {
id: loadingToast,
description: err.message,
duration: 6000,
});
@ -137,7 +132,6 @@ export default function RegisterPage() {
// Show error toast with conditional retry action
const toastOptions: any = {
id: loadingToast,
description: errorDetails.description,
duration: 6000,
};
@ -295,9 +289,16 @@ export default function RegisterPage() {
<button
type="submit"
disabled={isRegistering}
className="w-full rounded-md bg-blue-600 px-4 py-1.5 md:py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-all text-sm md:text-base"
className="w-full rounded-md bg-blue-600 px-4 py-1.5 md:py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-all text-sm md:text-base flex items-center justify-center gap-2"
>
{isRegistering ? t("creating_account_btn") : t("register")}
{isRegistering ? (
<>
<Spinner size="sm" className="text-white" />
<span>{t("creating_account_btn")}</span>
</>
) : (
t("register")
)}
</button>
</form>

View file

@ -0,0 +1,14 @@
"use client";
import { useTranslations } from "next-intl";
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
export default function AuthCallbackLoading() {
const t = useTranslations("auth");
// Use global loading - spinner animation won't reset when page transitions
useGlobalLoadingEffect(true, t("processing_authentication"), "default");
// Return null - the GlobalLoadingProvider handles the loading UI
return null;
}

View file

@ -1,23 +1,18 @@
"use client";
import { Suspense } from "react";
import TokenHandler from "@/components/TokenHandler";
export default function AuthCallbackPage() {
// Suspense fallback returns null - the GlobalLoadingProvider handles the loading UI
// TokenHandler uses useGlobalLoadingEffect to show the loading screen
return (
<div className="container mx-auto p-4">
<h1 className="text-2xl font-bold mb-4">Authentication Callback</h1>
<Suspense
fallback={
<div className="flex items-center justify-center min-h-[200px]">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
</div>
}
>
<Suspense fallback={null}>
<TokenHandler
redirectPath="/dashboard"
tokenParamName="token"
storageKey="surfsense_bearer_token"
/>
</Suspense>
</div>
);
}

View file

@ -1,7 +1,6 @@
"use client";
import { useAtomValue, useSetAtom } from "jotai";
import { Loader2 } from "lucide-react";
import { useParams, usePathname, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import type React from "react";
@ -19,6 +18,7 @@ import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb";
import { LayoutDataProvider } from "@/components/layout";
import { OnboardingTour } from "@/components/onboarding-tour";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
export function DashboardClientLayout({
children,
@ -146,31 +146,22 @@ export function DashboardClientLayout({
setActiveSearchSpaceIdState(activeSeacrhSpaceId);
}, [search_space_id, setActiveSearchSpaceIdState]);
if (
// Determine if we should show loading
const shouldShowLoading =
(!hasCheckedOnboarding &&
(loading || accessLoading || globalConfigsLoading) &&
!isOnboardingPage) ||
isAutoConfiguring
) {
return (
<div className="flex flex-col items-center justify-center min-h-screen space-y-4">
<Card className="w-[350px] bg-background/60 backdrop-blur-sm">
<CardHeader className="pb-2">
<CardTitle className="text-xl font-medium">
{isAutoConfiguring ? "Setting up AI..." : t("loading_config")}
</CardTitle>
<CardDescription>
{isAutoConfiguring
? "Auto-configuring with available settings"
: t("checking_llm_prefs")}
</CardDescription>
</CardHeader>
<CardContent className="flex justify-center py-6">
<Loader2 className="h-12 w-12 text-primary animate-spin" />
</CardContent>
</Card>
</div>
isAutoConfiguring;
// Use global loading screen - spinner animation won't reset
useGlobalLoadingEffect(
shouldShowLoading,
isAutoConfiguring ? t("setting_up_ai") : t("checking_llm_prefs"),
"default"
);
if (shouldShowLoading) {
return null;
}
if (error && !hasCheckedOnboarding && !isOnboardingPage) {

View file

@ -1,6 +1,6 @@
"use client";
import { ChevronDown, ChevronUp, FileX, Loader2, Plus } from "lucide-react";
import { ChevronDown, ChevronUp, FileX, Plus } from "lucide-react";
import { motion } from "motion/react";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
@ -9,6 +9,7 @@ import { useDocumentUploadDialog } from "@/components/assistant-ui/document-uplo
import { DocumentViewer } from "@/components/document-viewer";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Spinner } from "@/components/ui/spinner";
import {
Table,
TableBody,
@ -114,7 +115,7 @@ export function DocumentsTableShell({
{loading ? (
<div className="flex h-[400px] w-full items-center justify-center">
<div className="flex flex-col items-center gap-2">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<Spinner size="lg" className="text-primary" />
<p className="text-sm text-muted-foreground">{t("loading")}</p>
</div>
</div>

View file

@ -1,8 +1,7 @@
"use client";
import { useQueryClient } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { AlertCircle, ArrowLeft, FileText, Loader2, Save } from "lucide-react";
import { AlertCircle, ArrowLeft, FileText, Save } from "lucide-react";
import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
@ -21,6 +20,7 @@ import {
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Spinner } from "@/components/ui/spinner";
import { notesApiService } from "@/lib/apis/notes-api.service";
import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
@ -78,7 +78,6 @@ function extractTitleFromBlockNote(blocknoteDocument: BlockNoteDocument): string
export default function EditorPage() {
const params = useParams();
const router = useRouter();
const queryClient = useQueryClient();
const documentId = params.documentId as string;
const searchSpaceId = Number(params.search_space_id);
const isNewNote = documentId === "new";
@ -349,8 +348,8 @@ export default function EditorPage() {
<div className="flex items-center justify-center min-h-[400px] p-6">
<Card className="w-full max-w-md">
<CardContent className="flex flex-col items-center justify-center py-12">
<Loader2 className="h-12 w-12 text-primary animate-spin mb-4" />
<p className="text-muted-foreground">Loading editor...</p>
<Spinner size="xl" className="text-primary mb-4" />
<p className="text-muted-foreground">Loading editor</p>
</CardContent>
</Card>
</div>
@ -437,7 +436,7 @@ export default function EditorPage() {
>
{saving ? (
<>
<Loader2 className="h-3.5 w-3.5 md:h-4 md:w-4 animate-spin" />
<Spinner size="sm" className="h-3.5 w-3.5 md:h-4 md:w-4" />
<span className="text-xs md:text-sm">{isNewNote ? "Creating" : "Saving"}</span>
</>
) : (

View file

@ -0,0 +1,210 @@
"use client";
import { IconCalendar, IconMailFilled } from "@tabler/icons-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, ExternalLink, Gift, Loader2, Mail, Star } from "lucide-react";
import { motion } from "motion/react";
import Link from "next/link";
import { useEffect } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import type { IncentiveTaskInfo } from "@/contracts/types/incentive-tasks.types";
import { incentiveTasksApiService } from "@/lib/apis/incentive-tasks-api.service";
import {
trackIncentiveContactOpened,
trackIncentivePageViewed,
trackIncentiveTaskClicked,
trackIncentiveTaskCompleted,
} from "@/lib/posthog/events";
import { cn } from "@/lib/utils";
export default function MorePagesPage() {
const queryClient = useQueryClient();
// Track page view on mount
useEffect(() => {
trackIncentivePageViewed();
}, []);
// Fetch tasks from API
const { data, isLoading } = useQuery({
queryKey: ["incentive-tasks"],
queryFn: () => incentiveTasksApiService.getTasks(),
});
// Mutation to complete a task
const completeMutation = useMutation({
mutationFn: incentiveTasksApiService.completeTask,
onSuccess: (response, taskType) => {
if (response.success) {
toast.success(response.message);
// Track task completion
const task = data?.tasks.find((t) => t.task_type === taskType);
if (task) {
trackIncentiveTaskCompleted(taskType, task.pages_reward);
}
// Invalidate queries to refresh data
queryClient.invalidateQueries({ queryKey: ["incentive-tasks"] });
queryClient.invalidateQueries({ queryKey: ["user"] });
}
},
onError: () => {
toast.error("Failed to complete task. Please try again.");
},
});
const handleTaskClick = (task: IncentiveTaskInfo) => {
if (!task.completed) {
trackIncentiveTaskClicked(task.task_type);
completeMutation.mutate(task.task_type);
}
};
const allCompleted = data?.tasks.every((t) => t.completed) ?? false;
return (
<div className="flex min-h-[calc(100vh-64px)] items-center justify-center px-4 py-8">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="w-full max-w-md"
>
{/* Header */}
<div className="mb-6 text-center">
<Gift className="mx-auto mb-3 h-8 w-8 text-primary" />
<h2 className="text-xl font-bold tracking-tight">Get More Pages</h2>
<p className="text-sm text-muted-foreground">Complete tasks to earn additional pages</p>
</div>
{/* Tasks */}
{isLoading ? (
<Card>
<CardContent className="flex items-center gap-3 p-3">
<Skeleton className="h-9 w-9 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/4" />
</div>
<Skeleton className="h-8 w-16" />
</CardContent>
</Card>
) : (
<div className="space-y-2">
{data?.tasks.map((task) => (
<Card
key={task.task_type}
className={cn("transition-colors", task.completed && "bg-muted/50")}
>
<CardContent className="flex items-center gap-3 p-3">
<div
className={cn(
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full",
task.completed ? "bg-primary text-primary-foreground" : "bg-muted"
)}
>
{task.completed ? <Check className="h-4 w-4" /> : <Star className="h-4 w-4" />}
</div>
<div className="flex-1 min-w-0">
<p
className={cn(
"text-sm font-medium",
task.completed && "text-muted-foreground line-through"
)}
>
{task.title}
</p>
<p className="text-xs text-muted-foreground">+{task.pages_reward} pages</p>
</div>
<Button
variant={task.completed ? "ghost" : "outline"}
size="sm"
disabled={task.completed || completeMutation.isPending}
onClick={() => handleTaskClick(task)}
asChild={!task.completed}
>
{task.completed ? (
<span>Done</span>
) : (
<a
href={task.action_url}
target="_blank"
rel="noopener noreferrer"
className="gap-1"
>
{completeMutation.isPending ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<>
Go
<ExternalLink className="h-3 w-3" />
</>
)}
</a>
)}
</Button>
</CardContent>
</Card>
))}
</div>
)}
{/* Contact */}
<Separator className="my-6" />
<div className="text-center">
<p className="mb-3 text-sm text-muted-foreground">
{allCompleted ? "Thanks! Need even more pages?" : "Need more pages?"}
</p>
<Dialog onOpenChange={(open) => open && trackIncentiveContactOpened()}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<Mail className="h-4 w-4" />
Contact Us
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Contact Us</DialogTitle>
<DialogDescription>Schedule a meeting or send us an email.</DialogDescription>
</DialogHeader>
<div className="flex flex-col items-center gap-4 py-4">
<Link
href="https://calendly.com/eric-surfsense/surfsense-meeting"
target="_blank"
rel="noopener noreferrer"
className="flex w-full items-center justify-center gap-2 rounded-lg bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground transition hover:bg-primary/90"
>
<IconCalendar className="h-4 w-4" />
Schedule a Meeting
</Link>
<div className="flex items-center gap-2 text-muted-foreground">
<span className="h-px w-8 bg-border" />
<span className="text-xs">or</span>
<span className="h-px w-8 bg-border" />
</div>
<Link
href="mailto:eric@surfsense.com"
className="flex items-center gap-2 text-sm text-muted-foreground transition hover:text-foreground"
>
<IconMailFilled className="h-4 w-4" />
eric@surfsense.com
</Link>
</div>
</DialogContent>
</Dialog>
</div>
</motion.div>
</div>
);
}

View file

@ -9,6 +9,7 @@ import {
import { useQueryClient } from "@tanstack/react-query";
import { useAtomValue, useSetAtom } from "jotai";
import { useParams, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { z } from "zod";
@ -34,6 +35,7 @@ import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
import { LinkPreviewToolUI } from "@/components/tool-ui/link-preview";
import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage";
import { RecallMemoryToolUI, SaveMemoryToolUI } from "@/components/tool-ui/user-memory";
import { Spinner } from "@/components/ui/spinner";
import { useChatSessionStateSync } from "@/hooks/use-chat-session-state";
import { useMessagesElectric } from "@/hooks/use-messages-electric";
// import { WriteTodosToolUI } from "@/components/tool-ui/write-todos";
@ -236,6 +238,7 @@ interface ThinkingStepData {
}
export default function NewChatPage() {
const t = useTranslations("dashboard");
const params = useParams();
const queryClient = useQueryClient();
const [isInitializing, setIsInitializing] = useState(true);
@ -1475,8 +1478,9 @@ export default function NewChatPage() {
// Show loading state only when loading an existing thread
if (isInitializing) {
return (
<div className="flex h-[calc(100vh-64px)] items-center justify-center">
<div className="text-muted-foreground">Loading chat...</div>
<div className="flex h-[calc(100vh-64px)] flex-col items-center justify-center gap-4">
<Spinner size="lg" />
<div className="text-sm text-muted-foreground">{t("loading_chat")}</div>
</div>
);
}

View file

@ -1,7 +1,6 @@
"use client";
import { useAtomValue } from "jotai";
import { Loader2 } from "lucide-react";
import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
@ -17,6 +16,7 @@ import {
import { Logo } from "@/components/Logo";
import { LLMConfigForm, type LLMConfigFormData } from "@/components/shared/llm-config-form";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Spinner } from "@/components/ui/spinner";
import { getBearerToken, redirectToLogin } from "@/lib/auth-utils";
export default function OnboardPage() {
@ -156,7 +156,7 @@ export default function OnboardPage() {
<div className="relative">
<div className="absolute inset-0 blur-3xl bg-gradient-to-r from-violet-500/20 to-cyan-500/20 rounded-full" />
<div className="relative flex items-center justify-center w-24 h-24 mx-auto rounded-2xl bg-gradient-to-br from-violet-500 to-purple-600 shadow-2xl shadow-violet-500/25">
<Loader2 className="h-12 w-12 text-white animate-spin" />
<Spinner size="xl" className="text-white" />
</div>
</div>
<div className="space-y-2">

View file

@ -5,6 +5,7 @@ import {
Bot,
Brain,
ChevronRight,
FileText,
type LucideIcon,
Menu,
MessageSquare,
@ -15,6 +16,7 @@ import { AnimatePresence, motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useState } from "react";
import { GeneralSettingsManager } from "@/components/settings/general-settings-manager";
import { LLMRoleManager } from "@/components/settings/llm-role-manager";
import { ModelConfigManager } from "@/components/settings/model-config-manager";
import { PromptConfigManager } from "@/components/settings/prompt-config-manager";
@ -30,6 +32,12 @@ interface SettingsNavItem {
}
const settingsNavItems: SettingsNavItem[] = [
{
id: "general",
labelKey: "nav_general",
descriptionKey: "nav_general_desc",
icon: FileText,
},
{
id: "models",
labelKey: "nav_agent_configs",
@ -262,6 +270,9 @@ function SettingsContent({
ease: [0.4, 0, 0.2, 1],
}}
>
{activeSection === "general" && (
<GeneralSettingsManager searchSpaceId={searchSpaceId} />
)}
{activeSection === "models" && <ModelConfigManager searchSpaceId={searchSpaceId} />}
{activeSection === "roles" && <LLMRoleManager searchSpaceId={searchSpaceId} />}
{activeSection === "prompts" && <PromptConfigManager searchSpaceId={searchSpaceId} />}
@ -277,7 +288,7 @@ export default function SettingsPage() {
const router = useRouter();
const params = useParams();
const searchSpaceId = Number(params.search_space_id);
const [activeSection, setActiveSection] = useState("models");
const [activeSection, setActiveSection] = useState("general");
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
// Track settings section view

View file

@ -14,7 +14,6 @@ import {
Hash,
Link2,
LinkIcon,
Loader2,
Logs,
type LucideIcon,
MessageCircle,
@ -96,6 +95,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Spinner } from "@/components/ui/spinner";
import {
Table,
TableBody,
@ -105,7 +105,6 @@ import {
TableRow,
} from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import type {
CreateInviteRequest,
DeleteInviteRequest,
@ -122,6 +121,7 @@ import type {
Role,
UpdateRoleRequest,
} from "@/contracts/types/roles.types";
import type { PermissionInfo } from "@/contracts/types/permissions.types";
import { invitesApiService } from "@/lib/apis/invites-api.service";
import { rolesApiService } from "@/lib/apis/roles-api.service";
import { trackSearchSpaceInviteSent, trackSearchSpaceUsersViewed } from "@/lib/posthog/events";
@ -321,7 +321,7 @@ export default function TeamManagementPage() {
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-4"
>
<Loader2 className="h-10 w-10 text-primary animate-spin" />
<Spinner size="lg" className="text-primary" />
<p className="text-muted-foreground">Loading team data...</p>
</motion.div>
</div>
@ -471,13 +471,6 @@ export default function TeamManagementPage() {
className="w-full md:w-auto"
/>
)}
{activeTab === "roles" && hasPermission("roles:create") && (
<CreateRoleDialog
groupedPermissions={groupedPermissions}
onCreateRole={handleCreateRole}
className="w-full md:w-auto"
/>
)}
</div>
<TabsContent value="members" className="space-y-4">
@ -499,8 +492,10 @@ export default function TeamManagementPage() {
loading={rolesLoading}
onUpdateRole={handleUpdateRole}
onDeleteRole={handleDeleteRole}
onCreateRole={handleCreateRole}
canUpdate={hasPermission("roles:update")}
canDelete={hasPermission("roles:delete")}
canCreate={hasPermission("roles:create")}
/>
</TabsContent>
@ -571,7 +566,7 @@ function MembersTab({
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 text-primary animate-spin" />
<Spinner size="md" className="text-primary" />
</div>
);
}
@ -767,17 +762,71 @@ function MembersTab({
// ============ Role Permissions Display ============
const CATEGORY_CONFIG: Record<string, { label: string; icon: LucideIcon; order: number }> = {
documents: { label: "Documents", icon: FileText, order: 1 },
chats: { label: "Chats", icon: MessageSquare, order: 2 },
comments: { label: "Comments", icon: MessageCircle, order: 3 },
llm_configs: { label: "LLM Configs", icon: Bot, order: 4 },
podcasts: { label: "Podcasts", icon: Mic, order: 5 },
connectors: { label: "Connectors", icon: Plug, order: 6 },
logs: { label: "Logs", icon: Logs, order: 7 },
members: { label: "Members", icon: Users, order: 8 },
roles: { label: "Roles", icon: Shield, order: 9 },
settings: { label: "Settings", icon: Settings, order: 10 },
// Unified category configuration used across all role-related components
const CATEGORY_CONFIG: Record<
string,
{ label: string; icon: LucideIcon; description: string; order: number }
> = {
documents: {
label: "Documents",
icon: FileText,
description: "Manage files, notes, and content",
order: 1,
},
chats: {
label: "AI Chats",
icon: MessageSquare,
description: "Create and manage AI conversations",
order: 2,
},
comments: {
label: "Comments",
icon: MessageCircle,
description: "Add annotations to documents",
order: 3,
},
llm_configs: {
label: "AI Models",
icon: Bot,
description: "Configure AI model settings",
order: 4,
},
podcasts: {
label: "Podcasts",
icon: Mic,
description: "Generate AI podcasts from content",
order: 5,
},
connectors: {
label: "Integrations",
icon: Plug,
description: "Connect external data sources",
order: 6,
},
logs: {
label: "Activity Logs",
icon: Logs,
description: "View and manage audit trail",
order: 7,
},
members: {
label: "Team Members",
icon: Users,
description: "Manage team membership",
order: 8,
},
roles: {
label: "Roles",
icon: Shield,
description: "Configure role permissions",
order: 9,
},
settings: {
label: "Settings",
icon: Settings,
description: "Manage search space settings",
order: 10,
},
};
const ACTION_LABELS: Record<string, string> = {
@ -893,25 +942,31 @@ function RolePermissionsDisplay({ permissions }: { permissions: string[] }) {
function RolesTab({
roles,
groupedPermissions: _groupedPermissions,
groupedPermissions,
loading,
onUpdateRole: _onUpdateRole,
onDeleteRole,
onCreateRole,
canUpdate,
canDelete,
canCreate,
}: {
roles: Role[];
groupedPermissions: Record<string, { value: string; name: string; category: string }[]>;
groupedPermissions: Record<string, PermissionWithDescription[]>;
loading: boolean;
onUpdateRole: (roleId: number, data: { permissions?: string[] }) => Promise<Role>;
onDeleteRole: (roleId: number) => Promise<boolean>;
onCreateRole: (data: CreateRoleRequest["data"]) => Promise<Role>;
canUpdate: boolean;
canDelete: boolean;
canCreate: boolean;
}) {
const [showCreateRole, setShowCreateRole] = useState(false);
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 text-primary animate-spin" />
<Spinner size="md" className="text-primary" />
</div>
);
}
@ -921,8 +976,33 @@ function RolesTab({
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
className="space-y-6"
>
{/* Create Role Button / Section */}
{canCreate && !showCreateRole && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex justify-end"
>
<Button onClick={() => setShowCreateRole(true)} className="gap-2">
<Plus className="h-4 w-4" />
Create Custom Role
</Button>
</motion.div>
)}
{/* Create Role Form */}
{showCreateRole && (
<CreateRoleSection
groupedPermissions={groupedPermissions}
onCreateRole={onCreateRole}
onCancel={() => setShowCreateRole(false)}
/>
)}
{/* Roles Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{roles.map((role, index) => (
<motion.div
key={role.id}
@ -1007,8 +1087,8 @@ function RolesTab({
<AlertDialogHeader>
<AlertDialogTitle>Delete role?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete the "{role.name}" role. Members with
this role will lose their permissions.
This will permanently delete the "{role.name}" role. Members
with this role will lose their permissions.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
@ -1038,6 +1118,7 @@ function RolesTab({
</Card>
</motion.div>
))}
</div>
</motion.div>
);
}
@ -1068,7 +1149,7 @@ function InvitesTab({
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 text-primary animate-spin" />
<Spinner size="md" className="text-primary" />
</div>
);
}
@ -1446,7 +1527,7 @@ function CreateInviteDialog({
<Button onClick={handleCreate} disabled={creating}>
{creating ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
Creating
</>
) : (
@ -1461,13 +1542,14 @@ function CreateInviteDialog({
);
}
// ============ Create Role Dialog ============
// ============ Create Role Section ============
// Preset permission sets for quick role creation
// Editor: can create/read/update content, but cannot manage roles, remove members, or change settings
// Viewer: read-only access with ability to create comments
const PRESET_PERMISSIONS = {
editor: [
const ROLE_PRESETS = {
editor: {
name: "Editor",
description: "Can create, read, and update content, but cannot delete or manage team settings",
permissions: [
"documents:create",
"documents:read",
"documents:update",
@ -1491,7 +1573,11 @@ const PRESET_PERMISSIONS = {
"roles:read",
"settings:view",
],
viewer: [
},
viewer: {
name: "Viewer",
description: "Read-only access with ability to add comments",
permissions: [
"documents:read",
"chats:read",
"comments:create",
@ -1504,23 +1590,68 @@ const PRESET_PERMISSIONS = {
"roles:read",
"settings:view",
],
},
contributor: {
name: "Contributor",
description: "Can add and manage their own content",
permissions: [
"documents:create",
"documents:read",
"documents:update",
"chats:create",
"chats:read",
"comments:create",
"comments:read",
"llm_configs:read",
"podcasts:read",
"connectors:read",
"logs:read",
"members:view",
"roles:read",
"settings:view",
],
},
};
function CreateRoleDialog({
// Action display labels
const ACTION_DISPLAY: Record<string, { label: string; color: string }> = {
create: { label: "Create", color: "text-emerald-600 bg-emerald-500/10" },
read: { label: "View", color: "text-blue-600 bg-blue-500/10" },
update: { label: "Edit", color: "text-amber-600 bg-amber-500/10" },
delete: { label: "Delete", color: "text-red-600 bg-red-500/10" },
invite: { label: "Invite", color: "text-violet-600 bg-violet-500/10" },
view: { label: "View", color: "text-blue-600 bg-blue-500/10" },
remove: { label: "Remove", color: "text-red-600 bg-red-500/10" },
manage_roles: { label: "Manage Roles", color: "text-violet-600 bg-violet-500/10" },
};
// Use the imported PermissionInfo type which now includes description
type PermissionWithDescription = PermissionInfo;
function CreateRoleSection({
groupedPermissions,
onCreateRole,
className,
onCancel,
}: {
groupedPermissions: Record<string, { value: string; name: string; category: string }[]>;
groupedPermissions: Record<string, PermissionWithDescription[]>;
onCreateRole: (data: CreateRoleRequest["data"]) => Promise<Role>;
className?: string;
onCancel: () => void;
}) {
const [open, setOpen] = useState(false);
const [creating, setCreating] = useState(false);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [selectedPermissions, setSelectedPermissions] = useState<string[]>([]);
const [isDefault, setIsDefault] = useState(false);
const [expandedCategories, setExpandedCategories] = useState<string[]>([]);
// Sort categories by order
const sortedCategories = useMemo(() => {
return Object.keys(groupedPermissions).sort((a, b) => {
const orderA = CATEGORY_CONFIG[a]?.order ?? 99;
const orderB = CATEGORY_CONFIG[b]?.order ?? 99;
return orderA - orderB;
});
}, [groupedPermissions]);
const handleCreate = async () => {
if (!name.trim()) {
@ -1536,11 +1667,7 @@ function CreateRoleDialog({
permissions: selectedPermissions,
is_default: isDefault,
});
setOpen(false);
setName("");
setDescription("");
setSelectedPermissions([]);
setIsDefault(false);
onCancel();
} catch (error) {
console.error("Failed to create role:", error);
} finally {
@ -1548,13 +1675,14 @@ function CreateRoleDialog({
}
};
const togglePermission = (perm: string) => {
const togglePermission = useCallback((perm: string) => {
setSelectedPermissions((prev) =>
prev.includes(perm) ? prev.filter((p) => p !== perm) : [...prev, perm]
);
};
}, []);
const toggleCategory = (category: string) => {
const toggleCategory = useCallback(
(category: string) => {
const categoryPerms = groupedPermissions[category]?.map((p) => p.value) || [];
const allSelected = categoryPerms.every((p) => selectedPermissions.includes(p));
@ -1563,151 +1691,342 @@ function CreateRoleDialog({
} else {
setSelectedPermissions((prev) => [...new Set([...prev, ...categoryPerms])]);
}
};
},
[groupedPermissions, selectedPermissions]
);
const applyPreset = (preset: "editor" | "viewer") => {
setSelectedPermissions(PRESET_PERMISSIONS[preset]);
toast.success(`Applied ${preset === "editor" ? "Editor" : "Viewer"} preset permissions`);
};
const toggleCategoryExpanded = useCallback((category: string) => {
setExpandedCategories((prev) =>
prev.includes(category) ? prev.filter((c) => c !== category) : [...prev, category]
);
}, []);
const applyPreset = useCallback((presetKey: keyof typeof ROLE_PRESETS) => {
const preset = ROLE_PRESETS[presetKey];
setSelectedPermissions(preset.permissions);
if (!name.trim()) {
setName(preset.name);
setDescription(preset.description);
}
toast.success(`Applied ${preset.name} preset`);
}, [name]);
const getCategoryStats = useCallback(
(category: string) => {
const perms = groupedPermissions[category] || [];
const selected = perms.filter((p) => selectedPermissions.includes(p.value)).length;
return { selected, total: perms.length, allSelected: selected === perms.length };
},
[groupedPermissions, selectedPermissions]
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className={cn("gap-2", className)}>
<Plus className="h-4 w-4" />
Create Role
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="mb-6"
>
<Card className="border-primary/20 bg-gradient-to-br from-primary/5 via-background to-background">
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-xl bg-primary/10 flex items-center justify-center">
<Plus className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle className="text-lg">Create Custom Role</CardTitle>
<CardDescription className="text-sm">
Define permissions for a new role in this search space
</CardDescription>
</div>
</div>
<Button variant="ghost" size="icon" onClick={onCancel}>
<Trash2 className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="w-[92vw] max-w-[92vw] sm:max-w-xl p-4 md:p-6">
<DialogHeader>
<DialogTitle>Create Custom Role</DialogTitle>
<DialogDescription className="text-xs md:text-sm">
Define a new role with specific permissions for this search space.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-2 md:py-4">
<div className="flex flex-col md:grid md:grid-cols-2 gap-3 md:gap-4">
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Quick Start with Presets */}
<div className="space-y-3">
<Label className="text-sm font-medium">Quick Start with a Template</Label>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{Object.entries(ROLE_PRESETS).map(([key, preset]) => (
<button
key={key}
type="button"
onClick={() => applyPreset(key as keyof typeof ROLE_PRESETS)}
className={cn(
"p-4 rounded-lg border-2 text-left transition-all hover:border-primary/50 hover:bg-primary/5",
selectedPermissions.length > 0 &&
preset.permissions.every((p) => selectedPermissions.includes(p))
? "border-primary bg-primary/10"
: "border-border"
)}
>
<div className="flex items-center gap-2 mb-1">
<ShieldCheck
className={cn(
"h-4 w-4",
key === "editor" && "text-blue-600",
key === "viewer" && "text-gray-600",
key === "contributor" && "text-emerald-600"
)}
/>
<span className="font-medium text-sm">{preset.name}</span>
</div>
<p className="text-xs text-muted-foreground">{preset.description}</p>
</button>
))}
</div>
</div>
{/* Role Details */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="role-name">Role Name *</Label>
<Input
id="role-name"
placeholder="e.g., Contributor"
placeholder="e.g., Content Manager"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Checkbox checked={isDefault} onCheckedChange={(v) => setIsDefault(!!v)} />
<Label htmlFor="role-description">Description</Label>
<Input
id="role-description"
placeholder="Brief description of this role"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
</div>
{/* Default Role Checkbox */}
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
<Checkbox
id="is-default"
checked={isDefault}
onCheckedChange={(checked) => setIsDefault(checked === true)}
/>
<div className="flex-1">
<Label htmlFor="is-default" className="cursor-pointer font-medium">
Set as default role
</Label>
<p className="text-xs text-muted-foreground">
New invites without a role will use this
New members without a specific role will be assigned this role
</p>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="role-description">Description</Label>
<Textarea
id="role-description"
placeholder="Describe what this role can do..."
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
/>
</div>
<div className="space-y-2">
{/* Permissions Section */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label>Permissions ({selectedPermissions.length} selected)</Label>
<div className="flex gap-2">
<Label className="text-sm font-medium">
Permissions ({selectedPermissions.length} selected)
</Label>
<Button
type="button"
variant="outline"
variant="ghost"
size="sm"
className="h-7 text-xs gap-1"
onClick={() => applyPreset("editor")}
className="text-xs h-7"
onClick={() =>
setExpandedCategories(
expandedCategories.length === sortedCategories.length ? [] : sortedCategories
)
}
>
<ShieldCheck className="h-3 w-3 text-blue-600" />
Editor Preset
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-xs gap-1"
onClick={() => applyPreset("viewer")}
>
<ShieldCheck className="h-3 w-3 text-gray-600" />
Viewer Preset
{expandedCategories.length === sortedCategories.length
? "Collapse All"
: "Expand All"}
</Button>
</div>
</div>
<p className="text-xs text-muted-foreground">
Use presets to quickly apply Editor (create/read/update) or Viewer (read-only)
permissions
</p>
<ScrollArea className="h-64 rounded-lg border p-4">
<div className="space-y-4">
{Object.entries(groupedPermissions).map(([category, perms]) => {
const categorySelected = perms.filter((p) =>
selectedPermissions.includes(p.value)
).length;
const allSelected = categorySelected === perms.length;
<div className="space-y-2">
{sortedCategories.map((category) => {
const config = CATEGORY_CONFIG[category] || {
label: category,
icon: FileText,
description: "",
order: 99,
};
const IconComponent = config.icon;
const stats = getCategoryStats(category);
const isExpanded = expandedCategories.includes(category);
const perms = groupedPermissions[category] || [];
return (
<div key={category} className="space-y-2">
<button
type="button"
className="flex items-center gap-2 cursor-pointer hover:bg-muted/50 p-1 rounded w-full text-left"
onClick={() => toggleCategory(category)}
<div
key={category}
className="rounded-lg border bg-card overflow-hidden"
>
{/* Category Header */}
<div
className={cn(
"flex items-center justify-between p-3 cursor-pointer hover:bg-muted/50 transition-colors",
stats.allSelected && "bg-primary/5"
)}
onClick={() => toggleCategoryExpanded(category)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggleCategoryExpanded(category);
}
}}
tabIndex={0}
role="button"
>
<div className="flex items-center gap-3">
<div
className={cn(
"h-8 w-8 rounded-lg flex items-center justify-center",
stats.selected > 0 ? "bg-primary/10" : "bg-muted"
)}
>
<IconComponent
className={cn(
"h-4 w-4",
stats.selected > 0 ? "text-primary" : "text-muted-foreground"
)}
/>
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{config.label}</span>
<Badge
variant={stats.selected > 0 ? "default" : "secondary"}
className="text-xs h-5"
>
{stats.selected}/{stats.total}
</Badge>
</div>
<p className="text-xs text-muted-foreground hidden md:block">
{config.description}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Checkbox
checked={allSelected}
checked={stats.allSelected}
onCheckedChange={() => toggleCategory(category)}
onClick={(e) => e.stopPropagation()}
aria-label={`Select all ${config.label} permissions`}
/>
<span className="text-sm font-medium capitalize">
{category} ({categorySelected}/{perms.length})
</span>
</button>
<div className="grid grid-cols-2 gap-2 ml-6">
{perms.map((perm) => (
<button
type="button"
key={perm.value}
className="flex items-center gap-2 cursor-pointer text-left"
onClick={() => togglePermission(perm.value)}
<motion.div
animate={{ rotate: isExpanded ? 180 : 0 }}
transition={{ duration: 0.2 }}
>
<Checkbox
checked={selectedPermissions.includes(perm.value)}
onCheckedChange={() => togglePermission(perm.value)}
<svg
className="h-4 w-4 text-muted-foreground"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
<span className="text-xs">{perm.value.split(":")[1]}</span>
</button>
))}
</svg>
</motion.div>
</div>
</div>
{/* Permissions List */}
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="border-t"
>
<div className="p-3 space-y-1">
{perms.map((perm) => {
const action = perm.value.split(":")[1];
const actionConfig = ACTION_DISPLAY[action] || {
label: action,
color: "text-gray-600 bg-gray-500/10",
};
const isSelected = selectedPermissions.includes(perm.value);
return (
<div
key={perm.value}
className={cn(
"flex items-center justify-between p-2 rounded-md cursor-pointer transition-colors",
isSelected
? "bg-primary/10 hover:bg-primary/15"
: "hover:bg-muted/50"
)}
onClick={() => togglePermission(perm.value)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
togglePermission(perm.value);
}
}}
tabIndex={0}
role="checkbox"
aria-checked={isSelected}
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<Checkbox
checked={isSelected}
onCheckedChange={() => togglePermission(perm.value)}
onClick={(e) => e.stopPropagation()}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span
className={cn(
"text-xs font-medium px-2 py-0.5 rounded",
actionConfig.color
)}
>
{actionConfig.label}
</span>
</div>
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{perm.description}
</p>
</div>
</div>
</div>
);
})}
</div>
</ScrollArea>
</motion.div>
)}
</div>
);
})}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
{/* Actions */}
<div className="flex items-center justify-end gap-3 pt-4 border-t">
<Button variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button onClick={handleCreate} disabled={creating || !name.trim()}>
{creating ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Creating
<Spinner size="sm" className="mr-2" />
Creating...
</>
) : (
"Create Role"
<>
<Check className="h-4 w-4 mr-2" />
Create Role
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</CardContent>
</Card>
</motion.div>
);
}

View file

@ -1,8 +1,8 @@
"use client";
import { Loader2 } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
import { getBearerToken, redirectToLogin } from "@/lib/auth-utils";
interface DashboardLayoutProps {
@ -10,8 +10,12 @@ interface DashboardLayoutProps {
}
export default function DashboardLayout({ children }: DashboardLayoutProps) {
const t = useTranslations("dashboard");
const [isCheckingAuth, setIsCheckingAuth] = useState(true);
// Use the global loading screen - spinner animation won't reset
useGlobalLoadingEffect(isCheckingAuth, t("checking_auth"), "default");
useEffect(() => {
// Check if user is authenticated
const token = getBearerToken();
@ -23,21 +27,9 @@ export default function DashboardLayout({ children }: DashboardLayoutProps) {
setIsCheckingAuth(false);
}, []);
// Show loading screen while checking authentication
// Return null while loading - the global provider handles the loading UI
if (isCheckingAuth) {
return (
<div className="flex flex-col items-center justify-center min-h-screen space-y-4">
<Card className="w-[350px] bg-background/60 backdrop-blur-sm">
<CardHeader className="pb-2">
<CardTitle className="text-xl font-medium">Loading Dashboard</CardTitle>
<CardDescription>Checking authentication...</CardDescription>
</CardHeader>
<CardContent className="flex justify-center py-6">
<Loader2 className="h-12 w-12 text-primary animate-spin" />
</CardContent>
</Card>
</div>
);
return null;
}
return (

View file

@ -0,0 +1,14 @@
"use client";
import { useTranslations } from "next-intl";
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
export default function DashboardLoading() {
const t = useTranslations("common");
// Use global loading - spinner animation won't reset when page transitions
useGlobalLoadingEffect(true, t("loading"), "default");
// Return null - the GlobalLoadingProvider handles the loading UI
return null;
}

View file

@ -1,7 +1,7 @@
"use client";
import { useAtomValue } from "jotai";
import { AlertCircle, Loader2, Plus, Search } from "lucide-react";
import { AlertCircle, Plus, Search } from "lucide-react";
import { motion } from "motion/react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
@ -18,37 +18,7 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
function LoadingScreen() {
const t = useTranslations("dashboard");
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4">
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5 }}
>
<Card className="w-full max-w-[350px] bg-background/60 backdrop-blur-sm">
<CardHeader className="pb-2">
<CardTitle className="text-xl font-medium">{t("loading")}</CardTitle>
<CardDescription>{t("fetching_spaces")}</CardDescription>
</CardHeader>
<CardContent className="flex justify-center py-6">
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 1.5, repeat: Number.POSITIVE_INFINITY, ease: "linear" }}
>
<Loader2 className="h-12 w-12 text-primary" />
</motion.div>
</CardContent>
<CardFooter className="border-t pt-4 text-sm text-muted-foreground">
{t("may_take_moment")}
</CardFooter>
</Card>
</motion.div>
</div>
);
}
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
function ErrorScreen({ message }: { message: string }) {
const t = useTranslations("dashboard");
@ -121,6 +91,7 @@ export default function DashboardPage() {
const router = useRouter();
const [showCreateDialog, setShowCreateDialog] = useState(false);
const t = useTranslations("dashboard");
const { data: searchSpaces = [], isLoading, error } = useAtomValue(searchSpacesAtom);
useEffect(() => {
@ -131,11 +102,16 @@ export default function DashboardPage() {
}
}, [isLoading, searchSpaces, router]);
if (isLoading) return <LoadingScreen />;
// Show loading while fetching or while we have spaces and are about to redirect
const shouldShowLoading = isLoading || searchSpaces.length > 0;
// Use global loading screen - spinner animation won't reset
useGlobalLoadingEffect(shouldShowLoading, t("fetching_spaces"), "default");
if (error) return <ErrorScreen message={error?.message || "Failed to load search spaces"} />;
if (searchSpaces.length > 0) {
return <LoadingScreen />;
if (shouldShowLoading) {
return null;
}
return (

View file

@ -1,7 +1,7 @@
"use client";
import { useAtomValue } from "jotai";
import { Loader2, Menu, User } from "lucide-react";
import { Menu, User } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
@ -11,6 +11,7 @@ import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";
interface ProfileContentProps {
onMenuClick: () => void;
@ -129,7 +130,7 @@ export function ProfileContent({ onMenuClick }: ProfileContentProps) {
>
{isUserLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<Spinner size="md" className="text-muted-foreground" />
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
@ -166,7 +167,7 @@ export function ProfileContent({ onMenuClick }: ProfileContentProps) {
<div className="flex justify-end">
<Button type="submit" disabled={isPending || !hasChanges}>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isPending && <Spinner size="sm" className="mr-2" />}
{t("profile_save")}
</Button>
</div>

View file

@ -6,8 +6,6 @@ import {
AlertCircle,
ArrowRight,
CheckCircle2,
Clock,
Loader2,
LogIn,
Shield,
Sparkles,
@ -30,6 +28,7 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Spinner } from "@/components/ui/spinner";
import type { AcceptInviteResponse } from "@/contracts/types/invites.types";
import { invitesApiService } from "@/lib/apis/invites-api.service";
import { getBearerToken } from "@/lib/auth-utils";
@ -164,7 +163,7 @@ export default function InviteAcceptPage() {
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
>
<Loader2 className="h-12 w-12 text-primary" />
<Spinner size="xl" className="text-primary" />
</motion.div>
<p className="mt-4 text-muted-foreground">Loading invite details...</p>
</CardContent>
@ -353,7 +352,7 @@ export default function InviteAcceptPage() {
<Button className="flex-1 gap-2" onClick={handleAccept} disabled={accepting}>
{accepting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
<Spinner size="sm" />
Accepting...
</>
) : (

View file

@ -3,6 +3,7 @@ import "./globals.css";
import { RootProvider } from "fumadocs-ui/provider/next";
import { Roboto } from "next/font/google";
import { ElectricProvider } from "@/components/providers/ElectricProvider";
import { GlobalLoadingProvider } from "@/components/providers/GlobalLoadingProvider";
import { I18nProvider } from "@/components/providers/I18nProvider";
import { PostHogProvider } from "@/components/providers/PostHogProvider";
import { ThemeProvider } from "@/components/theme/theme-provider";
@ -104,7 +105,9 @@ export default function RootLayout({
>
<RootProvider>
<ReactQueryClientProvider>
<ElectricProvider>{children}</ElectricProvider>
<ElectricProvider>
<GlobalLoadingProvider>{children}</GlobalLoadingProvider>
</ElectricProvider>
</ReactQueryClientProvider>
<Toaster />
</RootProvider>

View file

@ -175,5 +175,12 @@ export default function sitemap(): MetadataRoute.Sitemap {
changeFrequency: "daily",
priority: 0.8,
},
// How-to documentation
{
url: "https://www.surfsense.com/docs/how-to/electric-sql",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
];
}

View file

@ -0,0 +1,30 @@
import { atom } from "jotai";
interface GlobalLoadingState {
isLoading: boolean;
message?: string;
variant: "login" | "default";
}
export const globalLoadingAtom = atom<GlobalLoadingState>({
isLoading: false,
message: undefined,
variant: "default",
});
// Helper atom for showing global loading
export const showGlobalLoadingAtom = atom(
null,
(
get,
set,
{ message, variant = "default" }: { message?: string; variant?: "login" | "default" }
) => {
set(globalLoadingAtom, { isLoading: true, message, variant });
}
);
// Helper atom for hiding global loading
export const hideGlobalLoadingAtom = atom(null, (get, set) => {
set(globalLoadingAtom, { isLoading: false, message: undefined, variant: "default" });
});

View file

@ -0,0 +1,88 @@
---
title: "SurfSense v0.0.12 - New Main UI, Real-time Collaboration and Comments"
description: "SurfSense v0.0.12 transforms the platform with a redesigned chat-first interface, real-time collaboration features, comment threads with @mentions, and instant notifications powered by ElectricSQL + PGlite."
date: "2026-01-26"
tags: ["UX", "UI", "Real-time chat", "Collaboration", "Comments"]
version: "0.0.12"
---
<img src="/changelog/0.0.12/header.gif" alt="SurfSense v0.0.12 - New Main UI, Real-time Collaboration and Comments" className="rounded-lg w-full" />
## What's New in v0.0.12
This release brings major improvements to **collaboration and user experience**. We've completely redesigned the main interface to be chat-first, introduced real-time notifications and live collaboration features, and added a powerful commenting system with @mentions. These changes make SurfSense faster, more intuitive, and better for team collaboration.
### Major UX/UI Overhaul
#### New Chat-First Interface
- **Dashboard Removed**: Users now land directly in a chat for faster access
- **Redesigned Search Spaces**: Moved to a left column with color-coded icons and hover tooltips
- **Collapsible Sidebar**: New sidebar design with collapsible sections for private and group chats
- **Streamlined Settings**: Accessible through intuitive dropdown menus
- **Mobile-Responsive Design**: Better experience on all devices
- **Single-Click Google Login**: Replaces the old two-step process
### Real-Time Collaboration Features
#### Live Shared Chats
- **Multi-User Collaboration**: Multiple users can now collaborate in the same chat in real-time
- **Status Indicators**: See when the AI is responding to another team member
- **Instant Sync**: Changes sync instantly across all open tabs and users
#### Chat Comments with @Mentions
- **Comment on AI Responses**: Discuss responses directly with your team
- **Single-Level Threading**: Reply to comments with organized threads
- **@Mentions**: Tag teammates to get their attention
- **Real-Time Notifications**: Receive instant alerts when someone mentions you
#### Real-Time Notifications
- **Instant Updates**: Replaced slow polling with instant notifications using ElectricSQL + PGlite
- **New Inbox**: See connector indexing, document processing, and system events immediately
- **Cross-Tab Sync**: Syncs across all your browser tabs in real-time
### Connector Enhancements
#### OAuth Migration for Better Security
- **New OAuth Connectors**: Migrated Linear, Slack, Notion, Discord, Confluence, and Jira to OAuth-based authentication
- **Circleback Integration**: Connect your AI meeting notes from Circleback
- **Future Date Indexing**: Index future dates for calendar-based connectors to plan ahead
- **5-Minute Periodic Syncing**: Near-real-time updates option
- **Real-Time UI Updates**: See connector indexing progress without page refreshes
<Accordion type="multiple" className="w-full not-prose">
<AccordionItem value="item-1">
<AccordionTrigger>Bug Fixes</AccordionTrigger>
<AccordionContent className="flex flex-col gap-4 text-balance">
<ul className="list-disc space-y-2 pl-4">
<li>Syncs with no new content now show "Already up to date!" instead of falsely reporting failures</li>
<li>Restored missing indexing options page for Google Drive connector</li>
<li>File mention picker now handles large document counts with server-side search and pagination</li>
<li>Reasoning steps no longer overlap with chat input field</li>
<li>File upload modal is now scrollable when adding many files</li>
<li>OAuth connectors now display properly on mobile devices</li>
</ul>
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionTrigger>Technical Improvements</AccordionTrigger>
<AccordionContent className="flex flex-col gap-4 text-balance">
<ul className="list-disc space-y-2 pl-4">
<li>Made Alembic migrations idempotent for safer deployments</li>
<li>Migrations now work on fresh databases from scratch</li>
<li>Major refactoring of chat components for better maintainability</li>
<li>Streamlined sidebar and connector page code</li>
<li>Fixed legacy route handling</li>
<li>Fixed hardcoded Docker values for complex deployments</li>
</ul>
</AccordionContent>
</AccordionItem>
</Accordion>
This release transforms SurfSense into a truly collaborative, real-time platform with a redesigned interface that puts chat front and center. The addition of comments, @mentions, and live collaboration features makes it easier than ever for teams to work together without leaving the app.
SurfSense is your AI-powered federated search solution, connecting all your knowledge sources in one place.

View file

@ -1,7 +1,9 @@
"use client";
import { useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useEffect } from "react";
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
import { getAndClearRedirectPath, setBearerToken } from "@/lib/auth-utils";
import { trackLoginSuccess } from "@/lib/posthog/events";
@ -25,8 +27,12 @@ const TokenHandler = ({
tokenParamName = "token",
storageKey = "surfsense_bearer_token",
}: TokenHandlerProps) => {
const t = useTranslations("auth");
const searchParams = useSearchParams();
// Always show loading for this component - spinner animation won't reset
useGlobalLoadingEffect(true, t("processing_authentication"), "default");
useEffect(() => {
// Only run on client-side
if (typeof window === "undefined") return;
@ -66,11 +72,8 @@ const TokenHandler = ({
}
}, [searchParams, tokenParamName, storageKey, redirectPath]);
return (
<div className="flex items-center justify-center min-h-[200px]">
<p className="text-gray-500">Processing authentication...</p>
</div>
);
// Return null - the global provider handles the loading UI
return null;
};
export default TokenHandler;

View file

@ -7,7 +7,7 @@ import {
useAssistantApi,
useAssistantState,
} from "@assistant-ui/react";
import { FileText, Loader2, Paperclip, PlusIcon, Upload, XIcon } from "lucide-react";
import { FileText, Paperclip, PlusIcon, Upload, XIcon } from "lucide-react";
import Image from "next/image";
import { type FC, type PropsWithChildren, useEffect, useRef, useState } from "react";
import { useShallow } from "zustand/shallow";
@ -20,6 +20,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { useDocumentUploadDialog } from "./document-upload-popup";
@ -135,7 +136,7 @@ const AttachmentThumb: FC = () => {
if (isProcessing) {
return (
<div className="flex h-full w-full items-center justify-center bg-muted">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
<Spinner size="md" className="text-muted-foreground" />
</div>
);
}
@ -213,7 +214,7 @@ const AttachmentUI: FC = () => {
>
{isProcessing ? (
<span className="flex items-center gap-1.5">
<Loader2 className="size-3 animate-spin" />
<Spinner size="xs" />
Processing...
</span>
) : (

View file

@ -1,7 +1,7 @@
"use client";
import { Loader2 } from "lucide-react";
import type { FC } from "react";
import { Spinner } from "@/components/ui/spinner";
import { cn } from "@/lib/utils";
interface ChatSessionStatusProps {
@ -43,7 +43,7 @@ export const ChatSessionStatus: FC<ChatSessionStatusProps> = ({
className
)}
>
<Loader2 className="size-3.5 animate-spin" />
<Spinner size="xs" />
<span>Currently responding to {displayName}</span>
</div>
);

View file

@ -1,13 +1,14 @@
"use client";
import { useAtomValue } from "jotai";
import { Cable, Loader2 } from "lucide-react";
import { Cable } from "lucide-react";
import { useSearchParams } from "next/navigation";
import type { FC } from "react";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsContent } from "@/components/ui/tabs";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { useConnectorsElectric } from "@/hooks/use-connectors-electric";
@ -174,7 +175,7 @@ export const ConnectorIndicator: FC = () => {
onClick={() => handleOpenChange(true)}
>
{isLoading ? (
<Loader2 className="size-4 animate-spin" />
<Spinner size="sm" />
) : (
<>
<Cable className="size-4 stroke-[1.5px]" />

View file

@ -1,9 +1,10 @@
"use client";
import { IconBrandYoutube } from "@tabler/icons-react";
import { FileText, Loader2 } from "lucide-react";
import { FileText } from "lucide-react";
import type { FC } from "react";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { cn } from "@/lib/utils";
@ -111,7 +112,7 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
</div>
{isIndexing ? (
<p className="text-[11px] text-primary mt-1 flex items-center gap-1.5">
<Loader2 className="size-3 animate-spin" />
<Spinner size="xs" />
Syncing
</p>
) : isConnected ? (
@ -151,7 +152,7 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
disabled={isConnecting || !isEnabled}
>
{isConnecting ? (
<Loader2 className="size-3 animate-spin" />
<Spinner size="xs" />
) : !isEnabled ? (
"Unavailable"
) : isConnected ? (

View file

@ -29,11 +29,6 @@
"enabled": false,
"status": "disabled",
"statusMessage": "Not available yet."
},
"GITHUB_CONNECTOR": {
"enabled": false,
"status": "warning",
"statusMessage": "Some issues with indexing repositories."
}
},
"globalSettings": {

View file

@ -6,12 +6,6 @@ import type { FC } from "react";
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
Form,
@ -85,6 +79,7 @@ export const BookStackConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitt
BOOKSTACK_TOKEN_SECRET: values.token_secret,
},
is_indexable: true,
is_active: true,
last_indexed_at: null,
periodic_indexing_enabled: periodicEnabled,
indexing_frequency_minutes: periodicEnabled ? parseInt(frequencyMinutes, 10) : null,
@ -301,124 +296,6 @@ export const BookStackConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitt
</ul>
</div>
)}
{/* Documentation Section */}
<Accordion
type="single"
collapsible
className="w-full border border-border rounded-xl bg-slate-400/5 dark:bg-white/5"
>
<AccordionItem value="documentation" className="border-0">
<AccordionTrigger className="text-sm sm:text-base font-medium px-3 sm:px-6 no-underline hover:no-underline">
Documentation
</AccordionTrigger>
<AccordionContent className="px-3 sm:px-6 pb-3 sm:pb-6 space-y-6">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">How it works</h3>
<p className="text-[10px] sm:text-xs text-muted-foreground">
The BookStack connector uses the BookStack REST API to fetch all pages from your
BookStack instance that your account has access to.
</p>
<ul className="mt-2 list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
<li>
For follow up indexing runs, the connector retrieves pages that have been updated
since the last indexing attempt.
</li>
<li>
Indexing is configured to run periodically, so updates should appear in your
search results within minutes.
</li>
</ul>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Authorization</h3>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 mb-4">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">API Token Required</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
You need to create an API token from your BookStack instance. The token requires
"Access System API" permission.
</AlertDescription>
</Alert>
<div className="space-y-4 sm:space-y-6">
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 1: Create an API Token
</h4>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
<li>Log in to your BookStack instance</li>
<li>Click on your profile icon Edit Profile</li>
<li>Navigate to the "API Tokens" tab</li>
<li>Click "Create Token" and give it a name</li>
<li>Copy both the Token ID and Token Secret</li>
<li>Paste them in the form above</li>
</ol>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 2: Grant necessary access
</h4>
<p className="text-[10px] sm:text-xs text-muted-foreground mb-3">
Your user account must have "Access System API" permission. The connector will
only index content your account can view.
</p>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">Rate Limiting</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
BookStack API has a rate limit of 180 requests per minute. The connector
automatically handles rate limiting to ensure reliable indexing.
</AlertDescription>
</Alert>
</div>
</div>
</div>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Indexing</h3>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground mb-4">
<li>
Navigate to the Connector Dashboard and select the <strong>BookStack</strong>{" "}
Connector.
</li>
<li>
Enter your <strong>BookStack Instance URL</strong> (e.g.,
https://docs.example.com)
</li>
<li>
Enter your <strong>Token ID</strong> and <strong>Token Secret</strong> from your
BookStack API token.
</li>
<li>
Click <strong>Connect</strong> to establish the connection.
</li>
<li>Once connected, your BookStack pages will be indexed automatically.</li>
</ol>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">What Gets Indexed</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
<p className="mb-2">The BookStack connector indexes the following data:</p>
<ul className="list-disc pl-5 space-y-1">
<li>All pages from your BookStack instance</li>
<li>Page content in Markdown format</li>
<li>Page titles and metadata</li>
<li>Book and chapter hierarchy information</li>
</ul>
</AlertDescription>
</Alert>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
};

View file

@ -6,12 +6,6 @@ import type { FC } from "react";
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
Form,
@ -253,131 +247,6 @@ export const LumaConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }
</ul>
</div>
)}
{/* Documentation Section */}
<Accordion
type="single"
collapsible
className="w-full border border-border rounded-xl bg-slate-400/5 dark:bg-white/5"
>
<AccordionItem value="documentation" className="border-0">
<AccordionTrigger className="text-sm sm:text-base font-medium px-3 sm:px-6 no-underline hover:no-underline">
Documentation
</AccordionTrigger>
<AccordionContent className="px-3 sm:px-6 pb-3 sm:pb-6 space-y-6">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">How it works</h3>
<p className="text-[10px] sm:text-xs text-muted-foreground">
The Luma connector uses the Luma API to fetch all events that your API key has
access to.
</p>
<ul className="mt-2 list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
<li>
For follow up indexing runs, the connector retrieves events that have been updated
since the last indexing attempt.
</li>
<li>
Indexing is configured to run periodically, so updates should appear in your
search results within minutes.
</li>
</ul>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Authorization</h3>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 mb-4">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">API Key Required</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
You need a Luma API key to use this connector. The key will be used to read your
Luma events with read-only permissions.
</AlertDescription>
</Alert>
<div className="space-y-4 sm:space-y-6">
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 1: Get Your API Key
</h4>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
<li>Log into your Luma account</li>
<li>Navigate to your account settings</li>
<li>Go to API settings or Developer settings</li>
<li>Generate a new API key</li>
<li>Copy the generated API key</li>
<li>
You can also visit{" "}
<a
href="https://lu.ma/api"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
Luma API Settings
</a>{" "}
for more information.
</li>
</ol>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 2: Grant necessary access
</h4>
<p className="text-[10px] sm:text-xs text-muted-foreground mb-3">
The API key will have access to all events that your user account can see.
Make sure your account has appropriate permissions for the events you want to
index.
</p>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">Data Privacy</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
Only event details, descriptions, and attendee information will be indexed.
Event attachments and linked files are not indexed by this connector.
</AlertDescription>
</Alert>
</div>
</div>
</div>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Indexing</h3>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground mb-4">
<li>
Navigate to the Connector Dashboard and select the <strong>Luma</strong>{" "}
Connector.
</li>
<li>
Place your <strong>API Key</strong> in the form field.
</li>
<li>
Click <strong>Connect</strong> to establish the connection.
</li>
<li>Once connected, your Luma events will be indexed automatically.</li>
</ol>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">What Gets Indexed</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
<p className="mb-2">The Luma connector indexes the following data:</p>
<ul className="list-disc pl-5 space-y-1">
<li>Event titles and descriptions</li>
<li>Event details and metadata</li>
<li>Attendee information</li>
<li>Event dates and locations</li>
</ul>
</AlertDescription>
</Alert>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
};

View file

@ -6,12 +6,6 @@ import type { FC } from "react";
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
Form,
@ -320,145 +314,6 @@ export const ObsidianConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitti
</ul>
</div>
)}
{/* Documentation Section */}
<Accordion
type="single"
collapsible
className="w-full border border-border rounded-xl bg-slate-400/5 dark:bg-white/5"
>
<AccordionItem value="documentation" className="border-0">
<AccordionTrigger className="text-sm sm:text-base font-medium px-3 sm:px-6 no-underline hover:no-underline">
Documentation
</AccordionTrigger>
<AccordionContent className="px-3 sm:px-6 pb-3 sm:pb-6 space-y-6">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">How it works</h3>
<p className="text-[10px] sm:text-xs text-muted-foreground">
The Obsidian connector scans your local Obsidian vault directory and indexes all
Markdown files. It preserves your note structure and extracts metadata from YAML
frontmatter.
</p>
<ul className="mt-2 list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
<li>
The connector parses frontmatter metadata (title, tags, aliases, dates, etc.)
</li>
<li>Wiki-style links ([[note]]) are extracted and preserved</li>
<li>Inline tags (#tag) are recognized and indexed</li>
<li>Content is chunked intelligently for optimal search results</li>
<li>
Subsequent indexing runs use content hashing to skip unchanged files for faster
sync
</li>
</ul>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Setup</h3>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 mb-4">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">
File System Access Required
</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
The SurfSense backend must have read access to your Obsidian vault directory.
For Docker deployments, mount your vault as a volume.
</AlertDescription>
</Alert>
<div className="space-y-4 sm:space-y-6">
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 1: Locate your vault
</h4>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
<li>
<strong>macOS/Linux:</strong> Right-click any note in Obsidian "Reveal in
Finder" to see the vault folder
</li>
<li>
<strong>Windows:</strong> Right-click any note "Show in system explorer"
</li>
<li>
<strong>Or:</strong> Click the vault switcher (bottom-left icon) "Open
folder" next to your vault name
</li>
</ol>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 2: Enter the path
</h4>
<p className="text-[10px] sm:text-xs text-muted-foreground mb-2">
<strong>Running locally (no Docker):</strong> Use the direct path to your
vault:
</p>
<pre className="bg-slate-800 text-slate-200 p-2 rounded text-[9px] sm:text-[10px] overflow-x-auto mb-2">
{`/Users/yourname/Documents/MyObsidianVault`}
</pre>
<p className="text-[10px] sm:text-xs text-muted-foreground mb-2">
<strong>Running in Docker:</strong> Mount your vault as a volume in
docker-compose.yml:
</p>
<pre className="bg-slate-800 text-slate-200 p-2 rounded text-[9px] sm:text-[10px] overflow-x-auto">
{`volumes:
- /path/to/your/vault:/app/obsidian_vaults/my-vault:ro`}
</pre>
<p className="text-[10px] sm:text-xs text-muted-foreground mt-2">
Then use <code>/app/obsidian_vaults/my-vault</code> as your vault path.
</p>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 3: Configure exclusions
</h4>
<p className="text-[10px] sm:text-xs text-muted-foreground">
Common folders to exclude:
</p>
<ul className="list-disc pl-5 mt-1 space-y-1 text-[10px] sm:text-xs text-muted-foreground">
<li>
<code>.obsidian</code> - Obsidian config (always recommended)
</li>
<li>
<code>.trash</code> - Obsidian's trash folder
</li>
<li>
<code>templates</code> - If you have a templates folder
</li>
<li>
<code>daily-notes</code> - If you want to exclude daily notes
</li>
</ul>
</div>
</div>
</div>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">What Gets Indexed</h3>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">Indexed Content</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
<p className="mb-2">The Obsidian connector indexes:</p>
<ul className="list-disc pl-5 space-y-1">
<li>All Markdown files (.md) in your vault</li>
<li>YAML frontmatter metadata (title, tags, aliases, dates)</li>
<li>Wiki-style links between notes</li>
<li>Inline tags throughout your notes</li>
<li>Full note content with proper chunking</li>
</ul>
</AlertDescription>
</Alert>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
};

View file

@ -1,8 +1,9 @@
"use client";
import { ArrowLeft, Loader2 } from "lucide-react";
import { ArrowLeft } from "lucide-react";
import { type FC, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import type { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { getConnectorTypeDisplay } from "@/lib/connectors/utils";
@ -139,7 +140,7 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Spinner size="sm" className="mr-2" />
Connecting
</>
) : connectorType === "MCP_CONNECTOR" ? (

View file

@ -1,15 +1,16 @@
"use client";
import { ArrowLeft, Info, Loader2, RefreshCw, Trash2 } from "lucide-react";
import { ArrowLeft, Info, RefreshCw, Trash2 } from "lucide-react";
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { cn } from "@/lib/utils";
import { DateRangeSelector } from "../../components/date-range-selector";
import { PeriodicSyncConfig } from "../../components/periodic-sync-config";
import { getConnectorConfigComponent } from "../index";
import { getConnectorDisplayName } from "../../tabs/all-connectors-tab";
import { getConnectorConfigComponent } from "../index";
interface ConnectorEditViewProps {
connector: SearchSourceConnector;
@ -311,7 +312,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
>
{isDisconnecting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Spinner size="sm" className="mr-2" />
Disconnecting
</>
) : (
@ -347,7 +348,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Spinner size="sm" className="mr-2" />
Saving
</>
) : (

View file

@ -1,9 +1,10 @@
"use client";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { ArrowLeft, Check, Info } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { getConnectorTypeDisplay } from "@/lib/connectors/utils";
import { cn } from "@/lib/utils";
@ -216,7 +217,7 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
>
{isStartingIndexing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Spinner size="sm" className="mr-2" />
Starting...
</>
) : (

View file

@ -1,11 +1,12 @@
"use client";
import { ArrowRight, Cable, Loader2 } from "lucide-react";
import { ArrowRight, Cable } from "lucide-react";
import { useRouter } from "next/navigation";
import type { FC } from "react";
import { useState } from "react";
import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { TabsContent } from "@/components/ui/tabs";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
@ -209,7 +210,7 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
<p className="text-[14px] font-semibold leading-tight truncate">{title}</p>
{isAnyIndexing ? (
<p className="text-[11px] text-primary mt-1 flex items-center gap-1.5">
<Loader2 className="size-3 animate-spin" />
<Spinner size="xs" />
Syncing
</p>
) : (
@ -270,7 +271,7 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
</div>
{isIndexing ? (
<p className="text-[11px] text-primary mt-1 flex items-center gap-1.5">
<Loader2 className="size-3 animate-spin" />
<Spinner size="xs" />
Syncing
</p>
) : !isMCPConnector ? (

View file

@ -1,9 +1,10 @@
"use client";
import { differenceInDays, differenceInMinutes, format, isToday, isYesterday } from "date-fns";
import { ArrowLeft, Loader2, Plus, Server } from "lucide-react";
import { ArrowLeft, Plus, Server } from "lucide-react";
import type { FC } from "react";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
@ -143,7 +144,7 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
>
<div className="flex h-5 w-5 items-center justify-center rounded-md bg-primary/10 shrink-0">
{isConnecting ? (
<Loader2 className="size-3 animate-spin text-primary" />
<Spinner size="xs" className="text-primary" />
) : (
<Plus className="size-3 text-primary" />
)}
@ -207,7 +208,7 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
</p>
{isIndexing ? (
<p className="text-[11px] text-primary mt-1 flex items-center gap-1.5">
<Loader2 className="size-3 animate-spin" />
<Spinner size="xs" />
Syncing
</p>
) : (

View file

@ -2,7 +2,7 @@
import { TagInput, type Tag as TagType } from "emblor";
import { useAtom } from "jotai";
import { ArrowLeft, Loader2 } from "lucide-react";
import { ArrowLeft } from "lucide-react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { type FC, useState } from "react";
@ -10,6 +10,7 @@ import { toast } from "sonner";
import { createDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
@ -222,7 +223,7 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ searchSpaceId,
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Spinner size="sm" className="mr-2" />
{t("processing")}
</>
) : (

View file

@ -6,6 +6,7 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { CommentActionsProps } from "./types";
@ -23,7 +24,7 @@ export function CommentActions({ canEdit, canDelete, onEdit, onDelete }: Comment
size="icon"
className="size-7 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity"
>
<MoreHorizontal className="size-4" />
<MoreHorizontal className="size-4 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
@ -33,8 +34,9 @@ export function CommentActions({ canEdit, canDelete, onEdit, onDelete }: Comment
Edit
</DropdownMenuItem>
)}
{canEdit && canDelete && <DropdownMenuSeparator />}
{canDelete && (
<DropdownMenuItem onClick={onDelete} className="text-destructive">
<DropdownMenuItem onClick={onDelete} className="text-destructive focus:text-destructive">
<Trash2 className="mr-2 size-4" />
Delete
</DropdownMenuItem>

View file

@ -1,7 +1,7 @@
"use client";
import { Loader2 } from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Spinner } from "@/components/ui/spinner";
import { MemberMentionItem } from "./member-mention-item";
import type { MemberMentionPickerProps } from "./types";
@ -24,7 +24,7 @@ export function MemberMentionPicker({
if (isLoading) {
return (
<div className="flex items-center justify-center py-6">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
<Spinner size="md" className="text-muted-foreground" />
</div>
);
}

View file

@ -10,12 +10,12 @@ import {
FolderOpen,
HardDrive,
Image,
Loader2,
Presentation,
} from "lucide-react";
import { useState } from "react";
import { Checkbox } from "@/components/ui/checkbox";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Spinner } from "@/components/ui/spinner";
import { useComposioDriveFolders } from "@/hooks/use-composio-drive-folders";
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
import { cn } from "@/lib/utils";
@ -253,7 +253,7 @@ export function ComposioDriveFolderTree({
aria-label={isExpanded ? `Collapse ${item.name}` : `Expand ${item.name}`}
>
{isLoading ? (
<Loader2 className="h-2.5 w-2.5 sm:h-3 sm:w-3 animate-spin" />
<Spinner size="xs" className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
) : isExpanded ? (
<ChevronDown className="h-3 w-3 sm:h-4 sm:w-4" />
) : (
@ -344,7 +344,7 @@ export function ComposioDriveFolderTree({
{isLoadingRoot && (
<div className="flex items-center justify-center py-4 sm:py-8">
<Loader2 className="h-4 w-4 sm:h-6 sm:w-6 animate-spin text-muted-foreground" />
<Spinner size="sm" className="sm:h-6 sm:w-6 text-muted-foreground" />
</div>
)}

View file

@ -10,12 +10,12 @@ import {
FolderOpen,
HardDrive,
Image,
Loader2,
Presentation,
} from "lucide-react";
import { useState } from "react";
import { Checkbox } from "@/components/ui/checkbox";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Spinner } from "@/components/ui/spinner";
import { useGoogleDriveFolders } from "@/hooks/use-google-drive-folders";
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
import { cn } from "@/lib/utils";
@ -253,7 +253,7 @@ export function GoogleDriveFolderTree({
aria-label={isExpanded ? `Collapse ${item.name}` : `Expand ${item.name}`}
>
{isLoading ? (
<Loader2 className="h-2.5 w-2.5 sm:h-3 sm:w-3 animate-spin" />
<Spinner size="xs" className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
) : isExpanded ? (
<ChevronDown className="h-3 w-3 sm:h-4 sm:w-4" />
) : (
@ -344,7 +344,7 @@ export function GoogleDriveFolderTree({
{isLoadingRoot && (
<div className="flex items-center justify-center py-4 sm:py-8">
<Loader2 className="h-4 w-4 sm:h-6 sm:w-6 animate-spin text-muted-foreground" />
<Spinner size="sm" className="sm:h-6 sm:w-6 text-muted-foreground" />
</div>
)}

View file

@ -1,104 +1,55 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { IconMailFilled } from "@tabler/icons-react";
import { IconCalendar, IconMailFilled } from "@tabler/icons-react";
import { motion } from "motion/react";
import Image from "next/image";
import Link from "next/link";
import type React from "react";
import { useId, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { useId } from "react";
import { cn } from "@/lib/utils";
// Define validation schema matching the database schema
const contactFormSchema = z.object({
name: z.string().min(1, "Name is required").max(255, "Name is too long"),
email: z.email("Invalid email address").max(255, "Email is too long"),
company: z.string().min(1, "Company is required").max(255, "Company name is too long"),
message: z.string().optional().prefault(""),
});
type ContactFormData = z.infer<typeof contactFormSchema>;
export function ContactFormGridWithDetails() {
const [isSubmitting, setIsSubmitting] = useState(false);
const {
register,
handleSubmit,
formState: { errors },
reset,
} = useForm<ContactFormData>({
resolver: zodResolver(contactFormSchema),
});
const onSubmit = async (data: ContactFormData) => {
setIsSubmitting(true);
try {
const response = await fetch("/api/contact", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
const result = await response.json();
if (response.ok) {
toast.success("Message sent successfully!", {
description: "We will get back to you as soon as possible.",
});
reset();
} else {
toast.error("Failed to send message", {
description: result.message || "Please try again later.",
});
}
} catch (error) {
console.error("Error submitting form:", error);
toast.error("Something went wrong", {
description: "Please try again later.",
});
} finally {
setIsSubmitting(false);
}
};
return (
<div className="mx-auto grid w-full max-w-7xl grid-cols-1 gap-10 px-4 py-10 md:px-6 md:py-20 lg:grid-cols-2">
<div className="relative flex flex-col items-center overflow-hidden lg:items-start">
<div className="mx-auto flex w-full max-w-7xl flex-col items-center gap-10 px-4 py-10 md:px-6 md:py-20">
<div className="relative flex flex-col items-center overflow-hidden">
<div className="flex items-start justify-start">
<FeatureIconContainer className="flex items-center justify-center overflow-hidden">
<IconMailFilled className="h-6 w-6 text-blue-500" />
</FeatureIconContainer>
</div>
<h2 className="mt-9 bg-gradient-to-b from-neutral-800 to-neutral-900 bg-clip-text text-left text-xl font-bold text-transparent md:text-3xl lg:text-5xl dark:from-neutral-200 dark:to-neutral-300">
<h2 className="mt-9 bg-gradient-to-b from-neutral-800 to-neutral-900 bg-clip-text text-center text-xl font-bold text-transparent md:text-3xl lg:text-5xl dark:from-neutral-200 dark:to-neutral-300">
Contact
</h2>
<p className="mt-8 max-w-lg text-center text-base text-neutral-600 md:text-left dark:text-neutral-400">
We'd love to Hear From You.
<p className="mt-8 max-w-lg text-center text-base text-neutral-600 dark:text-neutral-400">
We'd love to hear from you. Schedule a meeting or send us an email.
</p>
<div className="mt-10 hidden flex-col items-center gap-4 md:flex-row lg:flex">
<div className="mt-10 flex flex-col items-center gap-6">
<Link
href="mailto:rohan@surfsense.com"
className="text-sm text-neutral-500 dark:text-neutral-400"
href="https://calendly.com/eric-surfsense/surfsense-meeting"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 rounded-xl bg-gradient-to-b from-blue-500 to-blue-600 px-6 py-3 text-base font-medium text-white shadow-lg transition duration-200 hover:from-blue-600 hover:to-blue-700"
>
rohan@surfsense.com
<IconCalendar className="h-5 w-5" />
Schedule a Meeting
</Link>
<div className="h-1 w-1 rounded-full bg-neutral-500 dark:bg-neutral-400" />
<div className="flex items-center gap-2 text-neutral-500 dark:text-neutral-400">
<span className="h-px w-8 bg-neutral-300 dark:bg-neutral-600" />
<span className="text-sm">or</span>
<span className="h-px w-8 bg-neutral-300 dark:bg-neutral-600" />
</div>
<Link
href="https://cal.com/mod-surfsense"
className="text-sm text-neutral-500 dark:text-neutral-400"
href="mailto:eric@surfsense.com"
className="flex items-center gap-2 text-base text-neutral-600 transition duration-200 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-200"
>
https://cal.com/mod-surfsense
<IconMailFilled className="h-5 w-5" />
eric@surfsense.com
</Link>
</div>
<div className="div relative mt-20 flex w-[600px] flex-shrink-0 -translate-x-10 items-center justify-center [perspective:800px] [transform-style:preserve-3d] sm:-translate-x-0 lg:-translate-x-32">
<div className="div relative mt-20 flex w-[600px] flex-shrink-0 items-center justify-center [perspective:800px] [transform-style:preserve-3d]">
<Pin className="h-30 w-85 top-0 left-0" />
<Image
@ -110,95 +61,6 @@ export function ContactFormGridWithDetails() {
/>
</div>
</div>
<form
onSubmit={handleSubmit(onSubmit)}
className="relative mx-auto flex w-full max-w-2xl flex-col items-start gap-4 overflow-hidden rounded-3xl bg-gradient-to-b from-gray-100 to-gray-200 p-4 sm:p-10 dark:from-neutral-900 dark:to-neutral-950"
>
<Grid size={20} />
<div className="relative z-20 mb-4 w-full">
<label
className="mb-2 inline-block text-sm font-medium text-neutral-600 dark:text-neutral-300"
htmlFor="name"
>
Full name
</label>
<input
id="name"
type="text"
placeholder="John Doe"
{...register("name")}
className={cn(
"shadow-input h-10 w-full rounded-md border bg-white pl-4 text-sm text-neutral-700 placeholder-neutral-500 outline-none focus:ring-2 focus:ring-neutral-800 focus:outline-none active:outline-none dark:border-neutral-800 dark:bg-neutral-800 dark:text-white",
errors.name ? "border-red-500" : "border-transparent"
)}
/>
{errors.name && <p className="mt-1 text-xs text-red-500">{errors.name.message}</p>}
</div>
<div className="relative z-20 mb-4 w-full">
<label
className="mb-2 inline-block text-sm font-medium text-neutral-600 dark:text-neutral-300"
htmlFor="email"
>
Email Address
</label>
<input
id="email"
type="email"
placeholder="john.doe@example.com"
{...register("email")}
className={cn(
"shadow-input h-10 w-full rounded-md border bg-white pl-4 text-sm text-neutral-700 placeholder-neutral-500 outline-none focus:ring-2 focus:ring-neutral-800 focus:outline-none active:outline-none dark:border-neutral-800 dark:bg-neutral-800 dark:text-white",
errors.email ? "border-red-500" : "border-transparent"
)}
/>
{errors.email && <p className="mt-1 text-xs text-red-500">{errors.email.message}</p>}
</div>
<div className="relative z-20 mb-4 w-full">
<label
className="mb-2 inline-block text-sm font-medium text-neutral-600 dark:text-neutral-300"
htmlFor="company"
>
Company
</label>
<input
id="company"
type="text"
placeholder="Example Inc."
{...register("company")}
className={cn(
"shadow-input h-10 w-full rounded-md border bg-white pl-4 text-sm text-neutral-700 placeholder-neutral-500 outline-none focus:ring-2 focus:ring-neutral-800 focus:outline-none active:outline-none dark:border-neutral-800 dark:bg-neutral-800 dark:text-white",
errors.company ? "border-red-500" : "border-transparent"
)}
/>
{errors.company && <p className="mt-1 text-xs text-red-500">{errors.company.message}</p>}
</div>
<div className="relative z-20 mb-4 w-full">
<label
className="mb-2 inline-block text-sm font-medium text-neutral-600 dark:text-neutral-300"
htmlFor="message"
>
Message <span className="text-neutral-400 text-xs font-normal">(optional)</span>
</label>
<textarea
id="message"
rows={5}
placeholder="Type your message here"
{...register("message")}
className={cn(
"shadow-input w-full rounded-md border bg-white pt-4 pl-4 text-sm text-neutral-700 placeholder-neutral-500 outline-none focus:ring-2 focus:ring-neutral-800 focus:outline-none active:outline-none dark:border-neutral-800 dark:bg-neutral-800 dark:text-white",
errors.message ? "border-red-500" : "border-transparent"
)}
/>
{errors.message && <p className="mt-1 text-xs text-red-500">{errors.message.message}</p>}
</div>
<button
type="submit"
disabled={isSubmitting}
className="relative z-10 flex items-center justify-center rounded-md border border-transparent bg-neutral-800 px-4 py-2 text-sm font-medium text-white shadow-[0px_1px_0px_0px_#FFFFFF20_inset] transition duration-200 hover:bg-neutral-900 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm"
>
{isSubmitting ? "Submitting..." : "Submit"}
</button>
</form>
</div>
);
}

View file

@ -184,7 +184,7 @@ function GetStartedButton() {
return (
<motion.div whileHover={{ scale: 1.02, y: -2 }} whileTap={{ scale: 0.98 }}>
<Link
href="/register"
href="/login"
className="group relative z-20 flex h-11 w-full cursor-pointer items-center justify-center gap-2 rounded-xl bg-black px-6 py-2.5 text-sm font-semibold text-white shadow-lg transition-shadow duration-300 hover:shadow-xl sm:w-56 dark:bg-white dark:text-black"
>
Get Started

View file

@ -1,5 +1,11 @@
"use client";
import { IconBrandDiscord, IconBrandGithub, IconBrandReddit, IconMenu2, IconX } from "@tabler/icons-react";
import {
IconBrandDiscord,
IconBrandGithub,
IconBrandReddit,
IconMenu2,
IconX,
} from "@tabler/icons-react";
import { AnimatePresence, motion } from "motion/react";
import Link from "next/link";
import { useEffect, useState } from "react";
@ -79,7 +85,7 @@ export const Navbar = () => {
const [isScrolled, setIsScrolled] = useState(false);
const navItems = [
// { name: "Home", link: "/" },
{ name: "Contact Us", link: "/contact" },
{ name: "Pricing", link: "/pricing" },
{ name: "Changelog", link: "/changelog" },
// { name: "Sign In", link: "/login" },
@ -99,7 +105,7 @@ export const Navbar = () => {
}, []);
return (
<div className="fixed top-1 left-0 right-0 z-[60] w-full">
<div className="fixed top-1 left-0 right-0 z-60 w-full">
<DesktopNav navItems={navItems} isScrolled={isScrolled} />
<MobileNav navItems={navItems} isScrolled={isScrolled} />
</div>

View file

@ -7,6 +7,7 @@ import { useParams, usePathname, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useTheme } from "next-themes";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { currentThreadAtom, resetCurrentThreadAtom } from "@/atoms/chat/current-thread.atom";
import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms";
@ -22,7 +23,7 @@ import {
} from "@/components/ui/dialog";
import { useInbox } from "@/hooks/use-inbox";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { deleteThread, fetchThreads } from "@/lib/chat/thread-persistence";
import { deleteThread, fetchThreads, updateThread } from "@/lib/chat/thread-persistence";
import { cleanupElectric } from "@/lib/electric/client";
import { resetUser, trackLogout } from "@/lib/posthog/events";
import { cacheKeys } from "@/lib/query-client/cache-keys";
@ -57,6 +58,7 @@ export function LayoutDataProvider({
}: LayoutDataProviderProps) {
const t = useTranslations("dashboard");
const tCommon = useTranslations("common");
const tSidebar = useTranslations("sidebar");
const router = useRouter();
const params = useParams();
const pathname = usePathname();
@ -171,6 +173,7 @@ export function LayoutDataProvider({
url: `/dashboard/${searchSpaceId}/new-chat/${thread.id}`,
visibility: thread.visibility,
isOwnThread: thread.is_own_thread,
archived: thread.archived,
};
// Split based on visibility, not ownership:
@ -333,6 +336,28 @@ export function LayoutDataProvider({
setShowDeleteChatDialog(true);
}, []);
const handleChatArchive = useCallback(
async (chat: ChatItem) => {
const newArchivedState = !chat.archived;
const successMessage = newArchivedState
? tSidebar("chat_archived") || "Chat archived"
: tSidebar("chat_unarchived") || "Chat restored";
try {
await updateThread(chat.id, { archived: newArchivedState });
toast.success(successMessage);
// Invalidate queries to refresh UI (React Query will only refetch active queries)
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] });
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
} catch (error) {
console.error("Error archiving thread:", error);
toast.error(tSidebar("error_archiving_chat") || "Failed to archive chat");
}
},
[queryClient, searchSpaceId, tSidebar]
);
const handleSettings = useCallback(() => {
router.push(`/dashboard/${searchSpaceId}/settings`);
}, [router, searchSpaceId]);
@ -420,6 +445,7 @@ export function LayoutDataProvider({
onNewChat={handleNewChat}
onChatSelect={handleChatSelect}
onChatDelete={handleChatDelete}
onChatArchive={handleChatArchive}
onViewAllSharedChats={handleViewAllSharedChats}
onViewAllPrivateChats={handleViewAllPrivateChats}
user={{

View file

@ -30,6 +30,7 @@ export interface ChatItem {
isActive?: boolean;
visibility?: "PRIVATE" | "SEARCH_SPACE";
isOwnThread?: boolean;
archived?: boolean;
}
export interface PageUsage {

View file

@ -2,7 +2,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useAtomValue } from "jotai";
import { Loader2, Plus, Search } from "lucide-react";
import { Plus, Search } from "lucide-react";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { useForm } from "react-hook-form";
@ -26,6 +26,7 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
import { trackSearchSpaceCreated } from "@/lib/posthog/events";
const formSchema = z.object({
@ -82,29 +83,36 @@ export function CreateSearchSpaceDialog({ open, onOpenChange }: CreateSearchSpac
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Search className="h-5 w-5 text-primary" />
<DialogContent className="max-w-[90vw] sm:max-w-sm p-4 sm:p-5 data-[state=open]:animate-none data-[state=closed]:animate-none">
<DialogHeader className="space-y-2 pb-2">
<div className="flex items-center gap-2 sm:gap-3">
<div className="flex h-8 w-8 sm:h-10 sm:w-10 items-center justify-center rounded-lg bg-primary/10 flex-shrink-0">
<Search className="h-4 w-4 sm:h-5 sm:w-5 text-primary" />
</div>
<div>
<DialogTitle>{t("create_title")}</DialogTitle>
<DialogDescription>{t("create_description")}</DialogDescription>
<div className="flex-1 min-w-0">
<DialogTitle className="text-base sm:text-lg">{t("create_title")}</DialogTitle>
<DialogDescription className="text-xs sm:text-sm mt-0.5">
{t("create_description")}
</DialogDescription>
</div>
</div>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="flex flex-col gap-4">
<form onSubmit={form.handleSubmit(handleSubmit)} className="flex flex-col gap-3 sm:gap-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("name_label")}</FormLabel>
<FormLabel className="text-sm">{t("name_label")}</FormLabel>
<FormControl>
<Input placeholder={t("name_placeholder")} {...field} autoFocus />
<Input
placeholder={t("name_placeholder")}
{...field}
autoFocus
className="text-sm h-9 sm:h-10"
/>
</FormControl>
<FormMessage />
</FormItem>
@ -116,38 +124,47 @@ export function CreateSearchSpaceDialog({ open, onOpenChange }: CreateSearchSpac
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>
<FormLabel className="text-sm">
{t("description_label")}{" "}
<span className="text-muted-foreground font-normal">
({tCommon("optional")})
</span>
</FormLabel>
<FormControl>
<Input placeholder={t("description_placeholder")} {...field} />
<Input
placeholder={t("description_placeholder")}
{...field}
className="text-sm h-9 sm:h-10"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="flex gap-2 pt-2">
<DialogFooter className="flex-col sm:flex-row gap-2 pt-2 sm:pt-3">
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isSubmitting}
className="w-full sm:w-auto h-9 sm:h-10 text-sm"
>
{tCommon("cancel")}
</Button>
<Button type="submit" disabled={isSubmitting}>
<Button
type="submit"
disabled={isSubmitting}
className="w-full sm:w-auto h-9 sm:h-10 text-sm"
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Spinner size="sm" className="mr-1.5" />
{t("creating")}
</>
) : (
<>
<Plus className="mr-2 h-4 w-4" />
<Plus className="-mr-1 h-4 w-4" />
{t("create_button")}
</>
)}

View file

@ -26,6 +26,7 @@ interface LayoutShellProps {
onNewChat: () => void;
onChatSelect: (chat: ChatItem) => void;
onChatDelete?: (chat: ChatItem) => void;
onChatArchive?: (chat: ChatItem) => void;
onViewAllSharedChats?: () => void;
onViewAllPrivateChats?: () => void;
user: User;
@ -59,6 +60,7 @@ export function LayoutShell({
onNewChat,
onChatSelect,
onChatDelete,
onChatArchive,
onViewAllSharedChats,
onViewAllPrivateChats,
user,
@ -107,6 +109,7 @@ export function LayoutShell({
onNewChat={onNewChat}
onChatSelect={onChatSelect}
onChatDelete={onChatDelete}
onChatArchive={onChatArchive}
onViewAllSharedChats={onViewAllSharedChats}
onViewAllPrivateChats={onViewAllPrivateChats}
user={user}
@ -155,6 +158,7 @@ export function LayoutShell({
onNewChat={onNewChat}
onChatSelect={onChatSelect}
onChatDelete={onChatDelete}
onChatArchive={onChatArchive}
onViewAllSharedChats={onViewAllSharedChats}
onViewAllPrivateChats={onViewAllPrivateChats}
user={user}

View file

@ -4,7 +4,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { format } from "date-fns";
import {
ArchiveIcon,
Loader2,
MessageCircleMore,
MoreHorizontal,
RotateCcwIcon,
@ -28,6 +27,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
@ -304,7 +304,7 @@ export function AllPrivateChatsSidebar({
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<Spinner size="md" className="text-muted-foreground" />
</div>
) : error ? (
<div className="text-center py-8 text-sm text-destructive">
@ -365,7 +365,7 @@ export function AllPrivateChatsSidebar({
disabled={isBusy}
>
{isDeleting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<Spinner size="xs" />
) : (
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
)}

View file

@ -4,7 +4,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { format } from "date-fns";
import {
ArchiveIcon,
Loader2,
MessageCircleMore,
MoreHorizontal,
RotateCcwIcon,
@ -28,6 +27,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
@ -304,7 +304,7 @@ export function AllSharedChatsSidebar({
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<Spinner size="md" className="text-muted-foreground" />
</div>
) : error ? (
<div className="text-center py-8 text-sm text-destructive">
@ -365,7 +365,7 @@ export function AllSharedChatsSidebar({
disabled={isBusy}
>
{isDeleting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<Spinner size="xs" />
) : (
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
)}

View file

@ -1,12 +1,13 @@
"use client";
import { MessageSquare, MoreHorizontal } from "lucide-react";
import { ArchiveIcon, MessageSquare, MoreHorizontal, RotateCcwIcon, Trash2 } from "lucide-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
@ -14,11 +15,20 @@ import { cn } from "@/lib/utils";
interface ChatListItemProps {
name: string;
isActive?: boolean;
archived?: boolean;
onClick?: () => void;
onArchive?: () => void;
onDelete?: () => void;
}
export function ChatListItem({ name, isActive, onClick, onDelete }: ChatListItemProps) {
export function ChatListItem({
name,
isActive,
archived,
onClick,
onArchive,
onDelete,
}: ChatListItemProps) {
const t = useTranslations("sidebar");
return (
@ -48,15 +58,39 @@ export function ChatListItem({ name, isActive, onClick, onDelete }: ChatListItem
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="right">
{onArchive && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDelete?.();
onArchive();
}}
>
{archived ? (
<>
<RotateCcwIcon className="mr-2 h-4 w-4" />
<span>{t("unarchive") || "Restore"}</span>
</>
) : (
<>
<ArchiveIcon className="mr-2 h-4 w-4" />
<span>{t("archive") || "Archive"}</span>
</>
)}
</DropdownMenuItem>
)}
{onArchive && onDelete && <DropdownMenuSeparator />}
{onDelete && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="text-destructive focus:text-destructive"
>
{t("delete")}
<Trash2 className="mr-2 h-4 w-4" />
<span>{t("delete")}</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>

View file

@ -25,6 +25,7 @@ interface MobileSidebarProps {
onNewChat: () => void;
onChatSelect: (chat: ChatItem) => void;
onChatDelete?: (chat: ChatItem) => void;
onChatArchive?: (chat: ChatItem) => void;
onViewAllSharedChats?: () => void;
onViewAllPrivateChats?: () => void;
user: User;
@ -64,6 +65,7 @@ export function MobileSidebar({
onNewChat,
onChatSelect,
onChatDelete,
onChatArchive,
onViewAllSharedChats,
onViewAllPrivateChats,
user,
@ -141,6 +143,7 @@ export function MobileSidebar({
}}
onChatSelect={handleChatSelect}
onChatDelete={onChatDelete}
onChatArchive={onChatArchive}
onViewAllSharedChats={onViewAllSharedChats}
onViewAllPrivateChats={onViewAllPrivateChats}
user={user}

View file

@ -20,6 +20,8 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
const joyrideAttr =
item.title === "Documents" || item.title.toLowerCase().includes("documents")
? { "data-joyride": "documents-sidebar" }
: item.title === "Inbox" || item.title.toLowerCase().includes("inbox")
? { "data-joyride": "inbox-sidebar" }
: {};
if (isCollapsed) {
@ -32,8 +34,7 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
className={cn(
"relative flex h-10 w-10 items-center justify-center rounded-md transition-colors",
"hover:bg-accent hover:text-accent-foreground",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
item.isActive && "bg-accent text-accent-foreground"
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
)}
{...joyrideAttr}
>
@ -62,8 +63,7 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
className={cn(
"flex items-center gap-2 rounded-md mx-2 px-2 py-1.5 text-sm transition-colors text-left",
"hover:bg-accent hover:text-accent-foreground",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
item.isActive && "bg-accent text-accent-foreground"
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
)}
{...joyrideAttr}
>

View file

@ -1,6 +1,8 @@
"use client";
import { Mail } from "lucide-react";
import { Plus } from "lucide-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { Progress } from "@/components/ui/progress";
interface PageUsageDisplayProps {
@ -9,6 +11,8 @@ interface PageUsageDisplayProps {
}
export function PageUsageDisplay({ pagesUsed, pagesLimit }: PageUsageDisplayProps) {
const params = useParams();
const searchSpaceId = params.search_space_id;
const usagePercentage = (pagesUsed / pagesLimit) * 100;
return (
@ -21,13 +25,13 @@ export function PageUsageDisplay({ pagesUsed, pagesLimit }: PageUsageDisplayProp
<span className="font-medium">{usagePercentage.toFixed(0)}%</span>
</div>
<Progress value={usagePercentage} className="h-1.5" />
<a
href="mailto:rohan@surfsense.com?subject=Request%20to%20Increase%20Page%20Limits"
<Link
href={`/dashboard/${searchSpaceId}/more-pages`}
className="flex items-center gap-1.5 text-[10px] text-muted-foreground hover:text-primary transition-colors"
>
<Mail className="h-3 w-3 shrink-0" />
<span>Contact to increase limits</span>
</a>
<Plus className="h-3 w-3 shrink-0" />
<span>Get More Pages</span>
</Link>
</div>
</div>
);

View file

@ -27,6 +27,7 @@ interface SidebarProps {
onNewChat: () => void;
onChatSelect: (chat: ChatItem) => void;
onChatDelete?: (chat: ChatItem) => void;
onChatArchive?: (chat: ChatItem) => void;
onViewAllSharedChats?: () => void;
onViewAllPrivateChats?: () => void;
user: User;
@ -52,6 +53,7 @@ export function Sidebar({
onNewChat,
onChatSelect,
onChatDelete,
onChatArchive,
onViewAllSharedChats,
onViewAllPrivateChats,
user,
@ -175,7 +177,9 @@ export function Sidebar({
key={chat.id}
name={chat.name}
isActive={chat.id === activeChatId}
archived={chat.archived}
onClick={() => onChatSelect(chat)}
onArchive={() => onChatArchive?.(chat)}
onDelete={() => onChatDelete?.(chat)}
/>
))}
@ -216,7 +220,9 @@ export function Sidebar({
key={chat.id}
name={chat.name}
isActive={chat.id === activeChatId}
archived={chat.archived}
onClick={() => onChatSelect(chat)}
onArchive={() => onChatArchive?.(chat)}
onDelete={() => onChatDelete?.(chat)}
/>
))}

View file

@ -1,6 +1,6 @@
"use client";
import { ChevronsUpDown, ScrollText, Settings, Users } from "lucide-react";
import { ChevronsUpDown, Logs, Settings, Users } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
@ -57,7 +57,7 @@ export function SidebarHeader({
{t("manage_members")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push(`/dashboard/${searchSpaceId}/logs`)}>
<ScrollText className="mr-2 h-4 w-4" />
<Logs className="mr-2 h-4 w-4" />
{t("logs")}
</DropdownMenuItem>
<DropdownMenuSeparator />

View file

@ -1,6 +1,6 @@
"use client";
import { ChevronUp, Languages, Laptop, LogOut, Moon, Settings, Sun } from "lucide-react";
import { Check, ChevronUp, Languages, Laptop, LogOut, Moon, Settings, Sun } from "lucide-react";
import { useTranslations } from "next-intl";
import {
DropdownMenu,
@ -197,11 +197,12 @@ export function SidebarUserProfile({
className={cn(
"mb-1 last:mb-0 transition-all",
"hover:bg-accent/50",
isSelected && "bg-accent/80"
isSelected && "text-primary"
)}
>
<Icon className="mr-2 h-4 w-4" />
<span className="flex-1">{t(themeOption.value)}</span>
{isSelected && <Check className="h-4 w-4 shrink-0" />}
</DropdownMenuItem>
);
})}
@ -226,11 +227,12 @@ export function SidebarUserProfile({
className={cn(
"mb-1 last:mb-0 transition-all",
"hover:bg-accent/50",
isSelected && "bg-accent/80"
isSelected && "text-primary"
)}
>
<span className="mr-2">{language.flag}</span>
<span className="flex-1">{language.name}</span>
{isSelected && <Check className="h-4 w-4 shrink-0" />}
</DropdownMenuItem>
);
})}
@ -313,11 +315,12 @@ export function SidebarUserProfile({
className={cn(
"mb-1 last:mb-0 transition-all",
"hover:bg-accent/50",
isSelected && "bg-accent/80"
isSelected && "text-primary"
)}
>
<Icon className="mr-2 h-4 w-4" />
<span className="flex-1">{t(themeOption.value)}</span>
{isSelected && <Check className="h-4 w-4 shrink-0" />}
</DropdownMenuItem>
);
})}
@ -342,11 +345,12 @@ export function SidebarUserProfile({
className={cn(
"mb-1 last:mb-0 transition-all",
"hover:bg-accent/50",
isSelected && "bg-accent/80"
isSelected && "text-primary"
)}
>
<span className="mr-2">{language.flag}</span>
<span className="flex-1">{language.name}</span>
{isSelected && <Check className="h-4 w-4 shrink-0" />}
</DropdownMenuItem>
);
})}

View file

@ -8,7 +8,6 @@ import {
Cloud,
Edit3,
Globe,
Loader2,
Plus,
Settings2,
Sparkles,
@ -36,6 +35,7 @@ import {
CommandSeparator,
} from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Spinner } from "@/components/ui/spinner";
import type {
GlobalNewLLMConfig,
NewLLMConfigPublic,
@ -179,7 +179,7 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
>
{isLoading ? (
<>
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
<Spinner size="sm" className="text-muted-foreground" />
<span className="text-muted-foreground hidden md:inline">Loading</span>
</>
) : currentConfig ? (

View file

@ -1,17 +1,9 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import {
BookOpen,
ChevronDown,
ExternalLink,
FileText,
Hash,
Loader2,
Sparkles,
X,
} from "lucide-react";
import { BookOpen, ChevronDown, ExternalLink, FileText, Hash, Sparkles, X } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useTranslations } from "next-intl";
import type React from "react";
import { forwardRef, type ReactNode, useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
@ -20,6 +12,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Spinner } from "@/components/ui/spinner";
import type {
GetDocumentByChunkResponse,
GetSurfsenseDocsByChunkResponse,
@ -63,7 +56,7 @@ interface ChunkCardProps {
}
const ChunkCard = forwardRef<HTMLDivElement, ChunkCardProps>(
({ chunk, index, totalChunks, isCited, isActive, disableLayoutAnimation }, ref) => {
({ chunk, index, totalChunks, isCited }, ref) => {
return (
<div
ref={ref}
@ -122,12 +115,13 @@ export function SourceDetailPanel({
children,
isDocsChunk = false,
}: SourceDetailPanelProps) {
const t = useTranslations("dashboard");
const scrollAreaRef = useRef<HTMLDivElement>(null);
const hasScrolledRef = useRef(false); // Use ref to avoid stale closures
const [summaryOpen, setSummaryOpen] = useState(false);
const [activeChunkIndex, setActiveChunkIndex] = useState<number | null>(null);
const [mounted, setMounted] = useState(false);
const [hasScrolledToCited, setHasScrolledToCited] = useState(false);
const [_hasScrolledToCited, setHasScrolledToCited] = useState(false);
const shouldReduceMotion = useReducedMotion();
useEffect(() => {
@ -382,11 +376,10 @@ export function SourceDetailPanel({
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-4"
>
<div className="relative">
<div className="absolute inset-0 rounded-full bg-primary/20 blur-xl" />
<Loader2 className="h-12 w-12 animate-spin text-primary relative" />
</div>
<p className="text-sm text-muted-foreground font-medium">Loading document</p>
<Spinner size="lg" />
<p className="text-sm text-muted-foreground font-medium">
{t("loading_document")}
</p>
</motion.div>
</div>
)}

View file

@ -32,6 +32,12 @@ const TOUR_STEPS: TourStep[] = [
content: "Access and manage all your uploaded documents.",
placement: "right",
},
{
target: '[data-joyride="inbox-sidebar"]',
title: "Check your inbox",
content: "View mentions and notifications in one place.",
placement: "right",
},
];
interface TooltipPosition {
@ -188,14 +194,15 @@ function TourTooltip({
const getPointerStyles = (): React.CSSProperties => {
const lineLength = 16;
const dotSize = 6;
// Check if this is the documents step (stepIndex === 1)
// Check if this is the documents step (stepIndex === 1) or inbox step (stepIndex === 2)
const isDocumentsStep = stepIndex === 1;
const isInboxStep = stepIndex === 2;
if (position.pointerPosition === "left") {
return {
position: "absolute",
left: -lineLength - dotSize,
top: isDocumentsStep ? "calc(50% - 8px)" : "50%",
top: isDocumentsStep || isInboxStep ? "calc(50% - 8px)" : "50%",
transform: "translateY(-50%)",
display: "flex",
alignItems: "center",
@ -518,12 +525,13 @@ export function OnboardingTour() {
// User is new and hasn't seen tour - wait for DOM elements and start tour
const checkAndStartTour = () => {
// Check if both required elements exist
// Check if all required elements exist
const connectorEl = document.querySelector(TOUR_STEPS[0].target);
const documentsEl = document.querySelector(TOUR_STEPS[1].target);
const inboxEl = document.querySelector(TOUR_STEPS[2].target);
if (connectorEl && documentsEl) {
// Both elements found, start tour
if (connectorEl && documentsEl && inboxEl) {
// All elements found, start tour
setIsActive(true);
setTargetEl(connectorEl);
setSpotlightTargetEl(connectorEl);

View file

@ -1,8 +1,11 @@
"use client";
import { useAtomValue } from "jotai";
import { useTranslations } from "next-intl";
import { useEffect, useRef, useState } from "react";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
import { getBearerToken } from "@/lib/auth-utils";
import {
cleanupElectric,
type ElectricClient,
@ -27,6 +30,7 @@ interface ElectricProviderProps {
* 5. Provides client via context - hooks should use useElectricClient()
*/
export function ElectricProvider({ children }: ElectricProviderProps) {
const t = useTranslations("common");
const [electricClient, setElectricClient] = useState<ElectricClient | null>(null);
const [error, setError] = useState<Error | null>(null);
const {
@ -105,21 +109,25 @@ export function ElectricProvider({ children }: ElectricProviderProps) {
};
}, [user?.id, isUserLoaded, electricClient]);
// Check if user is authenticated first (has bearer token)
// This prevents showing loading screen for unauthenticated users on homepage
const hasToken = typeof window !== "undefined" && !!getBearerToken();
// Determine if we should show loading
const shouldShowLoading = hasToken && isUserLoaded && !!user?.id && !electricClient && !error;
// Use global loading hook with ownership tracking - prevents flash during transitions
useGlobalLoadingEffect(shouldShowLoading, t("initializing"), "default");
// For non-authenticated pages (like landing page), render immediately with null context
// Also render immediately if user query failed (e.g., token expired)
if (!isUserLoaded || !user?.id || isUserError) {
if (!hasToken || !isUserLoaded || !user?.id || isUserError) {
return <ElectricContext.Provider value={null}>{children}</ElectricContext.Provider>;
}
// Show loading state while initializing for authenticated users
// Return children with null context while initializing - the global provider handles the loading UI
if (!electricClient && !error) {
return (
<ElectricContext.Provider value={null}>
<div className="flex items-center justify-center min-h-screen">
<div className="text-muted-foreground">Initializing...</div>
</div>
</ElectricContext.Provider>
);
return <ElectricContext.Provider value={null}>{children}</ElectricContext.Provider>;
}
// If there's an error, still render but warn

View file

@ -0,0 +1,79 @@
"use client";
import { useAtomValue } from "jotai";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { AmbientBackground } from "@/app/(home)/login/AmbientBackground";
import { globalLoadingAtom } from "@/atoms/ui/loading.atoms";
import { Logo } from "@/components/Logo";
import { Spinner } from "@/components/ui/spinner";
import { cn } from "@/lib/utils";
/**
* GlobalLoadingProvider renders a persistent loading overlay.
* The spinner is ALWAYS in the DOM to prevent animation reset when
* loading states change between different pages/components.
*
* Visibility is controlled via CSS opacity/pointer-events, NOT mounting/unmounting.
*/
export function GlobalLoadingProvider({ children }: { children: React.ReactNode }) {
const [mounted, setMounted] = useState(false);
const { isLoading, message, variant } = useAtomValue(globalLoadingAtom);
useEffect(() => {
setMounted(true);
}, []);
// The overlay is ALWAYS rendered, but visibility is controlled by CSS
// This prevents the spinner animation from resetting
const loadingOverlay = (
<div
className={cn(
"fixed inset-0 z-[9999]",
isLoading
? "opacity-100 pointer-events-auto"
: "opacity-0 pointer-events-none transition-opacity duration-150"
)}
aria-hidden={!isLoading}
>
{variant === "login" ? (
<div className="relative w-full h-full overflow-hidden bg-background">
<AmbientBackground />
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
<Logo className="rounded-md" />
<div className="mt-8 flex flex-col items-center space-y-4">
<div className="h-12 w-12 flex items-center justify-center">
{/* Spinner is always mounted, animation never resets */}
<Spinner size="lg" className="text-muted-foreground" />
</div>
<span className="text-muted-foreground text-sm min-h-[1.25rem] text-center max-w-xs">
{message}
</span>
</div>
</div>
</div>
) : (
<div className="flex min-h-screen flex-col items-center justify-center bg-background">
<div className="flex flex-col items-center space-y-4">
<div className="h-12 w-12 flex items-center justify-center">
{/* Spinner is always mounted, animation never resets */}
<Spinner size="xl" className="text-primary" />
</div>
<span className="text-muted-foreground text-sm min-h-[1.25rem] text-center max-w-md px-4">
{message}
</span>
</div>
</div>
)}
</div>
);
// Render inline during SSR/before hydration, use portal after mounting
// This prevents the white flash during initial render
return (
<>
{children}
{mounted ? createPortal(loadingOverlay, document.body) : loadingOverlay}
</>
);
}

View file

@ -0,0 +1,197 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { Info, RotateCcw, Save } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { updateSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
interface GeneralSettingsManagerProps {
searchSpaceId: number;
}
export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManagerProps) {
const t = useTranslations("searchSpaceSettings");
const tCommon = useTranslations("common");
const {
data: searchSpace,
isLoading: loading,
refetch: fetchSearchSpace,
} = useQuery({
queryKey: cacheKeys.searchSpaces.detail(searchSpaceId.toString()),
queryFn: () => searchSpacesApiService.getSearchSpace({ id: searchSpaceId }),
enabled: !!searchSpaceId,
});
const { mutateAsync: updateSearchSpace } = useAtomValue(updateSearchSpaceMutationAtom);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [saving, setSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
// Initialize state from fetched search space
useEffect(() => {
if (searchSpace) {
setName(searchSpace.name || "");
setDescription(searchSpace.description || "");
setHasChanges(false);
}
}, [searchSpace]);
// Track changes
useEffect(() => {
if (searchSpace) {
const currentName = searchSpace.name || "";
const currentDescription = searchSpace.description || "";
const changed = currentName !== name || currentDescription !== description;
setHasChanges(changed);
}
}, [searchSpace, name, description]);
const handleSave = async () => {
try {
setSaving(true);
await updateSearchSpace({
id: searchSpaceId,
data: {
name: name.trim(),
description: description.trim() || undefined,
},
});
setHasChanges(false);
await fetchSearchSpace();
} catch (error: any) {
console.error("Error saving search space details:", error);
toast.error(error.message || "Failed to save search space details");
} finally {
setSaving(false);
}
};
const handleReset = () => {
if (searchSpace) {
setName(searchSpace.name || "");
setDescription(searchSpace.description || "");
setHasChanges(false);
}
};
if (loading) {
return (
<div className="space-y-4 md:space-y-6">
<Card>
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
<Skeleton className="h-5 md:h-6 w-36 md:w-48" />
<Skeleton className="h-3 md:h-4 w-full max-w-md mt-2" />
</CardHeader>
<CardContent className="space-y-3 md:space-y-4 px-3 md:px-6 pb-3 md:pb-6">
<Skeleton className="h-10 md:h-12 w-full" />
<Skeleton className="h-10 md:h-12 w-full" />
</CardContent>
</Card>
</div>
);
}
return (
<div className="space-y-4 md:space-y-6">
<Alert className="py-3 md:py-4">
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<AlertDescription className="text-xs md:text-sm">
Update your search space name and description. These details help identify and organize
your workspace.
</AlertDescription>
</Alert>
{/* Search Space Details Card */}
<Card>
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
<CardTitle className="text-base md:text-lg">Search Space Details</CardTitle>
<CardDescription className="text-xs md:text-sm">
Manage the basic information for this search space.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 md:space-y-5 px-3 md:px-6 pb-3 md:pb-6">
<div className="space-y-1.5 md:space-y-2">
<Label htmlFor="search-space-name" className="text-sm md:text-base font-medium">
{t("general_name_label")}
</Label>
<Input
id="search-space-name"
placeholder={t("general_name_placeholder")}
value={name}
onChange={(e) => setName(e.target.value)}
className="text-sm md:text-base h-9 md:h-10"
/>
<p className="text-[10px] md:text-xs text-muted-foreground">
{t("general_name_description")}
</p>
</div>
<div className="space-y-1.5 md:space-y-2">
<Label htmlFor="search-space-description" className="text-sm md:text-base font-medium">
{t("general_description_label")}{" "}
<span className="text-muted-foreground font-normal">({tCommon("optional")})</span>
</Label>
<Input
id="search-space-description"
placeholder={t("general_description_placeholder")}
value={description}
onChange={(e) => setDescription(e.target.value)}
className="text-sm md:text-base h-9 md:h-10"
/>
<p className="text-[10px] md:text-xs text-muted-foreground">
{t("general_description_description")}
</p>
</div>
</CardContent>
</Card>
{/* Action Buttons */}
<div className="flex items-center justify-between pt-3 md:pt-4 gap-2">
<Button
variant="outline"
onClick={handleReset}
disabled={!hasChanges || saving}
className="flex items-center gap-2 text-xs md:text-sm h-9 md:h-10"
>
<RotateCcw className="h-3.5 w-3.5 md:h-4 md:w-4" />
{t("general_reset")}
</Button>
<Button
onClick={handleSave}
disabled={!hasChanges || saving || !name.trim()}
className="flex items-center gap-2 text-xs md:text-sm h-9 md:h-10"
>
<Save className="h-3.5 w-3.5 md:h-4 md:w-4" />
{saving ? t("general_saving") : t("general_save")}
</Button>
</div>
{hasChanges && (
<Alert
variant="default"
className="bg-blue-50 dark:bg-blue-950/20 border-blue-200 dark:border-blue-800 py-3 md:py-4"
>
<Info className="h-3 w-3 md:h-4 md:w-4 text-blue-600 dark:text-blue-500 shrink-0" />
<AlertDescription className="text-blue-800 dark:text-blue-300 text-xs md:text-sm">
{t("general_unsaved_changes")}
</AlertDescription>
</Alert>
)}
</div>
);
}

View file

@ -1,16 +1,7 @@
"use client";
import { useAtomValue } from "jotai";
import {
AlertCircle,
Bot,
CheckCircle,
FileText,
Loader2,
RefreshCw,
RotateCcw,
Save,
} from "lucide-react";
import { AlertCircle, Bot, CheckCircle, FileText, RefreshCw, RotateCcw, Save } from "lucide-react";
import { motion } from "motion/react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
@ -32,6 +23,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Spinner } from "@/components/ui/spinner";
const ROLE_DESCRIPTIONS = {
agent: {
@ -206,7 +198,7 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
<Card>
<CardContent className="flex items-center justify-center py-8 md:py-12">
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="w-4 h-4 md:w-5 md:h-5 animate-spin" />
<Spinner size="sm" className="md:h-5 md:w-5" />
<span className="text-xs md:text-sm">
{configsLoading && preferencesLoading
? "Loading configurations and preferences..."

View file

@ -7,7 +7,6 @@ import {
Clock,
Edit3,
FileText,
Loader2,
MessageSquareQuote,
Plus,
RefreshCw,
@ -48,6 +47,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import type { NewLLMConfig } from "@/contracts/types/new-llm-config.types";
import { cn } from "@/lib/utils";
@ -211,7 +211,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
<Card>
<CardContent className="flex items-center justify-center py-10 md:py-16">
<div className="flex flex-col items-center gap-2 md:gap-3">
<Loader2 className="h-6 w-6 md:h-8 md:w-8 animate-spin text-muted-foreground" />
<Spinner size="md" className="md:h-8 md:w-8 text-muted-foreground" />
<span className="text-xs md:text-sm text-muted-foreground">
Loading configurations...
</span>
@ -484,7 +484,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
>
{isDeleting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Spinner size="sm" className="mr-2" />
Deleting
</>
) : (

View file

@ -8,7 +8,6 @@ import {
ChevronDown,
ChevronsUpDown,
Key,
Loader2,
MessageSquareQuote,
Rocket,
Sparkles,
@ -48,6 +47,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { getModelsByProvider } from "@/contracts/enums/llm-models";
@ -592,7 +592,7 @@ export function LLMConfigForm({
>
{isSubmitting ? (
<>
<Loader2 className="h-3.5 w-3.5 sm:h-4 sm:w-4 animate-spin" />
<Spinner size="sm" />
{mode === "edit" ? "Updating..." : "Creating"}
</>
) : (

View file

@ -1,7 +1,7 @@
"use client";
import { useAtom } from "jotai";
import { CheckCircle2, FileType, Info, Loader2, Tag, Upload, X } from "lucide-react";
import { CheckCircle2, FileType, Info, Tag, Upload, X } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useTranslations } from "next-intl";
import { useCallback, useMemo, useRef, useState } from "react";
@ -20,6 +20,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Separator } from "@/components/ui/separator";
import { Spinner } from "@/components/ui/spinner";
import {
trackDocumentUploadFailure,
trackDocumentUploadStarted,
@ -424,7 +425,7 @@ export function DocumentUploadTab({
>
{isUploading ? (
<span className="flex items-center gap-2">
<Loader2 className="h-4 w-4 sm:h-5 sm:w-5 animate-spin" />
<Spinner size="sm" />
{t("uploading")}
</span>
) : (

View file

@ -1,10 +1,11 @@
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
import { AlertCircleIcon, Loader2Icon, MicIcon } from "lucide-react";
import { AlertCircleIcon, MicIcon } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { z } from "zod";
import { Audio } from "@/components/tool-ui/audio";
import { Spinner } from "@/components/ui/spinner";
import { baseApiService } from "@/lib/apis/base-api.service";
import { authenticatedFetch } from "@/lib/auth-utils";
import { clearActivePodcastTaskId, setActivePodcastTaskId } from "@/lib/chat/podcast-state";
@ -97,8 +98,8 @@ function PodcastGeneratingState({ title }: { title: string }) {
<div className="flex-1">
<h3 className="font-semibold text-foreground text-lg">{title}</h3>
<div className="mt-2 flex items-center gap-2 text-muted-foreground">
<Loader2Icon className="size-4 animate-spin" />
<span className="text-sm">Generating podcast. This may take a few minutes</span>
<Spinner size="sm" />
<span className="text-sm">Generating podcast. This may take a few minutes.</span>
</div>
<div className="mt-3">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-primary/10">
@ -144,7 +145,7 @@ function AudioLoadingState({ title }: { title: string }) {
<div className="flex-1">
<h3 className="font-semibold text-foreground">{title}</h3>
<div className="mt-2 flex items-center gap-2 text-muted-foreground">
<Loader2Icon className="size-4 animate-spin" />
<Spinner size="sm" />
<span className="text-sm">Loading audio...</span>
</div>
</div>

View file

@ -1,11 +1,12 @@
"use client";
import { ExternalLinkIcon, ImageIcon, Loader2 } from "lucide-react";
import { ExternalLinkIcon, ImageIcon } from "lucide-react";
import NextImage from "next/image";
import { Component, type ReactNode, useState } from "react";
import { z } from "zod";
import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
import { Spinner } from "@/components/ui/spinner";
import { cn } from "@/lib/utils";
/**
@ -184,7 +185,7 @@ export function ImageLoading({ title = "Loading image..." }: { title?: string })
<Card className="w-full max-w-md overflow-hidden">
<div className="aspect-[4/3] bg-muted flex items-center justify-center">
<div className="flex flex-col items-center gap-3">
<Loader2 className="size-8 text-muted-foreground animate-spin" />
<Spinner size="lg" className="text-muted-foreground" />
<p className="text-muted-foreground text-sm">{title}</p>
</div>
</div>

View file

@ -1,12 +1,13 @@
"use client";
import { ExternalLinkIcon, Globe, ImageIcon, LinkIcon, Loader2 } from "lucide-react";
import { ExternalLinkIcon, Globe, ImageIcon, LinkIcon } from "lucide-react";
import Image from "next/image";
import { Component, type ReactNode } from "react";
import { z } from "zod";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
@ -299,18 +300,17 @@ export function MediaCard({
{/* Response Actions */}
{responseActions && responseActions.length > 0 && (
<div
className="mt-4 flex items-center justify-end gap-2 border-t pt-3"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<div className="mt-4 flex items-center justify-end gap-2 border-t pt-3">
{responseActions.map((action) => (
<Tooltip key={action.id}>
<TooltipTrigger asChild>
<Button
variant={action.variant || "secondary"}
size="sm"
onClick={() => onResponseAction?.(action.id)}
onClick={(e) => {
e.stopPropagation();
onResponseAction?.(action.id);
}}
>
{action.label}
</Button>
@ -337,7 +337,7 @@ export function MediaCardLoading({ title = "Loading preview..." }: { title?: str
return (
<Card className="w-full max-w-md overflow-hidden">
<div className="aspect-[2/1] bg-muted animate-pulse flex items-center justify-center">
<Loader2 className="size-8 text-muted-foreground animate-spin" />
<Spinner size="lg" className="text-muted-foreground" />
</div>
<CardContent className="p-4">
<div className="flex items-center gap-3">

View file

@ -2,7 +2,6 @@
import { makeAssistantToolUI, useAssistantState } from "@assistant-ui/react";
import { useAtomValue, useSetAtom } from "jotai";
import { Loader2 } from "lucide-react";
import { useEffect, useMemo } from "react";
import { z } from "zod";
import {
@ -11,6 +10,7 @@ import {
registerPlanOwner,
updatePlanStateAtom,
} from "@/atoms/chat/plan-state.atom";
import { Spinner } from "@/components/ui/spinner";
import { Plan, PlanErrorBoundary, parseSerializablePlan, TodoStatusSchema } from "./plan";
// ============================================================================
@ -46,7 +46,7 @@ function WriteTodosLoading() {
return (
<div className="my-4 w-full max-w-xl rounded-2xl border bg-card/60 px-5 py-4 shadow-sm">
<div className="flex items-center gap-3">
<Loader2 className="size-5 animate-spin text-primary" />
<Spinner size="md" className="text-primary" />
<span className="text-sm text-muted-foreground">Creating plan...</span>
</div>
</div>

View file

@ -7,12 +7,14 @@ import { cn } from "@/lib/utils";
function TooltipProvider({
delayDuration = 0,
disableHoverableContent = true,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
disableHoverableContent={disableHoverableContent}
{...props}
/>
);
@ -42,7 +44,7 @@ function TooltipContent({
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-black text-white font-medium shadow-xl px-3 py-1.5 dark:bg-zinc-800 dark:text-zinc-50 border-none animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md text-xs text-balance",
"bg-black text-white font-medium shadow-xl px-3 py-1.5 dark:bg-zinc-800 dark:text-zinc-50 border-none animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md text-xs text-balance pointer-events-none",
className
)}
{...props}

View file

@ -36,20 +36,20 @@ After registration, configure the required scopes (permissions) for your integra
| Scope | Description |
|-------|-------------|
| `data.recordComments:read` | See comments in records |
| `data.records:read` | See the data in records |
| `data.recordComments:read` | See comments in records |
| `data.records:read` | See the data in records |
### Base schema
| Scope | Description |
|-------|-------------|
| `schema.bases:read` | See the structure of a base, like table names or field types |
| `schema.bases:read` | See the structure of a base, like table names or field types |
### User metadata
| Scope | Description |
|-------|-------------|
| `user.email:read` | See the user's email address |
| `user.email:read` | See the user's email address |
![Scopes Configuration](/docs/connectors/airtable/airtable-scopes.png)
@ -80,7 +80,9 @@ After saving, you'll find your OAuth credentials on the integration page:
1. Copy your **Client ID**
2. Copy your **Client Secret**
> ⚠️ Never share your client secret publicly.
<Callout type="warn">
Never share your client secret publicly.
</Callout>
---

View file

@ -3,4 +3,70 @@ title: Bookstack
description: Connect your Bookstack instance to SurfSense
---
# Documentation in progress
# BookStack Integration Setup Guide
This guide walks you through connecting your BookStack instance to SurfSense.
## How it works
The BookStack connector uses the BookStack REST API to fetch all pages from your BookStack instance that your account has access to.
- For follow up indexing runs, the connector retrieves pages that have been updated since the last indexing attempt.
- Indexing should be configured to run periodically, so updates should appear in your search results within minutes.
---
## Authorization
<Callout type="info" title="API Token Required">
You need to create an API token from your BookStack instance. The token requires "Access System API" permission.
</Callout>
### Step 1: Create an API Token
1. Log in to your BookStack instance
2. Click on your profile icon → Edit Profile
3. Navigate to the "API Tokens" tab
4. Click "Create Token" and give it a name
5. Copy both the **Token ID** and **Token Secret**
6. Paste them in the connector form
### Step 2: Grant necessary access
Your user account must have "Access System API" permission. The connector will only index content your account can view.
<Callout type="info" title="Rate Limiting">
BookStack API has a rate limit of 180 requests per minute. The connector automatically handles rate limiting to ensure reliable indexing.
</Callout>
<Callout type="info" title="Periodic Sync">
Enable periodic sync to automatically re-index pages when content changes. Available frequencies: Every 5 minutes, 15 minutes, hourly, every 6 hours, daily, or weekly.
</Callout>
---
## Connecting to SurfSense
1. Navigate to the Connector Dashboard and select the **BookStack** Connector.
2. Fill in the required fields:
| Field | Description | Example |
|-------|-------------|---------|
| **Connector Name** | A friendly name to identify this connector | `My BookStack Connector` |
| **BookStack Base URL** | The full URL of your BookStack instance | `https://docs.example.com` |
| **Token ID** | Your API Token ID from BookStack | |
| **Token Secret** | Your API Token Secret from BookStack | |
3. Click **Connect** to establish the connection.
4. Once connected, your BookStack pages will be indexed automatically.
### What Gets Indexed
The BookStack connector indexes the following data:
| Data Type | Description |
|-----------|-------------|
| Pages | All pages from your BookStack instance |
| Content | Page content in Markdown format |
| Metadata | Page titles and metadata |
| Structure | Book and chapter hierarchy information |

View file

@ -1,8 +1,133 @@
---
title: Circleback
description: Connect your circleback to SurfSense
description: Connect your Circleback meetings to SurfSense
---
# Documentation in progress
# Circleback Integration Setup Guide
This guide walks you through connecting your Circleback meetings to SurfSense for meeting search and AI-powered insights.
## How it works
The Circleback connector uses a **webhook-based integration**. Unlike other connectors that pull data from APIs, Circleback automatically pushes meeting data to SurfSense when meetings are processed.
- Meetings are sent to SurfSense immediately after Circleback processes them
- No API keys or credentials required
- No periodic indexing needed - data arrives in real-time
- Each meeting is stored with its notes, transcript, action items, and insights
---
## What Gets Indexed
| Content Type | Description |
|--------------|-------------|
| Meeting Notes | AI-generated meeting notes in Markdown format |
| Transcript | Full meeting transcript with speaker identification and timestamps |
| Action Items | Tasks with assignees and status |
| Attendees | Names and emails of meeting participants |
| Insights | Custom insights extracted from the meeting |
| Metadata | Date, duration, tags, meeting URL, and Circleback link |
<Callout type="info">
Meeting recordings are not stored in SurfSense, but a link to the recording (valid for 24 hours from Circleback) is included in the indexed document.
</Callout>
---
## Setup Guide
### Step 1: Create the Connector in SurfSense
1. Navigate to **Connectors** → **Add Connector** → **Circleback**
2. Enter a connector name (e.g., `My Circleback Meetings`)
3. Click **Connect** to create the connector
<Callout type="info" title="No Credentials Needed">
Circleback uses webhooks, so no API key or authentication is required. The webhook URL is unique to your search space.
</Callout>
### Step 2: Copy Your Webhook URL
After creating the connector:
1. Open the connector settings
2. Find the **Webhook URL** field
3. Click **Copy** to copy the URL to your clipboard
The webhook URL looks like:
```
https://your-surfsense-url/api/v1/webhooks/circleback/{search_space_id}
```
### Step 3: Configure Circleback Automation
1. Log into your [Circleback account](https://app.circleback.ai)
2. Go to **Automations** (in the main navigation)
3. Click **Create automation**
4. Add conditions to filter which meetings trigger the webhook (optional):
- Specific calendar(s)
- Meeting tags
- Meeting participants
5. Select **Send webhook request** as the action
6. Enter your SurfSense webhook URL
7. Select the meeting outcomes to include (notes, action items, transcript, insights)
8. Optionally test with your most recent meeting
9. Name and save the automation
For detailed instructions, see [Circleback's official webhook documentation](https://circleback.ai/docs/webhook-integration).
<Callout type="warn">
Make sure your SurfSense backend is accessible from the internet for Circleback to send webhooks. If self-hosting, you may need to configure a reverse proxy or use a tunneling service.
</Callout>
---
## Connector Configuration
| Field | Description | Required |
|-------|-------------|----------|
| **Connector Name** | A friendly name to identify this connector | Yes |
<Callout type="info">
Unlike other connectors, Circleback doesn't support periodic indexing since it's webhook-based. Meetings are automatically pushed to SurfSense when processed by Circleback.
</Callout>
---
## Verifying the Integration
Once configured, new meetings will automatically appear in SurfSense after Circleback processes them. To verify:
1. Attend or process a meeting with Circleback
2. Wait for Circleback to complete processing (usually a few minutes after the meeting ends)
3. Check your SurfSense search space for the new meeting document
Each meeting document includes:
- A direct link to view the meeting on Circleback
- Full searchable transcript
- Extracted action items with assignees
- Meeting metadata and insights
---
## Troubleshooting
**Meetings not appearing**
- Verify the webhook URL is correctly configured in Circleback
- Check that your SurfSense backend is accessible from the internet
- Look for errors in your SurfSense backend logs
**Webhook URL not showing**
- Refresh the connector configuration page
- Ensure the connector was created successfully
- Check that your backend is running
**Duplicate meetings**
- Each meeting is uniquely identified by its Circleback meeting ID
- If content changes, the existing document is updated rather than duplicated
**Delayed indexing**
- Meeting data is processed asynchronously
- Documents should appear within a minute of receiving the webhook
- Check the task queue if experiencing longer delays

View file

@ -36,7 +36,9 @@ After creating the app, you'll see your credentials:
1. Copy your **Client ID**
2. Copy your **Client Secret** (click "Show" to reveal, or "Regenerate" if needed)
> ⚠️ Never share your client secret publicly.
<Callout type="warn">
Never share your client secret publicly.
</Callout>
---

View file

@ -28,7 +28,9 @@ This guide walks you through setting up an Atlassian OAuth 2.0 (3LO) integration
2. Check the box to agree to Atlassian's developer terms
3. Click **"Create"**
> New OAuth 2.0 integrations use rotating refresh tokens, which improve security by limiting token validity and enabling automatic detection of token reuse.
<Callout type="info">
New OAuth 2.0 integrations use rotating refresh tokens, which improve security by limiting token validity and enabling automatic detection of token reuse.
</Callout>
![Create New Integration Form](/docs/connectors/atlassian/atlassian-name-integration.png)
@ -41,7 +43,9 @@ This guide walks you through setting up an Atlassian OAuth 2.0 (3LO) integration
```
3. Click **"Save changes"**
> You can enter up to 10 redirect URIs, one per line.
<Callout type="info">
You can enter up to 10 redirect URIs, one per line.
</Callout>
![Authorization Callback URLs](/docs/connectors/atlassian/atlassian-authorization.png)
@ -62,7 +66,7 @@ Select the **"Classic scopes"** tab and enable:
| Scope Name | Code | Description |
|------------|------|-------------|
| Read user | `read:confluence-user` | View user information in Confluence that you have access to, including usernames, email addresses and profile pictures |
| Read user | `read:confluence-user` | View user information in Confluence that you have access to, including usernames, email addresses and profile pictures |
![Confluence API Classic Scopes](/docs/connectors/atlassian/confluence/atlassian-confluence-classic-scopes.png)
@ -72,9 +76,9 @@ Select the **"Granular scopes"** tab and enable:
| Scope Name | Code | Description |
|------------|------|-------------|
| View pages | `read:page:confluence` | View page content |
| View comments | `read:comment:confluence` | View comments on pages or blogposts |
| View spaces | `read:space:confluence` | View space details |
| View pages | `read:page:confluence` | View page content |
| View comments | `read:comment:confluence` | View comments on pages or blogposts |
| View spaces | `read:space:confluence` | View space details |
4. Click **"Save"**
@ -85,7 +89,9 @@ Select the **"Granular scopes"** tab and enable:
1. In the left sidebar, click **"Settings"**
2. Copy your **Client ID** and **Client Secret**
> ⚠️ Never share your client secret publicly.
<Callout type="warn">
Never share your client secret publicly.
</Callout>
---

View file

@ -38,7 +38,9 @@ You'll also see your **Application ID** and **Public Key** on this page.
http://localhost:8000/api/v1/auth/discord/connector/callback
```
> ⚠️ Keep **Public Client** disabled (off) since SurfSense uses a server to make requests.
<Callout type="warn">
Keep **Public Client** disabled (off) since SurfSense uses a server to make requests.
</Callout>
![OAuth2 Configuration](/docs/connectors/discord/discord-oauth2.png)
@ -46,13 +48,15 @@ You'll also see your **Application ID** and **Public Key** on this page.
1. In the left sidebar, click **"Bot"**
2. Configure the **Authorization Flow**:
- **Public Bot** - Enable to allow anyone to add the bot to servers
- **Public Bot** - Enable to allow anyone to add the bot to servers
3. Enable **Privileged Gateway Intents**:
- **Server Members Intent** - Required to receive GUILD_MEMBERS events
- **Message Content Intent** - Required to receive message content
- **Server Members Intent** - Required to receive GUILD_MEMBERS events
- **Message Content Intent** - Required to receive message content
> ⚠️ Once your bot reaches 100+ servers, these intents will require verification and approval.
<Callout type="warn">
Once your bot reaches 100+ servers, these intents will require verification and approval.
</Callout>
![Bot Settings](/docs/connectors/discord/discord-bot-settings.png)

View file

@ -3,4 +3,115 @@ title: Elasticsearch
description: Connect your Elasticsearch cluster to SurfSense
---
# Documentation in progress
# Elasticsearch Integration Setup Guide
This guide walks you through connecting your Elasticsearch cluster to SurfSense.
## How it works
The Elasticsearch connector allows you to search and retrieve documents from your Elasticsearch cluster. Configure connection details, select specific indices, and set search parameters to make your existing data searchable within SurfSense.
- For follow-up indexing runs, the connector retrieves documents that have been updated since the last indexing attempt.
- Indexing should be configured to run periodically, so updates should appear in your search results within minutes.
---
## Authorization
<Callout type="info" title="Authentication Required">
Elasticsearch requires authentication. You can use either an API key or username/password authentication.
</Callout>
### Step 1: Get Your Elasticsearch Endpoint
You'll need the endpoint URL for your Elasticsearch cluster. This typically looks like:
- **Cloud:** `https://your-cluster.es.region.aws.com:443`
- **Self-hosted:** `https://elasticsearch.example.com:9200`
### Step 2: Configure Authentication
Elasticsearch requires authentication. You can use either:
**API Key:** A base64-encoded API key. You can create one in Elasticsearch by running:
```bash
POST /_security/api_key
```
**Username & Password:** Basic authentication using your Elasticsearch username and password.
### Step 3: Select Indices
Specify which indices to search. You can:
- Use wildcards: `logs-*` to match multiple indices
- List specific indices: `logs-2024, documents-2024`
- Leave empty to search all accessible indices (not recommended for performance)
<Callout type="info" title="Periodic Sync">
Enable periodic sync to automatically re-index documents when content changes. Available frequencies: Every 5 minutes, 15 minutes, hourly, every 6 hours, daily, or weekly.
</Callout>
---
## Connecting to SurfSense
1. Navigate to the Connector Dashboard and select the **Elasticsearch** Connector.
2. Fill in the required fields:
| Field | Description | Example |
|-------|-------------|---------|
| **Connector Name** | A friendly name to identify this connector | `My Elasticsearch Connector` |
| **Elasticsearch Endpoint URL** | The full URL of your Elasticsearch cluster | `https://your-cluster.es.region.aws.com:443` |
| **API Key** | Your base64-encoded API key (if using API key auth) | |
| **Username** | Your Elasticsearch username (if using basic auth) | |
| **Password** | Your Elasticsearch password (if using basic auth) | |
| **Indices** | Comma-separated list of indices to search | `logs-*, documents-2024` |
3. Click **Connect** to establish the connection.
4. Once connected, your Elasticsearch documents will be indexed automatically.
---
## Advanced Configuration
### Search Query
The default query used for searches. Use `*` to match all documents, or specify a more complex Elasticsearch query.
### Search Fields
Limit searches to specific fields for better performance. Common fields include:
- `title` - Document titles
- `content` - Main content
- `description` - Descriptions
Leave empty to search all fields in your documents.
### Maximum Documents
Set a limit on the number of documents retrieved per search (1-10,000). This helps control response times and resource usage. Leave empty to use Elasticsearch's default limit.
---
## Troubleshooting
### Connection Issues
- **Invalid URL:** Ensure your endpoint URL includes the protocol (https://) and port number if required.
- **SSL/TLS Errors:** Verify that your cluster uses HTTPS and the certificate is valid. Self-signed certificates may require additional configuration.
- **Connection Timeout:** Check your network connectivity and firewall settings. Ensure the Elasticsearch cluster is accessible from SurfSense servers.
### Authentication Issues
- **Invalid Credentials:** Double-check your username/password or API key. API keys must be base64-encoded.
- **Permission Denied:** Ensure your API key or user account has read permissions for the indices you want to search.
- **API Key Format:** Elasticsearch API keys are typically base64-encoded strings. Make sure you're using the full key value.
### Search Issues
- **No Results:** Verify that your index selection matches existing indices. Use wildcards carefully.
- **Slow Searches:** Limit the number of indices or use specific index names instead of wildcards. Reduce the maximum documents limit.
- **Field Not Found:** Ensure the search fields you specify actually exist in your Elasticsearch documents.

View file

@ -3,9 +3,18 @@ title: GitHub
description: Connect your GitHub repositories to SurfSense
---
# GitHub Connector
# GitHub Integration Setup Guide
Connect your GitHub repositories to SurfSense for code search and AI-powered insights. The connector uses [gitingest](https://gitingest.com) to efficiently index entire codebases.
This guide walks you through connecting your GitHub repositories to SurfSense for code search and AI-powered insights.
## How it works
The GitHub connector uses [gitingest](https://gitingest.com) to fetch and process repository contents from GitHub.
- For follow-up indexing runs, the connector retrieves the latest repository state and updates changed files.
- Indexing should be configured to run periodically, so updates should appear in your search results within minutes.
---
## What Gets Indexed
@ -15,7 +24,9 @@ Connect your GitHub repositories to SurfSense for code search and AI-powered ins
| Documentation | README files, Markdown documents, text files |
| Configuration | JSON, YAML, TOML, .env examples, Dockerfiles |
> ⚠️ Binary files and files larger than 5MB are automatically excluded.
<Callout type="warn">
Binary files and files larger than 5MB are automatically excluded.
</Callout>
---
@ -39,7 +50,13 @@ For private repos, you need a GitHub Personal Access Token (PAT).
2. Set an expiration
3. Click **Generate token** and copy it
> ⚠️ The token starts with `ghp_`. Store it securely.
<Callout type="warn">
The token starts with `ghp_`. Store it securely.
</Callout>
<Callout type="info" title="Periodic Sync">
Enable periodic sync to automatically re-index repositories when content changes. Available frequencies: Every 5 minutes, 15 minutes, hourly, every 6 hours, daily, or weekly.
</Callout>
---
@ -53,21 +70,6 @@ For private repos, you need a GitHub Personal Access Token (PAT).
---
## Periodic Sync
Enable periodic sync to automatically re-index repositories when content changes:
| Frequency | Use Case |
|-----------|----------|
| Every 5 minutes | Active development |
| Every 15 minutes | Frequent commits |
| Every hour | Regular workflow |
| Every 6 hours | Less active repos |
| Daily | Reference repositories |
| Weekly | Stable codebases |
---
## Troubleshooting
**Repository not found**

View file

@ -60,7 +60,9 @@ This guide walks you through setting up a Google OAuth 2.0 integration for SurfS
1. After creating the OAuth client, you'll see a dialog with your credentials
2. Copy your **Client ID** and **Client Secret**
> ⚠️ Never share your client secret publicly.
<Callout type="warn">
Never share your client secret publicly.
</Callout>
![Google Developer Console Config](/docs/connectors/google/google_oauth_config.png)

View file

@ -59,7 +59,9 @@ This guide walks you through setting up a Google OAuth 2.0 integration for SurfS
1. After creating the OAuth client, you'll see a dialog with your credentials
2. Copy your **Client ID** and **Client Secret**
> ⚠️ Never share your client secret publicly.
<Callout type="warn">
Never share your client secret publicly.
</Callout>
![Google Developer Console Config](/docs/connectors/google/google_oauth_config.png)

View file

@ -60,7 +60,9 @@ This guide walks you through setting up a Google OAuth 2.0 integration for SurfS
1. After creating the OAuth client, you'll see a dialog with your credentials
2. Copy your **Client ID** and **Client Secret**
> ⚠️ Never share your client secret publicly.
<Callout type="warn">
Never share your client secret publicly.
</Callout>
![Google Developer Console Config](/docs/connectors/google/google_oauth_config.png)

View file

@ -28,7 +28,9 @@ This guide walks you through setting up an Atlassian OAuth 2.0 (3LO) integration
2. Check the box to agree to Atlassian's developer terms
3. Click **"Create"**
> New OAuth 2.0 integrations use rotating refresh tokens, which improve security by limiting token validity and enabling automatic detection of token reuse.
<Callout type="info">
New OAuth 2.0 integrations use rotating refresh tokens, which improve security by limiting token validity and enabling automatic detection of token reuse.
</Callout>
![Create New Integration Form](/docs/connectors/atlassian/atlassian-name-integration.png)
@ -41,7 +43,9 @@ This guide walks you through setting up an Atlassian OAuth 2.0 (3LO) integration
```
3. Click **"Save changes"**
> You can enter up to 10 redirect URIs, one per line.
<Callout type="info">
You can enter up to 10 redirect URIs, one per line.
</Callout>
![Authorization Callback URLs](/docs/connectors/atlassian/atlassian-authorization.png)
@ -60,8 +64,8 @@ This guide walks you through setting up an Atlassian OAuth 2.0 (3LO) integration
| Scope Name | Code | Description |
|------------|------|-------------|
| View Jira issue data | `read:jira-work` | Read Jira project and issue data, search for issues, and objects associated with issues like attachments and worklogs |
| View user profiles | `read:jira-user` | View user information in Jira that the user has access to, including usernames, email addresses, and avatars |
| View Jira issue data | `read:jira-work` | Read Jira project and issue data, search for issues, and objects associated with issues like attachments and worklogs |
| View user profiles | `read:jira-user` | View user information in Jira that the user has access to, including usernames, email addresses, and avatars |
4. Click **"Save"**
@ -72,7 +76,9 @@ This guide walks you through setting up an Atlassian OAuth 2.0 (3LO) integration
1. In the left sidebar, click **"Settings"**
2. Copy your **Client ID** and **Client Secret**
> ⚠️ Never share your client secret publicly.
<Callout type="warn">
Never share your client secret publicly.
</Callout>
---

View file

@ -30,7 +30,7 @@ Fill in the application details:
### Settings
- **Public** - Enable this to allow the application to be installed by other workspaces
- **Public** - Enable this to allow the application to be installed by other workspaces
Click **Create** to create the application.
@ -43,7 +43,9 @@ After creating the application, you'll see your OAuth credentials:
1. Copy your **Client ID**
2. Copy your **Client Secret**
> ⚠️ Never share your client secret publicly.
<Callout type="warn">
Never share your client secret publicly.
</Callout>
![OAuth Credentials](/docs/connectors/linear/linear-oauth-credentials.png)

View file

@ -3,4 +3,68 @@ title: Luma
description: Connect your Luma events to SurfSense
---
# Documentation in progress
# Luma Integration Setup Guide
This guide walks you through connecting your Luma events to SurfSense for event search and AI-powered insights.
## How it works
The Luma connector uses the Luma API to fetch all events that your API key has access to.
- For follow-up indexing runs, the connector retrieves events that have been updated since the last indexing attempt.
- Indexing should be configured to run periodically, so updates should appear in your search results within minutes.
---
## Authorization
<Callout type="info" title="API Key Required">
You need a Luma API key to use this connector. The key will be used to read your Luma events with read-only permissions.
</Callout>
### Step 1: Get Your API Key
1. Log into your Luma account
2. Navigate to your account settings
3. Go to API settings or Developer settings
4. Generate a new API key
5. Copy the generated API key
You can also visit [Luma API Docs](https://docs.luma.com/reference/getting-started-with-your-api) for more information.
### Step 2: Grant Necessary Access
The API key will have access to all events that your user account can see. Make sure your account has appropriate permissions for the events you want to index.
<Callout type="info" title="Periodic Sync">
Enable periodic sync to automatically re-index events when content changes. Available frequencies: Every 5 minutes, 15 minutes, hourly, every 6 hours, daily, or weekly.
</Callout>
---
## Connecting to SurfSense
1. Navigate to the Connector Dashboard and select the **Luma** Connector.
2. Fill in the required fields:
| Field | Description | Example |
|-------|-------------|---------|
| **Connector Name** | A friendly name to identify this connector | `My Luma Connector` |
| **Luma API Key** | Your Luma API key (will be encrypted and stored securely) | |
3. Click **Connect** to establish the connection.
4. Once connected, your Luma events will be indexed automatically.
### What Gets Indexed
The Luma connector indexes the following data:
| Data Type | Description |
|-----------|-------------|
| Event Details | Titles, descriptions, metadata |
| Attendee Info | Attendee information and lists |
| Event Metadata | Dates, locations, settings |
<Callout type="warn">
Event attachments and linked files are not indexed by this connector.
</Callout>

View file

@ -17,7 +17,8 @@
"luma",
"circleback",
"elasticsearch",
"bookstack"
"bookstack",
"obsidian"
],
"defaultOpen": false
}

View file

@ -58,7 +58,9 @@ After registration, you'll be taken to the app's **Overview** page. Here you'll
![Certificates & Secrets - Created](/docs/connectors/microsoft-teams/azure-certificates-created.png)
> ⚠️ Never share your client secret publicly or include it in code repositories.
<Callout type="warn">
Never share your client secret publicly or include it in code repositories.
</Callout>
## Step 6: Configure API Permissions
@ -78,7 +80,9 @@ After registration, you'll be taken to the app's **Overview** page. Here you'll
6. Click **"Add permissions"**
> ⚠️ The `ChannelMessage.Read.All` permission requires admin consent. An admin will need to click **"Grant admin consent for [Directory]"** for full functionality.
<Callout type="warn">
The `ChannelMessage.Read.All` permission requires admin consent. An admin will need to click **"Grant admin consent for [Directory]"** for full functionality.
</Callout>
![API Permissions](/docs/connectors/microsoft-teams/azure-api-permissions.png)

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