m/cup
1
0
mirror of https://github.com/sergi0g/cup.git synced 2025-11-17 09:33:38 -05:00

Changed frontend from Liquid to React, fixed bug where server would check for updates twice

This commit is contained in:
Sergio
2024-09-01 19:52:20 +03:00
parent e7673c04db
commit 2f195f611c
34 changed files with 623 additions and 515 deletions

78
web/src/App.tsx Normal file
View File

@@ -0,0 +1,78 @@
import { MouseEvent, useState } from "react";
import Logo from "./components/Logo";
import Statistic from "./components/Statistic";
import Image from "./components/Image";
import { IconRefresh } from "@tabler/icons-react";
import { LastChecked } from "./components/LastChecked";
import Loading from "./components/Loading";
import { Data } from "./types";
function App() {
const [data, setData] = useState<Data | null>(null);
const theme = "neutral"; // Stupid, I know but I want both the dev and prod to work easily.
if (!data) return <Loading onLoad={setData} />;
const refresh = (event: MouseEvent) => {
const btn = event.currentTarget as HTMLButtonElement;
btn.disabled = true;
let request = new XMLHttpRequest();
request.onload = function () {
if (request.status === 200) {
window.location.reload();
}
};
request.open(
"GET",
process.env.NODE_ENV === "production"
? "/json"
: `http://${window.location.hostname}:8000/json`,
);
request.send();
};
return (
<div
className={`flex justify-center min-h-screen bg-${theme}-50 dark:bg-${theme}-950`}
>
<div className="lg:px-8 sm:px-6 px-4 max-w-[80rem] mx-auto h-full w-full">
<div className="flex flex-col max-w-[48rem] mx-auto h-full my-8">
<div className="flex items-center gap-1">
<h1 className="text-5xl lg:text-6xl font-bold dark:text-white">
Cup
</h1>
<Logo />
</div>
<div
className={`shadow-sm bg-white dark:bg-${theme}-900 rounded-md my-8`}
>
<dl className="lg:grid-cols-4 md:grid-cols-2 gap-1 grid-cols-1 grid overflow-hidden *:relative">
{Object.entries(data.metrics).map(([name, value]) => (
<Statistic name={name} value={value} key={name} />
))}
</dl>
</div>
<div
className={`shadow-sm bg-white dark:bg-${theme}-900 rounded-md my-8`}
>
<div
className={`flex justify-between items-center px-6 py-4 text-${theme}-500`}
>
<LastChecked datetime={data.last_updated} />
<button className="group" onClick={refresh}>
<IconRefresh className="-scale-x-100 group-disabled:animate-spin" />
</button>
</div>
<ul
className={`*:py-4 *:px-6 *:flex *:items-center *:gap-3 dark:divide-${theme}-800 divide-y dark:text-white`}
>
{Object.entries(data.images).map(([name, status]) => (
<Image name={name} status={status} key={name} />
))}
</ul>
</div>
</div>
</div>
</div>
);
}
export default App;

View File

@@ -0,0 +1,30 @@
import {
IconCircleArrowUpFilled,
IconCircleCheckFilled,
IconCube,
IconHelpCircleFilled,
} from "@tabler/icons-react";
export default function Image({
name,
status,
}: {
name: string;
status: boolean | null;
}) {
return (
<li className="break-all">
<IconCube className="size-6 shrink-0" />
{name}
{status == false && (
<IconCircleCheckFilled className="text-green-500 ml-auto size-6 shrink-0" />
)}
{status == true && (
<IconCircleArrowUpFilled className="text-blue-500 ml-auto size-6 shrink-0" />
)}
{status == null && (
<IconHelpCircleFilled className="text-gray-500 ml-auto size-6 shrink-0" />
)}
</li>
);
}

View File

@@ -0,0 +1,6 @@
import { intlFormatDistance } from "date-fns/intlFormatDistance";
export function LastChecked({ datetime }: { datetime: string }) {
const date = intlFormatDistance(new Date(datetime), new Date());
return <h3>Last checked: {date}</h3>;
}

View File

@@ -0,0 +1,34 @@
import { IconLoader2 } from "@tabler/icons-react";
import { Data } from "../types";
import Logo from "./Logo";
export default function Loading({ onLoad }: { onLoad: (data: Data) => void }) {
const theme = "neutral";
fetch(
process.env.NODE_ENV === "production"
? "/json"
: `http://${window.location.hostname}:8000/json`,
).then((response) => response.json().then((data) => onLoad(data)));
return (
<div
className={`flex justify-center min-h-screen bg-${theme}-50 dark:bg-${theme}-950`}
>
<div className="lg:px-8 sm:px-6 px-4 max-w-[80rem] mx-auto h-full w-full absolute overflow-hidden">
<div className="flex flex-col max-w-[48rem] mx-auto h-full my-8">
<div className="flex items-center gap-1">
<h1 className="text-5xl lg:text-6xl font-bold dark:text-white">
Cup
</h1>
<Logo />
</div>
<div
className={`h-full flex justify-center
items-center gap-1 text-${theme}-500 dark:text-${theme}-400`}
>
Loading <IconLoader2 className="animate-spin" />
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,54 @@
export default function Logo() {
return (
<svg viewBox="0 0 128 128" className="size-14 lg:size-16">
<path
style={{ fill: "#A6CFD6" }}
d="M65.12,17.55c-17.6-0.53-34.75,5.6-34.83,14.36c-0.04,5.2,1.37,18.6,3.62,48.68s2.25,33.58,3.5,34.95
c1.25,1.37,10.02,8.8,25.75,8.8s25.93-6.43,26.93-8.05c0.48-0.78,1.83-17.89,3.5-37.07c1.81-20.84,3.91-43.9,3.99-45.06
C97.82,30.66,94.2,18.43,65.12,17.55z"
/>
<path
style={{ fill: "#DCEDF6" }}
d="M41.4,45.29c-0.12,0.62,1.23,24.16,2.32,27.94c1.99,6.92,9.29,7.38,10.23,4.16
c0.9-3.07-0.38-29.29-0.38-29.29s-3.66-0.3-6.43-0.84C44,46.63,41.4,45.29,41.4,45.29z"
/>
<path
style={{ fill: "#6CA4AE" }}
d="M33.74,32.61c-0.26,8.83,20.02,12.28,30.19,12.22c13.56-0.09,29.48-4.29,29.8-11.7
S79.53,21.1,63.35,21.1C49.6,21.1,33.96,25.19,33.74,32.61z"
/>
<path
style={{ fill: "#DC0D27" }}
d="M84.85,13.1c-0.58,0.64-9.67,30.75-9.67,30.75s2.01-0.33,4-0.79c2.63-0.61,3.76-1.06,3.76-1.06
s7.19-22.19,7.64-23.09c0.45-0.9,21.61-7.61,22.31-7.93c0.7-0.32,1.39-0.4,1.46-0.78c0.06-0.38-2.34-6.73-3.11-6.73
C110.47,3.47,86.08,11.74,84.85,13.1z"
/>
<path
style={{ fill: "#8A1F0F" }}
d="M110.55,7.79c1.04,2.73,2.8,3.09,3.55,2.77c0.45-0.19,1.25-1.84,0.01-4.47
c-0.99-2.09-2.17-2.74-2.93-2.61C110.42,3.6,109.69,5.53,110.55,7.79z"
/>
<g>
<path
style={{ fill: "#8A1F0F" }}
d="M91.94,18.34c-0.22,0-0.44-0.11-0.58-0.3l-3.99-5.77c-0.22-0.32-0.14-0.75,0.18-0.97
c0.32-0.22,0.76-0.14,0.97,0.18l3.99,5.77c0.22,0.32,0.14,0.75-0.18,0.97C92.21,18.3,92.07,18.34,91.94,18.34z"
/>
</g>
<g>
<path
style={{ fill: "#8A1F0F" }}
d="M90.28,19.43c-0.18,0-0.35-0.07-0.49-0.2l-5.26-5.12c-0.28-0.27-0.28-0.71-0.01-0.99
c0.27-0.28,0.71-0.28,0.99-0.01l5.26,5.12c0.28,0.27,0.28,0.71,0.01,0.99C90.64,19.36,90.46,19.43,90.28,19.43z"
/>
</g>
<g>
<path
style={{ fill: "#8A1F0F" }}
d="M89.35,21.22c-0.12,0-0.25-0.03-0.36-0.1l-5.6-3.39c-0.33-0.2-0.44-0.63-0.24-0.96
c0.2-0.33,0.63-0.44,0.96-0.24l5.6,3.39c0.33,0.2,0.44,0.63,0.24,0.96C89.82,21.1,89.59,21.22,89.35,21.22z"
/>
</g>
</svg>
);
}

View File

@@ -0,0 +1,48 @@
import {
IconCircleArrowUpFilled,
IconCircleCheckFilled,
IconEyeFilled,
IconHelpCircleFilled,
} from "@tabler/icons-react";
export default function Statistic({
name,
value,
}: {
name: string;
value: number;
}) {
const theme = "neutral";
name = name.replaceAll("_", " ");
name = name.slice(0, 1).toUpperCase() + name.slice(1); // Capitalize name
return (
<div
className={`before:bg-${theme}-200 before:dark:bg-${theme}-800 after:bg-${theme}-200 after:dark:bg-${theme}-800 gi`}
>
<div className="xl:px-8 px-6 py-4 gap-y-2 gap-x-4 justify-between align-baseline flex flex-col h-full">
<dt
className={`text-${theme}-500 dark:text-${theme}-400 leading-6 font-medium`}
>
{name}
</dt>
<div className="flex gap-1 justify-between items-center">
<dd className="text-black dark:text-white tracking-tight leading-10 font-medium text-3xl w-full">
{value}
</dd>
{name == "Monitored images" && (
<IconEyeFilled className="size-6 text-black dark:text-white shrink-0" />
)}
{name == "Up to date" && (
<IconCircleCheckFilled className="size-6 text-green-500 shrink-0" />
)}
{name == "Update available" && (
<IconCircleArrowUpFilled className="size-6 text-blue-500 shrink-0" />
)}
{name == "Unknown" && (
<IconHelpCircleFilled className="size-6 text-gray-500 shrink-0" />
)}
</div>
</div>
</div>
);
}

54
web/src/index.css Normal file
View File

@@ -0,0 +1,54 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Kinda hacky, but thank you https://geary.co/internal-borders-css-grid/ */
.gi {
position: relative;
height: 100%;
}
.gi::before,
.gi::after {
content: "";
position: absolute;
z-index: 1;
}
.gi::before {
inline-size: 1px;
block-size: 100vh;
inset-inline-start: -0.125rem;
}
.gi::after {
inline-size: 100vw;
block-size: 1px;
inset-inline-start: 0;
inset-block-start: -0.12rem;
}
@supports (scrollbar-color: auto) {
html {
scrollbar-color: #707070 #343840;
}
}
@supports selector(::-webkit-scrollbar) {
html::-webkit-scrollbar {
width: 10px;
}
html::-webkit-scrollbar-track {
background: #343840;
}
html::-webkit-scrollbar-thumb {
background: #707070;
border-radius: 0.375rem;
}
html::-webkit-scrollbar-thumb:hover {
background: #b5b5b5;
}
}

10
web/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);

12
web/src/types.ts Normal file
View File

@@ -0,0 +1,12 @@
export type Data = {
metrics: {
monitored_images: number;
up_to_date: number;
update_available: number;
unknown: number;
};
images: {
[key: string]: boolean | null;
};
last_updated: string;
};

1
web/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />