mirror of
https://github.com/sergi0g/cup.git
synced 2025-11-12 07:03:48 -05:00
Update frontend
This commit is contained in:
@@ -38,7 +38,7 @@ pub async fn serve(port: &u16, config: &Config) -> std::io::Result<()> {
|
|||||||
.at("/", get(handler_service(_static)))
|
.at("/", get(handler_service(_static)))
|
||||||
.at("/api/v1/simple", get(handler_service(api_simple)))
|
.at("/api/v1/simple", get(handler_service(api_simple)))
|
||||||
.at("/api/v1/full", get(handler_service(api_full)))
|
.at("/api/v1/full", get(handler_service(api_full)))
|
||||||
.at("/refresh", get(handler_service(refresh)))
|
.at("/api/v1/refresh", get(handler_service(refresh)))
|
||||||
.at("/*", get(handler_service(_static)))
|
.at("/*", get(handler_service(_static)))
|
||||||
.enclosed(Logger::new())
|
.enclosed(Logger::new())
|
||||||
.serve()
|
.serve()
|
||||||
|
|||||||
BIN
web/bun.lockb
BIN
web/bun.lockb
Binary file not shown.
@@ -11,6 +11,7 @@
|
|||||||
"fmt": "prettier --write ."
|
"fmt": "prettier --write ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@headlessui/react": "^2.1.10",
|
||||||
"@radix-ui/react-tooltip": "^1.1.2",
|
"@radix-ui/react-tooltip": "^1.1.2",
|
||||||
"@tabler/icons-react": "^3.14.0",
|
"@tabler/icons-react": "^3.14.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|||||||
@@ -45,10 +45,10 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
<Search onChange={setSearchQuery}/>
|
<Search onChange={setSearchQuery}/>
|
||||||
<ul
|
<ul
|
||||||
className={`*:py-4 *:px-6 *:flex *:items-center *:gap-3 dark:divide-${theme}-800 divide-y dark:text-white`}
|
className={`dark:divide-${theme}-800 divide-y dark:text-white`}
|
||||||
>
|
>
|
||||||
{Object.entries(data.images).filter(([name]) => name.includes(searchQuery)).map(([name, status]) => (
|
{data.images.filter((image) => image.reference.includes(searchQuery)).map((image) => (
|
||||||
<Image name={name} status={status} key={name} />
|
<Image data={image} key={image.reference} />
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,23 +1,60 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogBackdrop,
|
||||||
|
DialogPanel,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@headlessui/react";
|
||||||
|
import {
|
||||||
|
IconAlertTriangleFilled,
|
||||||
|
IconArrowUpRight,
|
||||||
IconCircleArrowUpFilled,
|
IconCircleArrowUpFilled,
|
||||||
IconCircleCheckFilled,
|
IconCircleCheckFilled,
|
||||||
IconCube,
|
IconCube,
|
||||||
IconHelpCircleFilled,
|
IconHelpCircleFilled,
|
||||||
|
IconStopwatch,
|
||||||
|
IconX,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { WithTooltip } from "./Tooltip";
|
import { WithTooltip } from "./Tooltip";
|
||||||
|
import type { Image } from "../types";
|
||||||
|
import { theme } from "../theme";
|
||||||
|
|
||||||
export default function Image({
|
const clickable_registries = [
|
||||||
name,
|
"registry-1.docker.io",
|
||||||
status,
|
"ghcr.io",
|
||||||
}: {
|
"quay.io",
|
||||||
name: string;
|
"gcr.io",
|
||||||
status: boolean | null;
|
]; // Not all registries redirect to an info page when visiting the image reference in a browser (e.g. Gitea and derivatives), so we only enable clicking those who do.
|
||||||
}) {
|
|
||||||
|
export default function Image({ data }: { data: Image }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const handleOpen = () => {
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
const handleClose = () => {
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
var url: string | null = null;
|
||||||
|
if (clickable_registries.includes(data.parts.registry)) {
|
||||||
|
switch (data.parts.registry) {
|
||||||
|
case "registry-1.docker.io":
|
||||||
|
url = `https://hub.docker.com/r/${data.parts.repository}`;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
url = `https://${data.parts.registry}/${data.parts.repository}`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={handleOpen}
|
||||||
|
className={`*:py-4 *:px-6 *:flex *:items-center *:gap-3 w-full`}
|
||||||
|
>
|
||||||
<li className="break-all">
|
<li className="break-all">
|
||||||
<IconCube className="size-6 shrink-0" />
|
<IconCube className="size-6 shrink-0" />
|
||||||
{name}
|
{data.reference}
|
||||||
{status == false && (
|
{data.result.has_update == false && (
|
||||||
<WithTooltip
|
<WithTooltip
|
||||||
text="Up to date"
|
text="Up to date"
|
||||||
className="text-green-500 ml-auto size-6 shrink-0"
|
className="text-green-500 ml-auto size-6 shrink-0"
|
||||||
@@ -25,7 +62,7 @@ export default function Image({
|
|||||||
<IconCircleCheckFilled />
|
<IconCircleCheckFilled />
|
||||||
</WithTooltip>
|
</WithTooltip>
|
||||||
)}
|
)}
|
||||||
{status == true && (
|
{data.result.has_update == true && (
|
||||||
<WithTooltip
|
<WithTooltip
|
||||||
text="Update available"
|
text="Update available"
|
||||||
className="text-blue-500 ml-auto size-6 shrink-0"
|
className="text-blue-500 ml-auto size-6 shrink-0"
|
||||||
@@ -33,7 +70,7 @@ export default function Image({
|
|||||||
<IconCircleArrowUpFilled />
|
<IconCircleArrowUpFilled />
|
||||||
</WithTooltip>
|
</WithTooltip>
|
||||||
)}
|
)}
|
||||||
{status == null && (
|
{data.result.has_update == null && (
|
||||||
<WithTooltip
|
<WithTooltip
|
||||||
text="Unknown"
|
text="Unknown"
|
||||||
className="text-gray-500 ml-auto size-6 shrink-0"
|
className="text-gray-500 ml-auto size-6 shrink-0"
|
||||||
@@ -42,5 +79,99 @@ export default function Image({
|
|||||||
</WithTooltip>
|
</WithTooltip>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
|
</button>
|
||||||
|
<Dialog open={open} onClose={setOpen} className="relative z-10">
|
||||||
|
<DialogBackdrop
|
||||||
|
transition
|
||||||
|
className={`fixed inset-0 bg-${theme}-500 dark:bg-${theme}-950 !bg-opacity-75 transition-opacity data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in`}
|
||||||
|
/>
|
||||||
|
<div className="fixed inset-0 z-10 w-screen overflow-y-auto">
|
||||||
|
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||||
|
<DialogPanel
|
||||||
|
transition
|
||||||
|
className={`relative transform overflow-hidden rounded-lg bg-white dark:bg-${theme}-900 dark:text-white text-left shadow-xl transition-all data-[closed]:translate-y-4 data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in sm:my-8 sm:w-full sm:max-w-lg md:max-w-xl lg:max-w-2xl data-[closed]:sm:translate-y-0 data-[closed]:sm:scale-95`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`py-4 px-6 flex flex-col gap-3 text-${theme}-400 dark:text-${theme}-600`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<IconCube className="size-6 shrink-0 text-black dark:text-white" />
|
||||||
|
<DialogTitle className="text-black dark:text-white">
|
||||||
|
{url ? (
|
||||||
|
<>
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-block after:bg-white after:h-[2px] after:bottom-[4px] after:left-0 after:scale-x-0 after:block after:relative after:w-full after:transition-transform after:duration-300 hover:after:scale-x-100"
|
||||||
|
>
|
||||||
|
{data.reference}
|
||||||
|
</a>
|
||||||
|
<IconArrowUpRight className="inline-block size-3" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
data.reference
|
||||||
|
)}
|
||||||
|
</DialogTitle>
|
||||||
|
<button onClick={handleClose} className="ml-auto">
|
||||||
|
<IconX className="size-6 shrink-0 text-gray-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{data.result.has_update == false && (
|
||||||
|
<>
|
||||||
|
<IconCircleCheckFilled className="text-green-500 size-6 shrink-0" />
|
||||||
|
Up to date
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{data.result.has_update == true && (
|
||||||
|
<>
|
||||||
|
<IconCircleArrowUpFilled className="text-blue-500 size-6 shrink-0" />
|
||||||
|
Update available
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{data.result.has_update == null && (
|
||||||
|
<>
|
||||||
|
<IconHelpCircleFilled className="text-gray-500 size-6 shrink-0" />
|
||||||
|
Unknown
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<IconStopwatch className="text-gray-500 size-6 shrink-0" />
|
||||||
|
Checked in {data.time} ms
|
||||||
|
</div>
|
||||||
|
{data.result.error && (
|
||||||
|
<div className="bg-yellow-400/10 flex items-center gap-3 overflow-hidden break-all rounded-md px-3 py-2">
|
||||||
|
<IconAlertTriangleFilled className="text-yellow-500 size-5 shrink-0" />
|
||||||
|
{data.result.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
Local digests
|
||||||
|
<div
|
||||||
|
className={`bg-${theme}-50 dark:bg-${theme}-950 text-gray-500 rounded-md px-3 py-2 font-mono scrollable`}
|
||||||
|
>
|
||||||
|
<p className="overflow-x-scroll">
|
||||||
|
{data.local_digests.join("\n")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{data.remote_digest && (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
Remote digest
|
||||||
|
<div
|
||||||
|
className={`bg-${theme}-50 dark:bg-${theme}-950 text-gray-500 rounded-md px-3 py-2 font-mono`}
|
||||||
|
>
|
||||||
|
<p className="overflow-x-scroll">{data.remote_digest}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogPanel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { theme } from "../theme";
|
|||||||
export default function Loading({ onLoad }: { onLoad: (data: Data) => void }) {
|
export default function Loading({ onLoad }: { onLoad: (data: Data) => void }) {
|
||||||
fetch(
|
fetch(
|
||||||
process.env.NODE_ENV === "production"
|
process.env.NODE_ENV === "production"
|
||||||
? "/json"
|
? "/api/v1/full"
|
||||||
: `http://${window.location.hostname}:8000/json`,
|
: `http://${window.location.hostname}:8000/api/v1/full`,
|
||||||
).then((response) => response.json().then((data) => {onLoad(data as Data)}));
|
).then((response) => response.json().then((data) => {onLoad(data as Data)}));
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ export default function RefreshButton() {
|
|||||||
request.open(
|
request.open(
|
||||||
"GET",
|
"GET",
|
||||||
process.env.NODE_ENV === "production"
|
process.env.NODE_ENV === "production"
|
||||||
? "/refresh"
|
? "/api/v1/refresh"
|
||||||
: `http://${window.location.hostname}:8000/refresh`,
|
: `http://${window.location.hostname}:8000/api/v1/refresh`,
|
||||||
);
|
);
|
||||||
request.send();
|
request.send();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const TooltipContent = forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
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`,
|
`z-50 overflow-hidden rounded-md border border-${theme}-200 dark:border-${theme}-800 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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -5,6 +5,22 @@ export interface Data {
|
|||||||
update_available: number;
|
update_available: number;
|
||||||
unknown: number;
|
unknown: number;
|
||||||
};
|
};
|
||||||
images: Record<string, boolean | null>;
|
images: Image[];
|
||||||
last_updated: string;
|
last_updated: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface Image {
|
||||||
|
reference: string,
|
||||||
|
parts: {
|
||||||
|
registry: string,
|
||||||
|
repository: string,
|
||||||
|
tag: string,
|
||||||
|
},
|
||||||
|
local_digests: string[],
|
||||||
|
remote_digest: string,
|
||||||
|
result: {
|
||||||
|
has_update: boolean | null,
|
||||||
|
error: string | null
|
||||||
|
},
|
||||||
|
time: number
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ export default {
|
|||||||
safelist: [
|
safelist: [
|
||||||
// Generate minimum extra CSS
|
// Generate minimum extra CSS
|
||||||
{
|
{
|
||||||
pattern: /bg-(gray|neutral)-50/,
|
pattern: /bg-(gray|neutral)-(50|500)/,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
pattern: /bg-(gray|neutral)-(900|950)/,
|
pattern: /bg-(gray|neutral)-(900|950)/,
|
||||||
@@ -39,10 +39,10 @@ export default {
|
|||||||
variants: ["dark"],
|
variants: ["dark"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
pattern: /border-(gray|neutral)-300/,
|
pattern: /border-(gray|neutral)-(200|300)/,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
pattern: /border-(gray|neutral)-700/,
|
pattern: /border-(gray|neutral)-(700|800)/,
|
||||||
variants: ["dark"],
|
variants: ["dark"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user