diff --git a/backend/danswer/server/admin.py b/backend/danswer/server/admin.py
index 3ec3c7b38858..91f43322031b 100644
--- a/backend/danswer/server/admin.py
+++ b/backend/danswer/server/admin.py
@@ -13,6 +13,7 @@ from danswer.db.models import IndexingStatus
from danswer.dynamic_configs.interface import ConfigNotFoundError
from danswer.utils.logging import setup_logger
from fastapi import APIRouter
+from fastapi import HTTPException
from pydantic import BaseModel
router = APIRouter(prefix="/admin")
@@ -20,7 +21,7 @@ router = APIRouter(prefix="/admin")
logger = setup_logger()
-@router.get("/slack_connector_config", response_model=SlackConfig)
+@router.get("/connectors/slack/config", response_model=SlackConfig)
def fetch_slack_config():
try:
return get_slack_config()
@@ -28,7 +29,12 @@ def fetch_slack_config():
return SlackConfig(slack_bot_token="", workspace_id="")
-@router.post("/slack_connector_config")
+@router.post("/connectors/slack/config")
+def modify_slack_config(slack_config: SlackConfig):
+ update_slack_config(slack_config)
+
+
+@router.post("/connectors/slack/auth")
def modify_slack_config(slack_config: SlackConfig):
update_slack_config(slack_config)
@@ -37,7 +43,7 @@ class WebIndexAttemptRequest(BaseModel):
url: str
-@router.post("/website_index", status_code=201)
+@router.post("/connectors/web/index-attempt", status_code=201)
async def index_website(web_index_attempt_request: WebIndexAttemptRequest):
index_request = IndexAttempt(
source=DocumentSource.WEB,
@@ -52,13 +58,15 @@ class IndexAttemptSnapshot(BaseModel):
url: str
status: IndexingStatus
time_created: datetime
+ time_updated: datetime
+ docs_indexed: int
class ListWebsiteIndexAttemptsResponse(BaseModel):
index_attempts: list[IndexAttemptSnapshot]
-@router.get("/website_index")
+@router.get("/connectors/web/index-attempt")
async def list_website_index_attempts() -> ListWebsiteIndexAttemptsResponse:
index_attempts = await fetch_index_attempts(sources=[DocumentSource.WEB])
return ListWebsiteIndexAttemptsResponse(
@@ -67,6 +75,10 @@ async def list_website_index_attempts() -> ListWebsiteIndexAttemptsResponse:
url=index_attempt.connector_specific_config["url"],
status=index_attempt.status,
time_created=index_attempt.time_created,
+ time_updated=index_attempt.time_updated,
+ docs_indexed=0
+ if not index_attempt.document_ids
+ else len(index_attempt.document_ids),
)
for index_attempt in index_attempts
]
diff --git a/web/package-lock.json b/web/package-lock.json
index 8352d6b96dfb..525340ba5d65 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -20,6 +20,7 @@
"postcss": "^8.4.23",
"react": "18.2.0",
"react-dom": "18.2.0",
+ "swr": "^2.1.5",
"tailwindcss": "^3.3.1",
"typescript": "5.0.3",
"yup": "^1.1.1"
@@ -3688,6 +3689,17 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/swr": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/swr/-/swr-2.1.5.tgz",
+ "integrity": "sha512-/OhfZMcEpuz77KavXST5q6XE9nrOBOVcBLWjMT+oAE/kQHyE3PASrevXCtQDZ8aamntOfFkbVJp7Il9tNBQWrw==",
+ "dependencies": {
+ "use-sync-external-store": "^1.2.0"
+ },
+ "peerDependencies": {
+ "react": "^16.11.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
"node_modules/synckit": {
"version": "0.8.5",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.5.tgz",
@@ -3977,6 +3989,14 @@
"punycode": "^2.1.0"
}
},
+ "node_modules/use-sync-external-store": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz",
+ "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
diff --git a/web/package.json b/web/package.json
index 91172cca5f88..699dfed0390e 100644
--- a/web/package.json
+++ b/web/package.json
@@ -21,6 +21,7 @@
"postcss": "^8.4.23",
"react": "18.2.0",
"react-dom": "18.2.0",
+ "swr": "^2.1.5",
"tailwindcss": "^3.3.1",
"typescript": "5.0.3",
"yup": "^1.1.1"
diff --git a/web/src/app/admin/connectors/page.tsx b/web/src/app/admin/connectors/page.tsx
deleted file mode 100644
index 4ee405f16f4c..000000000000
--- a/web/src/app/admin/connectors/page.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-"use client";
-
-import { Inter } from "next/font/google";
-import { Header } from "@/components/Header";
-import { SlackForm } from "@/components/admin/connectors/SlackForm";
-
-const inter = Inter({ subsets: ["latin"] });
-
-export default function Home() {
- return (
- <>
-
-
-
-
Slack
-
-
Config
-
console.log(success)} />
-
- >
- );
-}
diff --git a/web/src/app/admin/connectors/slack/SlackForm.tsx b/web/src/app/admin/connectors/slack/SlackForm.tsx
new file mode 100644
index 000000000000..2b1b1adff678
--- /dev/null
+++ b/web/src/app/admin/connectors/slack/SlackForm.tsx
@@ -0,0 +1,95 @@
+import React, { useState } from "react";
+import { Formik, Form, FormikHelpers } from "formik";
+import * as Yup from "yup";
+import { Popup } from "../../../../components/admin/connectors/Popup";
+import { TextFormField } from "../../../../components/admin/connectors/Field";
+import { SlackConfig } from "./interfaces";
+
+const validationSchema = Yup.object().shape({
+ slack_bot_token: Yup.string().required("Please enter your Slack Bot Token"),
+ workspace_id: Yup.string().required("Please enter your Workspace ID"),
+ pull_frequency: Yup.number().required(
+ "Please enter a pull frequency (in minutes). 0 => no pulling from slack"
+ ),
+});
+
+const handleSubmit = async (
+ values: SlackConfig,
+ { setSubmitting }: FormikHelpers,
+ setPopup: (
+ popup: { message: string; type: "success" | "error" } | null
+ ) => void
+) => {
+ let isSuccess = false;
+ setSubmitting(true);
+ try {
+ const response = await fetch("/api/admin/connectors/slack/config", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(values),
+ });
+
+ if (response.ok) {
+ isSuccess = true;
+ setPopup({ message: "Success!", type: "success" });
+ } else {
+ const errorData = await response.json();
+ setPopup({ message: `Error: ${errorData.detail}`, type: "error" });
+ }
+ } catch (error) {
+ setPopup({ message: `Error: ${error}`, type: "error" });
+ } finally {
+ setSubmitting(false);
+ setTimeout(() => {
+ setPopup(null);
+ }, 3000);
+ }
+ return isSuccess;
+};
+
+interface SlackFormProps {
+ existingSlackConfig: SlackConfig;
+ onSubmit: (isSuccess: boolean) => void;
+}
+
+export const SlackForm: React.FC = ({
+ existingSlackConfig,
+ onSubmit,
+}) => {
+ const [popup, setPopup] = useState<{
+ message: string;
+ type: "success" | "error";
+ } | null>(null);
+
+ return (
+ <>
+ {popup && }
+
+ handleSubmit(values, formikHelpers, setPopup).then((isSuccess) =>
+ onSubmit(isSuccess)
+ )
+ }
+ >
+ {({ isSubmitting }) => (
+
+ )}
+
+ >
+ );
+};
diff --git a/web/src/app/admin/connectors/slack/interfaces.ts b/web/src/app/admin/connectors/slack/interfaces.ts
new file mode 100644
index 000000000000..daa89952dd0d
--- /dev/null
+++ b/web/src/app/admin/connectors/slack/interfaces.ts
@@ -0,0 +1,11 @@
+export interface SlackConfig {
+ slack_bot_token: string;
+ workspace_id: string;
+ pull_frequency: number;
+}
+
+// interface SlackIndexAttempt {}
+
+// interface ListSlackIndexingResponse {
+// index_attempts: SlackIndexAttempt[];
+// }
diff --git a/web/src/app/admin/connectors/slack/page.tsx b/web/src/app/admin/connectors/slack/page.tsx
new file mode 100644
index 000000000000..d24e64d674e0
--- /dev/null
+++ b/web/src/app/admin/connectors/slack/page.tsx
@@ -0,0 +1,76 @@
+"use client";
+
+import {
+ ConnectorStatus,
+ ReccuringConnectorStatus,
+} from "@/components/admin/connectors/RecurringConnectorStatus";
+import { SlackForm } from "@/app/admin/connectors/slack/SlackForm";
+import { SlackIcon } from "@/components/icons/icons";
+import { fetcher } from "@/lib/fetcher";
+import useSWR, { useSWRConfig } from "swr";
+import { SlackConfig } from "./interfaces";
+import { ThinkingAnimation } from "@/components/Thinking";
+
+const MainSection = () => {
+ // TODO: add back in once this is ready
+ // const { data, isLoading, error } = useSWR(
+ // "/api/admin/connectors/web/index-attempt",
+ // fetcher
+ // );
+ const { mutate } = useSWRConfig();
+ const { data, isLoading, error } = useSWR(
+ "/api/admin/connectors/slack/config",
+ fetcher
+ );
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ } else if (error || !data) {
+ return (
+ {`Error loading Slack config - ${error}`}
+ );
+ }
+
+ return (
+
+
+ Status
+
+ {
+
+ }
+
+
+ Config
+
+
+ mutate("/api/admin/connectors/slack/config")}
+ />
+
+
+ );
+};
+
+export default function Page() {
+ return (
+
+ );
+}
diff --git a/web/src/app/admin/connectors/web/WebIndexForm.tsx b/web/src/app/admin/connectors/web/WebIndexForm.tsx
new file mode 100644
index 000000000000..3e1ef532e7b2
--- /dev/null
+++ b/web/src/app/admin/connectors/web/WebIndexForm.tsx
@@ -0,0 +1,94 @@
+import React, { useState } from "react";
+import { Formik, Form, FormikHelpers } from "formik";
+import * as Yup from "yup";
+import { Popup } from "../../../../components/admin/connectors/Popup";
+import { TextFormField } from "../../../../components/admin/connectors/Field";
+
+interface FormData {
+ url: string;
+}
+
+const validationSchema = Yup.object().shape({
+ url: Yup.string().required(
+ "Please enter the website URL to scrape e.g. https://docs.github.com/en/actions"
+ ),
+});
+
+const handleSubmit = async (
+ values: FormData,
+ { setSubmitting }: FormikHelpers,
+ setPopup: (
+ popup: { message: string; type: "success" | "error" } | null
+ ) => void
+): Promise => {
+ setSubmitting(true);
+ let isSuccess = false;
+ try {
+ const response = await fetch("/api/admin/connectors/web/index-attempt", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(values),
+ });
+
+ if (response.ok) {
+ isSuccess = true;
+ setPopup({ message: "Success!", type: "success" });
+ } else {
+ const errorData = await response.json();
+ setPopup({ message: `Error: ${errorData.detail}`, type: "error" });
+ }
+ } catch (error) {
+ setPopup({ message: `Error: ${error}`, type: "error" });
+ } finally {
+ setSubmitting(false);
+ setTimeout(() => {
+ setPopup(null);
+ }, 3000);
+ return isSuccess;
+ }
+};
+
+interface SlackFormProps {
+ onSubmit: (isSuccess: boolean) => void;
+}
+
+export const WebIndexForm: React.FC = ({ onSubmit }) => {
+ const [popup, setPopup] = useState<{
+ message: string;
+ type: "success" | "error";
+ } | null>(null);
+
+ return (
+ <>
+ {popup && }
+
+ handleSubmit(values, formikHelpers, setPopup).then((isSuccess) =>
+ onSubmit(isSuccess)
+ )
+ }
+ >
+ {({ isSubmitting }) => (
+
+ )}
+
+ >
+ );
+};
diff --git a/web/src/app/admin/connectors/web/page.tsx b/web/src/app/admin/connectors/web/page.tsx
new file mode 100644
index 000000000000..c696df642f68
--- /dev/null
+++ b/web/src/app/admin/connectors/web/page.tsx
@@ -0,0 +1,113 @@
+"use client";
+
+import useSWR, { useSWRConfig } from "swr";
+
+import { BasicTable } from "@/components/admin/connectors/BasicTable";
+import { WebIndexForm } from "@/app/admin/connectors/web/WebIndexForm";
+import { ThinkingAnimation } from "@/components/Thinking";
+import { timeAgo } from "@/lib/time";
+import { GlobeIcon } from "@/components/icons/icons";
+import { fetcher } from "@/lib/fetcher";
+
+interface WebsiteIndexAttempt {
+ url: string;
+ status: "success" | "failure" | "in_progress" | "not_started";
+ time_created: string;
+ time_updated: string;
+ docs_indexed: number;
+}
+
+interface ListWebIndexingResponse {
+ index_attempts: WebsiteIndexAttempt[];
+}
+
+const COLUMNS = [
+ { header: "Base URL", key: "url" },
+ { header: "Last Indexed", key: "indexed_at" },
+ { header: "Docs Indexed", key: "docs_indexed" },
+ { header: "Status", key: "status" },
+];
+
+export default function Web() {
+ const { mutate } = useSWRConfig();
+ const { data, isLoading, error } = useSWR(
+ "/api/admin/connectors/web/index-attempt",
+ fetcher
+ );
+
+ const urlToLatestIndexAttempt = new Map();
+ const urlToLatestIndexSuccess = new Map();
+ data?.index_attempts.forEach((indexAttempt) => {
+ const latestIndexAttempt = urlToLatestIndexAttempt.get(indexAttempt.url);
+ if (
+ !latestIndexAttempt ||
+ indexAttempt.time_created > latestIndexAttempt.time_created
+ ) {
+ urlToLatestIndexAttempt.set(indexAttempt.url, indexAttempt);
+ }
+
+ const latestIndexSuccess = urlToLatestIndexSuccess.get(indexAttempt.url);
+ if (
+ indexAttempt.status === "success" &&
+ (!latestIndexSuccess || indexAttempt.time_updated > latestIndexSuccess)
+ ) {
+ urlToLatestIndexSuccess.set(indexAttempt.url, indexAttempt.time_updated);
+ }
+ });
+
+ return (
+
+
+
+
Web
+
+
+ Request Indexing
+
+
+ {
+ if (success) {
+ mutate("/api/admin/connectors/web/index-attempt");
+ }
+ }}
+ />
+
+
+
+ Indexing History
+
+ {isLoading ? (
+
+ ) : error ? (
+
Error loading indexing history
+ ) : (
+
0
+ ? Array.from(urlToLatestIndexAttempt.values()).map(
+ (indexAttempt) => ({
+ ...indexAttempt,
+ indexed_at:
+ timeAgo(urlToLatestIndexSuccess.get(indexAttempt.url)) ||
+ "-",
+ docs_indexed: indexAttempt.docs_indexed || "-",
+ url: (
+
+ {indexAttempt.url}
+
+ ),
+ })
+ )
+ : []
+ }
+ />
+ )}
+
+ );
+}
diff --git a/web/src/app/admin/layout.tsx b/web/src/app/admin/layout.tsx
new file mode 100644
index 000000000000..68572d544d2e
--- /dev/null
+++ b/web/src/app/admin/layout.tsx
@@ -0,0 +1,48 @@
+import { Header } from "@/components/Header";
+import { Sidebar } from "@/components/admin/connectors/Sidebar";
+import { GlobeIcon, SlackIcon } from "@/components/icons/icons";
+
+export default function AdminLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
+ ),
+ link: "/admin/connectors/slack",
+ },
+ {
+ name: (
+
+ ),
+ link: "/admin/connectors/web",
+ },
+ ],
+ },
+ ]}
+ />
+
+ {children}
+
+
+
+ );
+}
diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx
index fe978a9038e4..2f1fe7efeacc 100644
--- a/web/src/app/layout.tsx
+++ b/web/src/app/layout.tsx
@@ -1,5 +1,12 @@
import "./globals.css";
+import { Inter } from "next/font/google";
+
+const inter = Inter({
+ subsets: ["latin"],
+ variable: "--font-inter",
+});
+
export const metadata = {
title: "Danswer",
description: "Question answering for your documents",
@@ -12,7 +19,7 @@ export default function RootLayout({
}) {
return (
- {children}
+ {children}
);
}
diff --git a/web/src/components/SearchBar.tsx b/web/src/components/SearchBar.tsx
index bc7e1b7be0c5..e28cd9a9ac8d 100644
--- a/web/src/components/SearchBar.tsx
+++ b/web/src/components/SearchBar.tsx
@@ -61,10 +61,6 @@ const SearchBar: React.FC = ({ onSearch }) => {
target.style.height = `${newHeight}px`;
};
- // const handleSubmit = (event: KeyboardEvent) => {
- // onSearch(searchTerm);
- // };
-
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Enter" && !event.shiftKey) {
onSearch(searchTerm);
diff --git a/web/src/components/Thinking.tsx b/web/src/components/Thinking.tsx
index d1f9751ee59d..54e000fb6494 100644
--- a/web/src/components/Thinking.tsx
+++ b/web/src/components/Thinking.tsx
@@ -1,7 +1,13 @@
import React, { useState, useEffect } from "react";
import "./thinking.css";
-export const ThinkingAnimation: React.FC = () => {
+interface ThinkingAnimationProps {
+ text?: string;
+}
+
+export const ThinkingAnimation: React.FC = ({
+ text,
+}) => {
const [dots, setDots] = useState("...");
useEffect(() => {
@@ -26,7 +32,8 @@ export const ThinkingAnimation: React.FC = () => {
return (
- Thinking{dots}
+ {text || "Thinking"}
+ {dots}
);
diff --git a/web/src/components/admin/connectors/BasicTable.tsx b/web/src/components/admin/connectors/BasicTable.tsx
new file mode 100644
index 000000000000..d3acba3135e9
--- /dev/null
+++ b/web/src/components/admin/connectors/BasicTable.tsx
@@ -0,0 +1,67 @@
+import React, { FC } from "react";
+
+type Column = {
+ header: string;
+ key: string;
+};
+
+type TableData = {
+ [key: string]: string | number | JSX.Element;
+};
+
+interface BasicTableProps {
+ columns: Column[];
+ data: TableData[];
+}
+
+export const BasicTable: FC = ({ columns, data }) => {
+ return (
+
+
+
+
+ {columns.map((column, index) => (
+
+ {column.header}
+ |
+ ))}
+
+
+
+ {data.map((row, rowIndex) => (
+
+ {columns.map((column, colIndex) => {
+ let entryClassName = "px-4 py-2 border-b border-gray-700";
+ const isFinalRow = rowIndex === data.length - 1;
+ if (colIndex === 0) {
+ entryClassName += " border-l";
+ if (isFinalRow) {
+ entryClassName += " rounded-bl-md";
+ }
+ }
+ if (colIndex === columns.length - 1) {
+ entryClassName += " border-r";
+ if (isFinalRow) {
+ entryClassName += " rounded-br-md";
+ }
+ }
+ return (
+
+ {row[column.key]}
+ |
+ );
+ })}
+
+ ))}
+
+
+
+ );
+};
diff --git a/web/src/components/admin/connectors/Field.tsx b/web/src/components/admin/connectors/Field.tsx
new file mode 100644
index 000000000000..0890f1751403
--- /dev/null
+++ b/web/src/components/admin/connectors/Field.tsx
@@ -0,0 +1,27 @@
+import { ErrorMessage, Field } from "formik";
+
+interface TextFormFieldProps {
+ name: string;
+ label: string;
+}
+
+export const TextFormField = ({ name, label }: TextFormFieldProps) => {
+ return (
+
+
+
+
+
+ );
+};
diff --git a/web/src/components/admin/connectors/RecurringConnectorStatus.tsx b/web/src/components/admin/connectors/RecurringConnectorStatus.tsx
new file mode 100644
index 000000000000..02acb0b626a8
--- /dev/null
+++ b/web/src/components/admin/connectors/RecurringConnectorStatus.tsx
@@ -0,0 +1,29 @@
+import { CheckCircle, MinusCircle } from "@phosphor-icons/react";
+
+export enum ConnectorStatus {
+ Running = "Running",
+ NotSetup = "Not Setup",
+}
+
+interface ReccuringConnectorStatusProps {
+ status: ConnectorStatus;
+}
+
+export const ReccuringConnectorStatus = ({
+ status,
+}: ReccuringConnectorStatusProps) => {
+ if (status === ConnectorStatus.Running) {
+ return (
+
+ );
+ }
+ return (
+
+ );
+};
diff --git a/web/src/components/admin/connectors/Sidebar.tsx b/web/src/components/admin/connectors/Sidebar.tsx
new file mode 100644
index 000000000000..c6fb57be0b1b
--- /dev/null
+++ b/web/src/components/admin/connectors/Sidebar.tsx
@@ -0,0 +1,43 @@
+// Sidebar.tsx
+import React from "react";
+import Link from "next/link";
+
+interface Item {
+ name: string | JSX.Element;
+ link: string;
+}
+
+interface Collection {
+ name: string | JSX.Element;
+ items: Item[];
+}
+
+interface SidebarProps {
+ title: string;
+ collections: Collection[];
+}
+
+export const Sidebar: React.FC = ({ collections }) => {
+ return (
+
+ );
+};
diff --git a/web/src/components/admin/connectors/SlackForm.tsx b/web/src/components/admin/connectors/SlackForm.tsx
deleted file mode 100644
index 845f1cd11713..000000000000
--- a/web/src/components/admin/connectors/SlackForm.tsx
+++ /dev/null
@@ -1,160 +0,0 @@
-import React, { useEffect, useState } from "react";
-import { Formik, Form, Field, ErrorMessage, FormikHelpers } from "formik";
-import * as Yup from "yup";
-import { Popup } from "./Popup";
-
-interface FormData {
- slack_bot_token: string;
- workspace_id: string;
-}
-
-const validationSchema = Yup.object().shape({
- slack_bot_token: Yup.string().required("Please enter your Slack Bot Token"),
- workspace_id: Yup.string().required("Please enter your Workspace ID"),
- pull_frequency: Yup.number().required(
- "Please enter a pull frequency (in minutes). 0 => no pulling from slack"
- ),
-});
-
-const getConfig = async (): Promise => {
- const response = await fetch("/api/admin/slack_connector_config");
- return response.json();
-};
-
-const handleSubmit = async (
- values: FormData,
- { setSubmitting }: FormikHelpers,
- setPopup: (
- popup: { message: string; type: "success" | "error" } | null
- ) => void
-) => {
- setSubmitting(true);
- try {
- const response = await fetch("/api/admin/slack_connector_config", {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify(values),
- });
-
- if (response.ok) {
- setPopup({ message: "Success!", type: "success" });
- } else {
- const errorData = await response.json();
- setPopup({ message: `Error: ${errorData.detail}`, type: "error" });
- }
- } catch (error) {
- setPopup({ message: `Error: ${error}`, type: "error" });
- } finally {
- setSubmitting(false);
- setTimeout(() => {
- setPopup(null);
- }, 3000);
- }
-};
-
-interface SlackFormProps {
- onSubmit: (isSuccess: boolean) => void;
-}
-
-export const SlackForm: React.FC = ({ onSubmit }) => {
- const [initialValues, setInitialValues] = React.useState();
- const [popup, setPopup] = useState<{
- message: string;
- type: "success" | "error";
- } | null>(null);
-
- useEffect(() => {
- getConfig().then((response) => {
- setInitialValues(response);
- });
- }, []);
-
- if (!initialValues) {
- // TODO (chris): improve
- return Loading...
;
- }
-
- return (
- <>
- {popup && }
-
- handleSubmit(values, formikHelpers, setPopup)
- }
- >
- {({ isSubmitting }) => (
-
- )}
-
- >
- );
-};
diff --git a/web/src/components/icons/icons.tsx b/web/src/components/icons/icons.tsx
new file mode 100644
index 000000000000..f7b576bb8035
--- /dev/null
+++ b/web/src/components/icons/icons.tsx
@@ -0,0 +1,24 @@
+"use client";
+
+import { Globe, SlackLogo } from "@phosphor-icons/react";
+
+interface IconProps {
+ size?: string;
+ className?: string;
+}
+
+const defaultTailwindCSS = "text-blue-400 my-auto flex flex-shrink-0";
+
+export const GlobeIcon = ({
+ size = "16",
+ className = defaultTailwindCSS,
+}: IconProps) => {
+ return ;
+};
+
+export const SlackIcon = ({
+ size = "16",
+ className = defaultTailwindCSS,
+}: IconProps) => {
+ return ;
+};
diff --git a/web/src/lib/fetcher.ts b/web/src/lib/fetcher.ts
new file mode 100644
index 000000000000..50b047c9ada7
--- /dev/null
+++ b/web/src/lib/fetcher.ts
@@ -0,0 +1 @@
+export const fetcher = (url: string) => fetch(url).then((res) => res.json());
diff --git a/web/src/lib/time.ts b/web/src/lib/time.ts
new file mode 100644
index 000000000000..f01a9e234a2b
--- /dev/null
+++ b/web/src/lib/time.ts
@@ -0,0 +1,36 @@
+export const timeAgo = (dateString: string | undefined): string | null => {
+ if (!dateString) {
+ return null;
+ }
+
+ const date = new Date(dateString);
+ const now = new Date();
+ const secondsDiff = Math.floor((now.getTime() - date.getTime()) / 1000);
+
+ if (secondsDiff < 60) {
+ return `${secondsDiff} seconds ago`;
+ }
+
+ const minutesDiff = Math.floor(secondsDiff / 60);
+ if (minutesDiff < 60) {
+ return `${minutesDiff} minutes ago`;
+ }
+
+ const hoursDiff = Math.floor(minutesDiff / 60);
+ if (hoursDiff < 24) {
+ return `${hoursDiff} hours ago`;
+ }
+
+ const daysDiff = Math.floor(hoursDiff / 24);
+ if (daysDiff < 30) {
+ return `${daysDiff} days ago`;
+ }
+
+ const monthsDiff = Math.floor(daysDiff / 30);
+ if (monthsDiff < 12) {
+ return `${monthsDiff} months ago`;
+ }
+
+ const yearsDiff = Math.floor(monthsDiff / 12);
+ return `${yearsDiff} years ago`;
+};
diff --git a/web/tailwind.config.js b/web/tailwind.config.js
index d8b456688336..75bce2a900c0 100644
--- a/web/tailwind.config.js
+++ b/web/tailwind.config.js
@@ -9,7 +9,11 @@ module.exports = {
"./src/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
- extend: {},
+ extend: {
+ fontFamily: {
+ sans: ["var(--font-inter)"],
+ },
+ },
},
plugins: [],
};