mirror of
https://github.com/sergi0g/cup.git
synced 2025-11-11 14:43:49 -05:00
Added tooltips, centralized theme declaration, fixed some eslint errors
Some checks failed
CI / build-binary (push) Has been cancelled
CI / build-image (push) Has been cancelled
Deploy github pages / build (push) Has been cancelled
Deploy github pages / deploy (push) Has been cancelled
Some checks failed
CI / build-binary (push) Has been cancelled
CI / build-image (push) Has been cancelled
Deploy github pages / build (push) Has been cancelled
Deploy github pages / deploy (push) Has been cancelled
This commit is contained in:
BIN
web/bun.lockb
BIN
web/bun.lockb
Binary file not shown.
@@ -11,10 +11,14 @@
|
|||||||
"fmt": "prettier --write ."
|
"fmt": "prettier --write ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-tooltip": "^1.1.2",
|
||||||
"@tabler/icons-react": "^3.14.0",
|
"@tabler/icons-react": "^3.14.0",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1",
|
||||||
|
"tailwind-merge": "^2.5.2",
|
||||||
|
"tailwindcss-animate": "^1.0.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.9.0",
|
"@eslint/js": "^9.9.0",
|
||||||
|
|||||||
@@ -1,33 +1,16 @@
|
|||||||
import { MouseEvent, useState } from "react";
|
import { useState } from "react";
|
||||||
import Logo from "./components/Logo";
|
import Logo from "./components/Logo";
|
||||||
import Statistic from "./components/Statistic";
|
import Statistic from "./components/Statistic";
|
||||||
import Image from "./components/Image";
|
import Image from "./components/Image";
|
||||||
import { LastChecked } from "./components/LastChecked";
|
import { LastChecked } from "./components/LastChecked";
|
||||||
import Loading from "./components/Loading";
|
import Loading from "./components/Loading";
|
||||||
import { Data } from "./types";
|
import { Data } from "./types";
|
||||||
|
import { theme } from "./theme";
|
||||||
|
import RefreshButton from "./components/RefreshButton";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [data, setData] = useState<Data | null>(null);
|
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} />;
|
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 = () => {
|
|
||||||
if (request.status === 200) {
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
request.open(
|
|
||||||
"GET",
|
|
||||||
process.env.NODE_ENV === "production"
|
|
||||||
? "/refresh"
|
|
||||||
: `http://${window.location.hostname}:8000/refresh`
|
|
||||||
);
|
|
||||||
request.send();
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`flex justify-center min-h-screen bg-${theme}-50 dark:bg-${theme}-950`}
|
className={`flex justify-center min-h-screen bg-${theme}-50 dark:bg-${theme}-950`}
|
||||||
@@ -56,24 +39,7 @@ function App() {
|
|||||||
className={`flex justify-between items-center px-6 py-4 text-${theme}-500`}
|
className={`flex justify-between items-center px-6 py-4 text-${theme}-500`}
|
||||||
>
|
>
|
||||||
<LastChecked datetime={data.last_updated} />
|
<LastChecked datetime={data.last_updated} />
|
||||||
<button className="group" onClick={refresh}>
|
<RefreshButton />
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
className="group-disabled:animate-spin"
|
|
||||||
>
|
|
||||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
|
||||||
<path d="M4,11A8.1,8.1 0 0 1 19.5,9M20,5v4h-4" />
|
|
||||||
<path d="M20,13A8.1,8.1 0 0 1 4.5,15M4,19v-4h4" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<ul
|
<ul
|
||||||
className={`*:py-4 *:px-6 *:flex *:items-center *:gap-3 dark:divide-${theme}-800 divide-y dark:text-white`}
|
className={`*:py-4 *:px-6 *:flex *:items-center *:gap-3 dark:divide-${theme}-800 divide-y dark:text-white`}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
IconCube,
|
IconCube,
|
||||||
IconHelpCircleFilled,
|
IconHelpCircleFilled,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
|
import { WithTooltip } from "./Tooltip";
|
||||||
|
|
||||||
export default function Image({
|
export default function Image({
|
||||||
name,
|
name,
|
||||||
@@ -17,13 +18,28 @@ export default function Image({
|
|||||||
<IconCube className="size-6 shrink-0" />
|
<IconCube className="size-6 shrink-0" />
|
||||||
{name}
|
{name}
|
||||||
{status == false && (
|
{status == false && (
|
||||||
<IconCircleCheckFilled className="text-green-500 ml-auto size-6 shrink-0" />
|
<WithTooltip
|
||||||
|
text="Up to date"
|
||||||
|
className="text-green-500 ml-auto size-6 shrink-0"
|
||||||
|
>
|
||||||
|
<IconCircleCheckFilled />
|
||||||
|
</WithTooltip>
|
||||||
)}
|
)}
|
||||||
{status == true && (
|
{status == true && (
|
||||||
<IconCircleArrowUpFilled className="text-blue-500 ml-auto size-6 shrink-0" />
|
<WithTooltip
|
||||||
|
text="Update available"
|
||||||
|
className="text-blue-500 ml-auto size-6 shrink-0"
|
||||||
|
>
|
||||||
|
<IconCircleArrowUpFilled />
|
||||||
|
</WithTooltip>
|
||||||
)}
|
)}
|
||||||
{status == null && (
|
{status == null && (
|
||||||
<IconHelpCircleFilled className="text-gray-500 ml-auto size-6 shrink-0" />
|
<WithTooltip
|
||||||
|
text="Unknown"
|
||||||
|
className="text-gray-500 ml-auto size-6 shrink-0"
|
||||||
|
>
|
||||||
|
<IconHelpCircleFilled />
|
||||||
|
</WithTooltip>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { IconLoader2 } from "@tabler/icons-react";
|
import { IconLoader2 } from "@tabler/icons-react";
|
||||||
import { Data } from "../types";
|
import { Data } from "../types";
|
||||||
import Logo from "./Logo";
|
import Logo from "./Logo";
|
||||||
|
import { theme } from "../theme";
|
||||||
|
|
||||||
export default function Loading({ onLoad }: { onLoad: (data: Data) => void }) {
|
export default function Loading({ onLoad }: { onLoad: (data: Data) => void }) {
|
||||||
const theme = "neutral";
|
|
||||||
fetch(
|
fetch(
|
||||||
process.env.NODE_ENV === "production"
|
process.env.NODE_ENV === "production"
|
||||||
? "/json"
|
? "/json"
|
||||||
: `http://${window.location.hostname}:8000/json`,
|
: `http://${window.location.hostname}:8000/json`,
|
||||||
).then((response) => response.json().then((data) => onLoad(data)));
|
).then((response) => response.json().then((data) => {onLoad(data as Data)}));
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`flex justify-center min-h-screen bg-${theme}-50 dark:bg-${theme}-950`}
|
className={`flex justify-center min-h-screen bg-${theme}-50 dark:bg-${theme}-950`}
|
||||||
|
|||||||
45
web/src/components/RefreshButton.tsx
Normal file
45
web/src/components/RefreshButton.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { MouseEvent } from "react";
|
||||||
|
import { WithTooltip } from "./Tooltip";
|
||||||
|
|
||||||
|
export default function RefreshButton() {
|
||||||
|
const refresh = (event: MouseEvent) => {
|
||||||
|
const btn = event.currentTarget as HTMLButtonElement;
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
const request = new XMLHttpRequest();
|
||||||
|
request.onload = () => {
|
||||||
|
if (request.status === 200) {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
request.open(
|
||||||
|
"GET",
|
||||||
|
process.env.NODE_ENV === "production"
|
||||||
|
? "/refresh"
|
||||||
|
: `http://${window.location.hostname}:8000/refresh`,
|
||||||
|
);
|
||||||
|
request.send();
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<WithTooltip text="Reload">
|
||||||
|
<button className="group" onClick={refresh}>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="group-disabled:animate-spin"
|
||||||
|
>
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M4,11A8.1,8.1 0 0 1 19.5,9M20,5v4h-4" />
|
||||||
|
<path d="M20,13A8.1,8.1 0 0 1 4.5,15M4,19v-4h4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</WithTooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
IconEyeFilled,
|
IconEyeFilled,
|
||||||
IconHelpCircleFilled,
|
IconHelpCircleFilled,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
|
import { theme } from "../theme";
|
||||||
|
|
||||||
export default function Statistic({
|
export default function Statistic({
|
||||||
name,
|
name,
|
||||||
@@ -12,7 +13,6 @@ export default function Statistic({
|
|||||||
name: string;
|
name: string;
|
||||||
value: number;
|
value: number;
|
||||||
}) {
|
}) {
|
||||||
const theme = "neutral";
|
|
||||||
name = name.replaceAll("_", " ");
|
name = name.replaceAll("_", " ");
|
||||||
name = name.slice(0, 1).toUpperCase() + name.slice(1); // Capitalize name
|
name = name.slice(0, 1).toUpperCase() + name.slice(1); // Capitalize name
|
||||||
return (
|
return (
|
||||||
|
|||||||
46
web/src/components/Tooltip.tsx
Normal file
46
web/src/components/Tooltip.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { Provider, Root, Trigger, Content } from "@radix-ui/react-tooltip";
|
||||||
|
import { cn } from "../utils";
|
||||||
|
import { forwardRef, ReactNode } from "react";
|
||||||
|
import { theme } from "../theme";
|
||||||
|
|
||||||
|
const TooltipContent = forwardRef<
|
||||||
|
React.ElementRef<typeof Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
`z-50 overflow-hidden rounded-md border border-${theme}-200 bg-white px-3 py-1.5 text-sm text-${theme}-950 shadow-md 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 dark:border-${theme}-800 dark:bg-${theme}-950 dark:text-${theme}-50`,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
|
TooltipContent.displayName = Content.displayName;
|
||||||
|
|
||||||
|
const WithTooltip = ({
|
||||||
|
children,
|
||||||
|
text,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
text: string;
|
||||||
|
className?: string;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Provider>
|
||||||
|
<Root>
|
||||||
|
<Trigger className={className} asChild>
|
||||||
|
{children}
|
||||||
|
</Trigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p className="text-black dark:text-white">{text}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Root>
|
||||||
|
</Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { WithTooltip };
|
||||||
1
web/src/theme.ts
Normal file
1
web/src/theme.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const theme = "neutral"; // Will be modified by server at runtime
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
export type Data = {
|
export interface Data {
|
||||||
metrics: {
|
metrics: {
|
||||||
monitored_images: number;
|
monitored_images: number;
|
||||||
up_to_date: number;
|
up_to_date: number;
|
||||||
update_available: number;
|
update_available: number;
|
||||||
unknown: number;
|
unknown: number;
|
||||||
};
|
};
|
||||||
images: {
|
images: Record<string, boolean | null>;
|
||||||
[key: string]: boolean | null;
|
|
||||||
};
|
|
||||||
last_updated: string;
|
last_updated: string;
|
||||||
};
|
};
|
||||||
|
|||||||
6
web/src/utils.ts
Normal file
6
web/src/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ export default {
|
|||||||
theme: {
|
theme: {
|
||||||
extend: {},
|
extend: {},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [require("tailwindcss-animate")],
|
||||||
safelist: [
|
safelist: [
|
||||||
// Generate minimum extra CSS
|
// Generate minimum extra CSS
|
||||||
{
|
{
|
||||||
@@ -33,5 +33,12 @@ export default {
|
|||||||
pattern: /divide-(gray|neutral)-800/,
|
pattern: /divide-(gray|neutral)-800/,
|
||||||
variants: ["dark"],
|
variants: ["dark"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
pattern: /border-(gray|neutral)-200/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: /border-(gray|neutral)-800/,
|
||||||
|
variants: ["dark"],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import react from "@vitejs/plugin-react-swc";
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
build: {
|
build: {
|
||||||
rollupOptions: { // https://stackoverflow.com/q/69614671/vite-without-hash-in-filename#75344943
|
rollupOptions: {
|
||||||
|
// https://stackoverflow.com/q/69614671/vite-without-hash-in-filename#75344943
|
||||||
output: {
|
output: {
|
||||||
entryFileNames: `assets/[name].js`,
|
entryFileNames: `assets/[name].js`,
|
||||||
chunkFileNames: `assets/[name].js`,
|
chunkFileNames: `assets/[name].js`,
|
||||||
|
|||||||
Reference in New Issue
Block a user