Reupload
This commit is contained in:
commit
61cbd57af1
168 changed files with 31208 additions and 0 deletions
3
web-app/src/components/AppName.tsx
Normal file
3
web-app/src/components/AppName.tsx
Normal file
|
@ -0,0 +1,3 @@
|
|||
export default function AppName() {
|
||||
return <span className="text-4xl"><span className="text-app-primary">Awary</span></span>
|
||||
}
|
41
web-app/src/components/Button.tsx
Normal file
41
web-app/src/components/Button.tsx
Normal file
|
@ -0,0 +1,41 @@
|
|||
import {ButtonHTMLAttributes, useState} from "react";
|
||||
import Spinner from "./Spinner";
|
||||
|
||||
const buttons: {[index: string]: string} = {
|
||||
primary: `inline-block px-6 py-2.5 bg-primary text-white font-medium text-xs leading-tight uppercase rounded hover:bg-primary-hover focus:bg-primary focus:outline-none focus:ring-0 active:bg-primary-active transition duration-150 ease-in-out`,
|
||||
danger: "inline-block px-6 py-2.5 bg-red-600 text-white font-medium text-xs leading-tight uppercase rounded hover:bg-red-700 focus:bg-red-700 focus:outline-none focus:ring-0 active:bg-red-800 transition duration-150 ease-in-out",
|
||||
light: "inline-block px-6 py-2.5 bg-gray-200 text-gray-700 font-medium text-xs leading-tight uppercase rounded hover:bg-gray-300 focus:bg-gray-300 focus:outline-none focus:ring-0 active:bg-gray-400 transition duration-150 ease-in-out",
|
||||
dark: "inline-block px-6 py-2.5 bg-neutral-800 text-white font-medium text-xs leading-tight uppercase rounded hover:bg-neutral-900 focus:bg-neutral-900 focus:outline-none focus:ring-0 active:bg-neutral-900 transition duration-150 ease-in-out",
|
||||
"brighter-dark": "inline-block px-6 py-2.5 bg-neutral-700 text-white font-medium text-xs leading-tight uppercase rounded hover:bg-neutral-900 focus:bg-neutral-900 focus:outline-none focus:ring-0 active:bg-neutral-900 transition duration-150 ease-in-out",
|
||||
}
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
text: string
|
||||
color?: string
|
||||
onClickAsync?: () => Promise<void>
|
||||
spinner?: boolean
|
||||
}
|
||||
|
||||
export default function Button({text, color, className, onClickAsync, spinner, ...props}: ButtonProps) {
|
||||
const colorKey = color ? color : "primary"
|
||||
const buttonStyle = buttons[colorKey] ? buttons[colorKey] : buttons.primary
|
||||
const [showSpinner, SetShowSnipper] = useState(false)
|
||||
|
||||
let onClick = props.onClick
|
||||
|
||||
if (onClickAsync) {
|
||||
onClick = async () => {
|
||||
SetShowSnipper(true)
|
||||
try {
|
||||
await onClickAsync()
|
||||
} catch (e) {console.error(e)}
|
||||
SetShowSnipper(false)
|
||||
}
|
||||
}
|
||||
|
||||
const shouldShowSpinner = spinner || showSpinner
|
||||
|
||||
return (
|
||||
<button {...props} onClick={onClick} className={`${buttonStyle} w-[fit-content] font-bold flex flex-row items-center gap-4 ${className}`} disabled={shouldShowSpinner}>{text} {shouldShowSpinner && <Spinner/>}</button>
|
||||
)
|
||||
}
|
26
web-app/src/components/Card.tsx
Normal file
26
web-app/src/components/Card.tsx
Normal file
|
@ -0,0 +1,26 @@
|
|||
import {HTMLAttributes, ReactElement} from "react";
|
||||
|
||||
interface CardProps extends HTMLAttributes<HTMLElement> {
|
||||
header?: string | ReactElement<any, any>
|
||||
hideSeparator?: boolean
|
||||
className?: string
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
export default function Card({header, hideSeparator, children, onClick, className}: CardProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`bg-neutral-600 rounded-md text-left p-4 ${className}`}
|
||||
onClick={() => {onClick?.()}}
|
||||
>
|
||||
{header && <>
|
||||
<div className="text-xl bg-neutral-600">{header}</div>
|
||||
{!hideSeparator && <div className="border-b border-b-neutral-500 my-4"/>}
|
||||
</>}
|
||||
<div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
98
web-app/src/components/Chart.tsx
Normal file
98
web-app/src/components/Chart.tsx
Normal file
|
@ -0,0 +1,98 @@
|
|||
import moment from "moment";
|
||||
import { useState } from "react";
|
||||
import {Bar, ComposedChart} from "recharts";
|
||||
import {Line, ResponsiveContainer, Tooltip, XAxis, YAxis} from "recharts";
|
||||
import {formatTimestamp} from "../utils/formatTimestamp";
|
||||
import Select from "./Select";
|
||||
|
||||
const dayDuration = 1000 * 60 * 60 * 24 // Duration of a day in milliseconds
|
||||
|
||||
function lastDays(data: {value: number | null, date: number}[], alwaysUseLastValue: boolean, daysCount: number = 30) {
|
||||
data.sort((a, b) => b.date - a.date)
|
||||
const processedData: {date: string, value: number | null}[] = []
|
||||
const startingPoint = moment(Date.now()).endOf("day").valueOf()
|
||||
|
||||
for (let currentDay = 0; currentDay < daysCount; ++currentDay) {
|
||||
const beforeThreshold = startingPoint - (currentDay * dayDuration)
|
||||
let last = data.find(x => x.date < beforeThreshold)
|
||||
if (!alwaysUseLastValue && last && last?.date < beforeThreshold - (dayDuration)) {
|
||||
last = undefined
|
||||
}
|
||||
processedData.push({
|
||||
date: formatTimestamp(beforeThreshold),
|
||||
value: last && last.value !== null ? last.value : null
|
||||
})
|
||||
}
|
||||
|
||||
return processedData.reverse()
|
||||
}
|
||||
|
||||
type ChartProps = {
|
||||
charts: {
|
||||
name: string
|
||||
type: string
|
||||
color: string
|
||||
alwaysUseLastValue: boolean
|
||||
data: {
|
||||
value: number
|
||||
date: number
|
||||
}[]
|
||||
}[]
|
||||
}
|
||||
|
||||
export default function Chart({charts}: ChartProps) {
|
||||
|
||||
const [daysRange, setDaysRange] = useState<number>(30)
|
||||
const processedData = charts.map(d => ({
|
||||
name: d.name,
|
||||
type: d.type,
|
||||
color: d.color,
|
||||
data: lastDays(d.data, d.alwaysUseLastValue, daysRange)
|
||||
}))
|
||||
const chartData: {
|
||||
values: number[]
|
||||
date: string
|
||||
}[] = []
|
||||
|
||||
for (let i = 0; i < processedData.length; ++i) {
|
||||
for (let j = 0; j < daysRange; ++j) {
|
||||
if (i === 0) {
|
||||
chartData.push({date: processedData[i].data[j].date, values: [processedData[i].data[j].value || 0]})
|
||||
continue;
|
||||
}
|
||||
chartData[j].values.push(processedData[i].data[j].value || 0)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-2">
|
||||
<div className="f-r items-center gap-2">
|
||||
<span>Show:</span>
|
||||
<Select onChange={(event) => setDaysRange(parseInt(event.target.value))}>
|
||||
<option value={7}>last 7 days</option>
|
||||
<option value={15}>last 15 days</option>
|
||||
<option selected value={30}>last 30 days</option>
|
||||
<option value={60}>last 60 days</option>
|
||||
<option value={90}>last 90 days</option>
|
||||
<option value={360}>last 365 days</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-b border-b-neutral-500 my-4"/>
|
||||
<ResponsiveContainer width="99%" height={180}>
|
||||
<ComposedChart data={chartData}>
|
||||
{chartData.map((_, index) => {
|
||||
if (charts[index]?.type === "line")
|
||||
return <Line type="monotone" dataKey={`values[${index}]`} dot={false} name={charts[index]?.name} stroke={charts[index].color} />
|
||||
if (charts[index]?.type === "bar")
|
||||
return <Bar fill={charts[index]?.color} dataKey={`values[${index}]`} name={charts[index]?.name} stroke={charts[index].color} />
|
||||
})}
|
||||
<XAxis dataKey="date" stroke="#AAAAAA"/>
|
||||
<YAxis stroke="#AAAAAA"/>
|
||||
<Tooltip contentStyle={{backgroundColor: "#444444"}}/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</>
|
||||
)
|
||||
}
|
44
web-app/src/components/HeaderBar.tsx
Normal file
44
web-app/src/components/HeaderBar.tsx
Normal file
|
@ -0,0 +1,44 @@
|
|||
import {useState} from "react";
|
||||
import {Link, useNavigate} from "react-router-dom";
|
||||
import {useApi} from "../api";
|
||||
import AppName from "./AppName";
|
||||
import {MenuList, MenuListItem} from "./MenuList";
|
||||
|
||||
export default function HeaderBar() {
|
||||
const api = useApi();
|
||||
const navigate = useNavigate()
|
||||
const [showMenu, setShowMenu] = useState(false)
|
||||
|
||||
if (!api.IsLogged()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-neutral-800 py-4 f-r justify-between items-center gap-2 px-0 xl:px-96">
|
||||
<div className="f-r flex-1 justify-start">
|
||||
<Link to={api.IsLogged() ? "/projects" : "/"}>
|
||||
<AppName/>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex-1 f-r items-center justify-end">
|
||||
{
|
||||
api.GetUserData() ?
|
||||
<>
|
||||
<div className="p-1 px-2 border border-neutral-500 rounded-md relative">
|
||||
<div className="cursor-pointer" onClick={() => { setShowMenu(!showMenu) }}>
|
||||
{api.GetUserData().email}
|
||||
</div>
|
||||
<MenuList show={showMenu}>
|
||||
<MenuListItem text="Logout" onClick={async () => {
|
||||
await api.Logout();
|
||||
navigate("/")
|
||||
}}/>
|
||||
</MenuList>
|
||||
</div>
|
||||
</>
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
49
web-app/src/components/Input.tsx
Normal file
49
web-app/src/components/Input.tsx
Normal file
|
@ -0,0 +1,49 @@
|
|||
import {InputHTMLAttributes, TextareaHTMLAttributes} from "react"
|
||||
import {UseFormRegisterReturn} from "react-hook-form"
|
||||
|
||||
interface LineEditProps {
|
||||
state?: string,
|
||||
stateHandler?: (value: string) => void,
|
||||
register?: UseFormRegisterReturn,
|
||||
label?: string,
|
||||
labelError?: string,
|
||||
isValid?: (value: string) => boolean
|
||||
value?: string | number
|
||||
onChange?: (value: string) => void
|
||||
required?: boolean
|
||||
type?: string
|
||||
minLength?: number
|
||||
multiline?: boolean
|
||||
className?: string
|
||||
inputClassName?: string
|
||||
rows?: number
|
||||
cols?: number
|
||||
}
|
||||
|
||||
export default function LineEdit({state, stateHandler, label, labelError, isValid, register, value, ...props}: LineEditProps) {
|
||||
|
||||
const inputProps: InputHTMLAttributes<HTMLInputElement> & TextareaHTMLAttributes<HTMLTextAreaElement> = {
|
||||
className: `bg-neutral-700 text-white rounded-md p-2 flex-1 min-w-0 ${props.inputClassName}`,
|
||||
onChange: ((e: any) => props.onChange && props.onChange(e.target.value)),
|
||||
required: props.required,
|
||||
type: props.type,
|
||||
minLength: props.minLength,
|
||||
rows: props.rows,
|
||||
cols: props.cols
|
||||
}
|
||||
|
||||
if (!register) {
|
||||
inputProps.value = value
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`text-left min-w-0 ${props.className}`}>
|
||||
{label && <div className="text-sm mb-1 ml-1">{label}</div>}
|
||||
<div className="f-r">
|
||||
{!props.multiline && <input {...inputProps} {...register}/>}
|
||||
{props.multiline && <textarea {...inputProps} {...register}/>}
|
||||
</div>
|
||||
{labelError && <div className="text-red-300">{labelError}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
69
web-app/src/components/LogCard.tsx
Normal file
69
web-app/src/components/LogCard.tsx
Normal file
|
@ -0,0 +1,69 @@
|
|||
import {TrashIcon} from "@heroicons/react/outline";
|
||||
import moment from "moment";
|
||||
import {useState} from "react";
|
||||
import {useApi} from "../api";
|
||||
import {Log} from "../core/Log";
|
||||
import {Project} from "../core/Project";
|
||||
import {TagSticker} from "./TagSticker";
|
||||
import {useModal} from "../services/ModalService";
|
||||
import {formatTimestamp} from "../utils/formatTimestamp";
|
||||
|
||||
export type LogCardProps = {
|
||||
project: Project
|
||||
log: Log
|
||||
onDelete?: () => void
|
||||
}
|
||||
|
||||
export function LogCard({log, project, onDelete}: LogCardProps) {
|
||||
const [showContent, setShowContent] = useState(false);
|
||||
const modalService = useModal()
|
||||
const api = useApi()
|
||||
|
||||
const deleteLog = () => {
|
||||
modalService.confirmation("Delete the log ?", async () => {
|
||||
await api.deleteLog(log.projectId, log.id)
|
||||
onDelete && onDelete();
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card p-4 cursor-pointer" onClick={() => {setShowContent(true)}}>
|
||||
<div className="f-c gap-2" >
|
||||
<div className="f-r gap-4 items-center">
|
||||
<span className="text-xl">{log.title}</span>
|
||||
<div className="f-r gap-2">
|
||||
{log.tags.map(tag => <TagSticker tag={tag}/>)}
|
||||
</div>
|
||||
<span className="text-neutral-400 text-sm ml-auto">
|
||||
{formatTimestamp(log.createdAt)}
|
||||
</span>
|
||||
<TrashIcon
|
||||
className="w-4 h-4 text-red-300 cursor-pointer"
|
||||
onClick={deleteLog}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{showContent && <p className="text-sm text-left pt-4">{log.content}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TinyLogCard({log, project}: LogCardProps) {
|
||||
|
||||
let borderStyle = {}
|
||||
if (log.tags.length > 0) {
|
||||
borderStyle = {borderLeftColor: `${log.tags[0].color}`}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="f-c gap-2">
|
||||
<div className="f-r items-stretch">
|
||||
<div className="border-l-2 border-neutral-500 pr-2" style={borderStyle}/>
|
||||
<p>{log.title}</p>
|
||||
<p className='mt-auto ml-auto text-xs text-neutral-400'>{moment(log.createdAt).format("YYYY-MM-DD hh:mm a")}</p>
|
||||
</div>
|
||||
<div className="w-full border-b border-neutral-500"/>
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
25
web-app/src/components/MenuList.tsx
Normal file
25
web-app/src/components/MenuList.tsx
Normal file
|
@ -0,0 +1,25 @@
|
|||
import {HTMLAttributes} from "react";
|
||||
|
||||
export function MenuListItem(props: (HTMLAttributes<HTMLDivElement> & {text: string})) {
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={`text-left p-4 hover:bg-neutral-700 cursor-pointer rounded-md ${props.className}`}
|
||||
>
|
||||
{props.text}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function MenuList({show, ...props}: (HTMLAttributes<HTMLDivElement> & {show: boolean})) {
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={`bg-neutral-900 absolute top-0 left-0 translate-y-[-110%] right-0 w-full rounded-md ${show ? "block" : "hidden"} ${props.className}`}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
42
web-app/src/components/Modal.tsx
Normal file
42
web-app/src/components/Modal.tsx
Normal file
|
@ -0,0 +1,42 @@
|
|||
import {useEffect, useState} from "react"
|
||||
|
||||
interface ModalProps {
|
||||
show: boolean
|
||||
children?: JSX.Element | null
|
||||
onClose: () => void
|
||||
customId?: string
|
||||
ignoreInputs?: boolean
|
||||
}
|
||||
|
||||
export default function Modal({show, onClose, children, customId, ignoreInputs}: ModalProps) {
|
||||
|
||||
const [createdAt] = useState(Date.now())
|
||||
|
||||
useEffect(() => {
|
||||
if (!show)
|
||||
return;
|
||||
function handleClose(e: any) {
|
||||
if (!ignoreInputs && !document.getElementById(customId || 'modal-content')?.contains(e.target) && Date.now() - createdAt > 1000) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
document.addEventListener('click', handleClose)
|
||||
document.body.style.overflow = "hidden"
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClose)
|
||||
document.body.style.overflow = "visible"
|
||||
}
|
||||
}, [show, onClose, customId, createdAt])
|
||||
|
||||
if (!show) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed top-0 left-0 w-screen h-screen bg-black bg-opacity-50 flex justify-center items-center overflow-scroll">
|
||||
<div id={customId || 'modal-content'} className="bg-neutral-600 p-2 rounded-md max-h-[90%] overflow-scroll">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
30
web-app/src/components/ProjectHeader.tsx
Normal file
30
web-app/src/components/ProjectHeader.tsx
Normal file
|
@ -0,0 +1,30 @@
|
|||
import { ClipboardCopyIcon } from "@heroicons/react/outline";
|
||||
import { Project } from "../core/Project";
|
||||
import { useModal } from "../services/ModalService";
|
||||
import { copyToClipboard } from "../utils/copyToClipboard";
|
||||
|
||||
type ProjectHeaderProps = {
|
||||
project: Project
|
||||
middle?: JSX.Element
|
||||
right?: JSX.Element
|
||||
}
|
||||
|
||||
export default function ProjectHeader({project, middle, right}: ProjectHeaderProps) {
|
||||
const modalService = useModal();
|
||||
return (
|
||||
<div className="f-r pb-4 border-b border-b-neutral-500">
|
||||
<div className="flex-1 f-r items-center gap-2 text-left text-2xl">
|
||||
<h2 className="text-3xl">{project.name}</h2>
|
||||
<ClipboardCopyIcon
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
onClick={() => {
|
||||
copyToClipboard(project.id)
|
||||
modalService.info("Project id copied to clipboard")
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 f-c justify-center">{middle}</div>
|
||||
<div className="flex-1 f-c justify-center">{right}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
53
web-app/src/components/ProjectPageBase.tsx
Normal file
53
web-app/src/components/ProjectPageBase.tsx
Normal file
|
@ -0,0 +1,53 @@
|
|||
import {useCallback, useEffect, useState} from "react";
|
||||
import {Route, Routes, useParams} from "react-router-dom";
|
||||
import {useApi} from "../api";
|
||||
import {Project} from "../core/Project";
|
||||
import { MetricHistoryPage } from "../pages/MetricHistory";
|
||||
import {ProjectApiKeysPage} from "../pages/ProjectApiKeys";
|
||||
import ProjectLogsPage from "../pages/ProjectLogs";
|
||||
import {ProjectMetricsPage} from "../pages/ProjectMetrics";
|
||||
import ProjectPage from "../pages/projectPage";
|
||||
import {ProjectSideBar} from "./ProjectSideBar";
|
||||
|
||||
export default function ProjectPageBase() {
|
||||
|
||||
const api = useApi();
|
||||
const {projectId} = useParams();
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
|
||||
const fetchProject = useCallback(async () => {
|
||||
const project = await api.fetchProject(projectId as string);
|
||||
setProject(project);
|
||||
}, [projectId, api])
|
||||
|
||||
useEffect(() => {fetchProject()}, [fetchProject])
|
||||
|
||||
if (!project) {
|
||||
return <div>Loading project...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 f-r h-full overflow-hidden">
|
||||
<ProjectSideBar project={project}/>
|
||||
<div className="flex-1 f-c gap-4 p-4 overflow-hidden">
|
||||
<Routes>
|
||||
<Route path="/" element={
|
||||
<ProjectPage/>
|
||||
}/>
|
||||
<Route path="/logs" element={
|
||||
<ProjectLogsPage/>
|
||||
}/>
|
||||
<Route path="/metrics" element={
|
||||
<ProjectMetricsPage/>
|
||||
}/>
|
||||
<Route path="/metrics/:metricId/history" element={
|
||||
<MetricHistoryPage/>
|
||||
}/>
|
||||
<Route path="/api-keys" element={
|
||||
<ProjectApiKeysPage/>
|
||||
}/>
|
||||
</Routes>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
84
web-app/src/components/ProjectSideBar.tsx
Normal file
84
web-app/src/components/ProjectSideBar.tsx
Normal file
|
@ -0,0 +1,84 @@
|
|||
import {useState} from "react";
|
||||
import {Link, useNavigate} from "react-router-dom";
|
||||
import {useApi} from "../api";
|
||||
import AppName from "./AppName";
|
||||
import {MenuList, MenuListItem} from "./MenuList";
|
||||
import {BookOpenIcon, ChartBarIcon, CollectionIcon, KeyIcon, ViewGridIcon} from "@heroicons/react/outline"
|
||||
import {Project} from "../core/Project";
|
||||
|
||||
const topSide = [
|
||||
{
|
||||
name: "Dashboard",
|
||||
icon: ViewGridIcon,
|
||||
route: "/"
|
||||
},
|
||||
{
|
||||
name: "Logs",
|
||||
icon: CollectionIcon,
|
||||
route: "/logs"
|
||||
},
|
||||
{
|
||||
name: "Metrics",
|
||||
icon: ChartBarIcon,
|
||||
route: "/metrics"
|
||||
},
|
||||
{
|
||||
name: "Api keys",
|
||||
icon: KeyIcon,
|
||||
route: "/api-keys"
|
||||
}
|
||||
]
|
||||
|
||||
export type ProjectSideBarProps = {
|
||||
project?: Project
|
||||
}
|
||||
|
||||
export function ProjectSideBar({project}: ProjectSideBarProps) {
|
||||
const api = useApi();
|
||||
const navigate = useNavigate()
|
||||
const [showMenu, setShowMenu] = useState(false)
|
||||
|
||||
if (!api.IsLogged()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-neutral-800 p-4 flex flex-col justify-between items-center gap-2">
|
||||
<div className="f-c flex-1 justify-start">
|
||||
<Link to={api.IsLogged() ? "/projects" : "/"} className="mb-8">
|
||||
<AppName/>
|
||||
</Link>
|
||||
{project && topSide.map(elem => (
|
||||
<Link key={elem.route} to={`/projects/${project.id}${elem.route}`} className="f-r items-center gap-4 p-4 hover:bg-neutral-600 rounded-md duration-200">
|
||||
<elem.icon className="w-8 h-8"/>
|
||||
<div className="text-xl">{elem.name}</div>
|
||||
</Link>)
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 f-c gap-4 items-center justify-end">
|
||||
<a href={process.env.REACT_APP_DOCS_URL} target="_blank" className="f-r items-center gap-4 p-4 hover:bg-neutral-600 rounded-md duration-200">
|
||||
<BookOpenIcon className="w-8 h-8"/>
|
||||
<div className="text-xl">Docs</div>
|
||||
</a>
|
||||
{
|
||||
api.GetUserData() ?
|
||||
<>
|
||||
<div className="p-1 px-2 border border-neutral-500 rounded-md relative">
|
||||
<div className="cursor-pointer" onClick={() => { setShowMenu(!showMenu) }}>
|
||||
{api.GetUserData().email}
|
||||
</div>
|
||||
<MenuList show={showMenu}>
|
||||
{/*<MenuListItem text="Account settings" onClick={() => { console.log("Account settings") }}/>*/}
|
||||
<MenuListItem text="Logout" onClick={async () => {
|
||||
await api.Logout();
|
||||
navigate("/")
|
||||
}}/>
|
||||
</MenuList>
|
||||
</div>
|
||||
</>
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
20
web-app/src/components/RequireAuth.tsx
Normal file
20
web-app/src/components/RequireAuth.tsx
Normal file
|
@ -0,0 +1,20 @@
|
|||
import {useState} from "react"
|
||||
import {Navigate} from "react-router-dom"
|
||||
import {useApi} from "../api"
|
||||
import {useModal} from "../services/ModalService"
|
||||
|
||||
export function RequireAuth ({children}: {children: JSX.Element}) {
|
||||
const api = useApi()
|
||||
const modalService = useModal()
|
||||
const [hasError, setHasError] = useState(false)
|
||||
|
||||
if (!api.IsLogged()) {
|
||||
return <Navigate to="/"/>
|
||||
}
|
||||
|
||||
if (!api.IsLoadingUser() && !api.GetUserData() && !hasError) {
|
||||
setHasError(true)
|
||||
modalService.error("Could not retrieve user data")
|
||||
}
|
||||
return children
|
||||
}
|
32
web-app/src/components/Select.tsx
Normal file
32
web-app/src/components/Select.tsx
Normal file
|
@ -0,0 +1,32 @@
|
|||
import {SelectHTMLAttributes} from "react"
|
||||
import {UseFormRegisterReturn} from "react-hook-form"
|
||||
|
||||
interface LineEditProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||
register?: UseFormRegisterReturn,
|
||||
label?: string,
|
||||
labelError?: string,
|
||||
isValid?: (value: string) => boolean
|
||||
children: JSX.Element | JSX.Element[]
|
||||
}
|
||||
|
||||
export default function Select({children, label, labelError, isValid, register, ...props}: LineEditProps) {
|
||||
let labelDiv = null
|
||||
if (label) {
|
||||
labelDiv = <div className="text-sm mb-1 ml-1">{label}</div>
|
||||
}
|
||||
|
||||
let labelErrorDiv = null
|
||||
if (labelError) {
|
||||
labelErrorDiv = <div className="text-red-300">{labelError}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-left">
|
||||
{labelDiv}
|
||||
<select {...props} {...register} className="bg-neutral-700 text-white rounded-md p-2">
|
||||
{children}
|
||||
</select>
|
||||
{labelErrorDiv}
|
||||
</div>
|
||||
)
|
||||
}
|
60
web-app/src/components/SideBar.tsx
Normal file
60
web-app/src/components/SideBar.tsx
Normal file
|
@ -0,0 +1,60 @@
|
|||
import {useState} from "react";
|
||||
import {Link, useNavigate} from "react-router-dom";
|
||||
import {useApi} from "../api";
|
||||
import AppName from "./AppName";
|
||||
import {MenuList, MenuListItem} from "./MenuList";
|
||||
import {CollectionIcon} from "@heroicons/react/outline"
|
||||
|
||||
const topSide = [
|
||||
{
|
||||
name: "Projects",
|
||||
icon: CollectionIcon,
|
||||
route: "/projects"
|
||||
}
|
||||
]
|
||||
|
||||
export default function SideBar() {
|
||||
const api = useApi();
|
||||
const navigate = useNavigate()
|
||||
const [showMenu, setShowMenu] = useState(false)
|
||||
|
||||
if (!api.IsLogged()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-neutral-800 p-4 flex flex-col justify-between items-center gap-2">
|
||||
<div className="f-c flex-1 justify-start">
|
||||
<Link to={api.IsLogged() ? "/projects" : "/"} className="mb-8">
|
||||
<AppName/>
|
||||
</Link>
|
||||
{topSide.map(elem => (
|
||||
<Link key={elem.route} to={elem.route} className="f-r items-center gap-4 p-4 hover:bg-neutral-600 rounded-md duration-200">
|
||||
<elem.icon className="w-8 h-8"/>
|
||||
<div className="text-xl">{elem.name}</div>
|
||||
</Link>)
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col items-center justify-end">
|
||||
{
|
||||
api.GetUserData() ?
|
||||
<>
|
||||
<div className="p-1 px-2 border border-neutral-500 rounded-md relative">
|
||||
<div className="cursor-pointer" onClick={() => { setShowMenu(!showMenu) }}>
|
||||
{api.GetUserData().email}
|
||||
</div>
|
||||
<MenuList show={showMenu}>
|
||||
{/*<MenuListItem text="Account settings" onClick={() => { console.log("Account settings") }}/>*/}
|
||||
<MenuListItem text="Logout" onClick={async () => {
|
||||
await api.Logout();
|
||||
navigate("/")
|
||||
}}/>
|
||||
</MenuList>
|
||||
</div>
|
||||
</>
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
35
web-app/src/components/SideMenu.tsx
Normal file
35
web-app/src/components/SideMenu.tsx
Normal file
|
@ -0,0 +1,35 @@
|
|||
import {useState} from "react"
|
||||
|
||||
interface SideMenuProps {
|
||||
items: {
|
||||
label: string
|
||||
}[]
|
||||
onChange: (index: number) => void
|
||||
}
|
||||
|
||||
export function SideMenu({items, onChange}: SideMenuProps) {
|
||||
|
||||
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="f-c p-2 gap-2 bg-neutral-600 rounded-md text-left cursor-pointer">
|
||||
{items.map((item, index) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={index === selectedIndex
|
||||
? "bg-neutral-500 p-1 px-2 rounded-md whitespace-nowrap"
|
||||
: "p-1 px-2 hover:bg-neutral-500 duration-100 rounded-md whitespace-nowrap"}
|
||||
onClick={() => {
|
||||
setSelectedIndex(index)
|
||||
onChange(index)
|
||||
}}>{item.label}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
13
web-app/src/components/Spinner.tsx
Normal file
13
web-app/src/components/Spinner.tsx
Normal file
|
@ -0,0 +1,13 @@
|
|||
export interface SpinnerProps {
|
||||
size?: number
|
||||
}
|
||||
|
||||
export default function Spinner({size = 3}: SpinnerProps) {
|
||||
return (
|
||||
<div className="flex justify-center items-center">
|
||||
<div className="spinner-border animate-spin inline-block border-2 rounded-full" role="status" style={{width: size, height: size}}>
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
37
web-app/src/components/Tabs.tsx
Normal file
37
web-app/src/components/Tabs.tsx
Normal file
|
@ -0,0 +1,37 @@
|
|||
import {useState} from "react"
|
||||
|
||||
interface TabsProps {
|
||||
tabs: {
|
||||
name: string,
|
||||
renderer: JSX.Element
|
||||
}[]
|
||||
}
|
||||
|
||||
export default function Tabs({tabs}: TabsProps) {
|
||||
|
||||
const [selectedTab, SetSelectedTab] = useState(0)
|
||||
|
||||
const selectedButtonClassName = "border-b border-b-app-primary text-awary-primary box-border p-4 cursor-pointer"
|
||||
const unselectedButtonClassName = "border-b border-neutral-600 box-border p-4 cursor-pointer"
|
||||
|
||||
const bar = tabs.map((tab, index) =>
|
||||
<div
|
||||
key={index}
|
||||
className={index === selectedTab ? selectedButtonClassName : unselectedButtonClassName}
|
||||
onClick={() => {SetSelectedTab(index)}}
|
||||
>
|
||||
{tab.name}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-row border-b border-b-neutral-500">
|
||||
{bar}
|
||||
</div>
|
||||
<div>
|
||||
{tabs[selectedTab].renderer}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
12
web-app/src/components/TagSticker.tsx
Normal file
12
web-app/src/components/TagSticker.tsx
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { TagData } from "../core/Tag"
|
||||
|
||||
export function TagSticker({tag}: {tag: TagData}) {
|
||||
return (
|
||||
<div
|
||||
className="border border-neutral-400 rounded-md text-xs py-1 px-4"
|
||||
style={{color: tag.color, borderColor: tag.color}}
|
||||
>
|
||||
{tag.name}
|
||||
</div>
|
||||
)
|
||||
}
|
45
web-app/src/components/URLEdit.tsx
Normal file
45
web-app/src/components/URLEdit.tsx
Normal file
|
@ -0,0 +1,45 @@
|
|||
import {UseFormRegisterReturn} from "react-hook-form"
|
||||
|
||||
interface URLEditProps {
|
||||
state?: string,
|
||||
stateHandler?: (value: string) => void,
|
||||
registerMethod?: UseFormRegisterReturn,
|
||||
registerUrl?: UseFormRegisterReturn,
|
||||
label?: string,
|
||||
labelError?: string,
|
||||
isValid?: (value: string) => boolean
|
||||
value?: string
|
||||
onChange?: (value: string) => void
|
||||
required?: boolean
|
||||
type?: string
|
||||
minLength?: number
|
||||
className?: string
|
||||
inputClassName?: string
|
||||
}
|
||||
|
||||
export default function URLEdit({state, stateHandler, label, labelError, isValid, registerMethod, registerUrl, value, ...props}: URLEditProps) {
|
||||
|
||||
const inputProps = {
|
||||
className: `bg-neutral-700 text-white rounded-r-md p-2 flex-1 min-w-0 ${props.inputClassName}`,
|
||||
onChange: ((e: any) => props.onChange && props.onChange(e.target.value)),
|
||||
required: props.required,
|
||||
type: props.type,
|
||||
minLength: props.minLength,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`text-left min-w-0 ${props.className}`}>
|
||||
{label && <div className="text-sm mb-1 ml-1">{label}</div>}
|
||||
<div className="f-r">
|
||||
<select {...registerMethod} className="bg-neutral-700 text-white rounded-l-md p-2 border-r border-r-neutral-500">
|
||||
<option value="GET">GET</option>
|
||||
<option value="POST">POST</option>
|
||||
<option value="PUT">PUT</option>
|
||||
<option value="DELETE">DELETE</option>
|
||||
</select>
|
||||
<input {...inputProps} {...registerUrl}/>
|
||||
</div>
|
||||
{labelError && <div className="text-red-300">{labelError}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue