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 ( +
+
+ +

Slack

+
+ +
+ ); +} 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 ( +
+
+
+ + +
Slack
+
+ ), + link: "/admin/connectors/slack", + }, + { + name: ( +
+ +
Web
+
+ ), + 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) => ( + + ))} + + + + {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 ( + + ); + })} + + ))} + +
+ {column.header} +
+ {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 ( +
+ +

{status}

+
+ ); + } + return ( +
+ +

{status}

+
+ ); +}; 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: [], };