mirror of
https://github.com/DI0IK/homepage-plus.git
synced 2025-07-08 14:38:47 +00:00
first public source commit
This commit is contained in:
parent
1a4fbb9d42
commit
3914fee775
65 changed files with 4697 additions and 312 deletions
15
src/components/bookmarks/group.jsx
Normal file
15
src/components/bookmarks/group.jsx
Normal file
|
@ -0,0 +1,15 @@
|
|||
import List from "components/bookmarks/list";
|
||||
|
||||
export default function BookmarksGroup({ group }) {
|
||||
return (
|
||||
<div
|
||||
key={group.name}
|
||||
className="basis-full md:basis-1/2 lg:basis-1/3 xl:basis-1/4 flex-1 p-1"
|
||||
>
|
||||
<h2 className="text-theme-800 dark:text-theme-300 text-xl font-medium">
|
||||
{group.name}
|
||||
</h2>
|
||||
<List bookmarks={group.bookmarks} />
|
||||
</div>
|
||||
);
|
||||
}
|
23
src/components/bookmarks/item.jsx
Normal file
23
src/components/bookmarks/item.jsx
Normal file
|
@ -0,0 +1,23 @@
|
|||
export default function Item({ bookmark }) {
|
||||
const { hostname } = new URL(bookmark.href);
|
||||
|
||||
return (
|
||||
<li
|
||||
onClick={() => {
|
||||
window.open(bookmark.href, "_blank").focus();
|
||||
}}
|
||||
key={bookmark.name}
|
||||
className="mb-3 cursor-pointer flex rounded-md font-medium text-theme-700 hover:text-theme-800 dark:text-theme-200 dark:hover:text-theme-300 shadow-md shadow-theme-900/10 dark:shadow-theme-900 bg-white/50 hover:bg-theme-300/10 dark:bg-white/5 dark:hover:bg-white/10"
|
||||
>
|
||||
<div className="flex-shrink-0 flex items-center justify-center w-11 bg-theme-500/10 dark:bg-theme-900/50 text-theme-700 dark:text-theme-200 text-sm font-medium rounded-l-md">
|
||||
{bookmark.abbr}
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-between rounded-r-md ">
|
||||
<div className="flex-1 grow pl-3 py-2 text-xs">{bookmark.name}</div>
|
||||
<div className="px-2 py-2 truncate text-theme-500 dark:text-theme-400 opacity-50 text-xs">
|
||||
{hostname}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
11
src/components/bookmarks/list.jsx
Normal file
11
src/components/bookmarks/list.jsx
Normal file
|
@ -0,0 +1,11 @@
|
|||
import Item from "components/bookmarks/item";
|
||||
|
||||
export default function List({ bookmarks }) {
|
||||
return (
|
||||
<ul role="list" className="mt-3 flex flex-col">
|
||||
{bookmarks.map((bookmark) => (
|
||||
<Item key={bookmark.name} bookmark={bookmark} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
20
src/components/greeting.jsx
Normal file
20
src/components/greeting.jsx
Normal file
|
@ -0,0 +1,20 @@
|
|||
export default function Greeting() {
|
||||
const name = process.env.NEXT_PUBLIC_DISPLAY_NAME;
|
||||
const hour = new Date().getHours();
|
||||
|
||||
let day = "day";
|
||||
|
||||
if (hour < 12) {
|
||||
day = "morning";
|
||||
} else if (hour < 17) {
|
||||
day = "afternoon";
|
||||
} else {
|
||||
day = "evening";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="self-end grow text-2xl font-thin text-theme-800 dark:text-theme-200">
|
||||
Good {day}
|
||||
</div>
|
||||
);
|
||||
}
|
68
src/components/modal.jsx
Normal file
68
src/components/modal.jsx
Normal file
|
@ -0,0 +1,68 @@
|
|||
import { Fragment, useRef, useState, Children } from "react";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
|
||||
function classNames(...classes) {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
const Modal = ({ Toggle, Content }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const cancelButtonRef = useRef(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toggle open={open} setOpen={setOpen} />
|
||||
<Transition.Root show={open} as={Fragment}>
|
||||
<Dialog
|
||||
as="div"
|
||||
className="relative z-10"
|
||||
initialFocus={cancelButtonRef}
|
||||
onClose={setOpen}
|
||||
>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-theme-900/90 transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed z-10 inset-0 overflow-y-auto">
|
||||
<div className="flex items-center justify-center min-h-full">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative rounded-lg shadow-xl transform transition-all my-8 max-w-lg w-full">
|
||||
<Content open={open} setOpen={setOpen} />
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ModalToggle = ({ open, setOpen, children }) => (
|
||||
<div onClick={() => setOpen(!open)}>{children}</div>
|
||||
);
|
||||
|
||||
const ModalContent = ({ open, setOpen, children }) => (
|
||||
<div className="body">{children}</div>
|
||||
);
|
||||
|
||||
Modal.Toggle = ModalToggle;
|
||||
Modal.Content = ModalContent;
|
||||
|
||||
export default Modal;
|
15
src/components/services/group.jsx
Normal file
15
src/components/services/group.jsx
Normal file
|
@ -0,0 +1,15 @@
|
|||
import List from "components/services/list";
|
||||
|
||||
export default function ServicesGroup({ services }) {
|
||||
return (
|
||||
<div
|
||||
key={services.name}
|
||||
className="basis-full md:basis-1/2 lg:basis-1/3 xl:basis-1/4 flex-1 p-1"
|
||||
>
|
||||
<h2 className="text-theme-800 dark:text-theme-300 text-xl font-medium">
|
||||
{services.name}
|
||||
</h2>
|
||||
<List services={services.services} />
|
||||
</div>
|
||||
);
|
||||
}
|
58
src/components/services/item.jsx
Normal file
58
src/components/services/item.jsx
Normal file
|
@ -0,0 +1,58 @@
|
|||
import Image from "next/image";
|
||||
import { useState } from "react";
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
|
||||
import StatsList from "./stats/list";
|
||||
import Status from "./status";
|
||||
import Widget from "./widget";
|
||||
|
||||
export default function Item({ service }) {
|
||||
const [statsOpen, setStatsOpen] = useState(false);
|
||||
return (
|
||||
<li key={service.name} className="">
|
||||
<Disclosure>
|
||||
<div className="transition-all h-15 overflow-hidden mb-3 cursor-pointer p-1 rounded-md font-medium text-theme-700 hover:text-theme-800 dark:text-theme-200 dark:hover:text-theme-300 shadow-md shadow-theme-900/10 dark:shadow-theme-900 bg-white/50 hover:bg-theme-300/10 dark:bg-white/5 dark:hover:bg-white/10">
|
||||
<div className="flex">
|
||||
<div
|
||||
onClick={() => {
|
||||
window.open(service.href, "_blank").focus();
|
||||
}}
|
||||
className="flex-shrink-0 flex items-center justify-center w-12 "
|
||||
>
|
||||
<Image
|
||||
src={`https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${service.icon}`}
|
||||
width={32}
|
||||
height={32}
|
||||
alt="logo"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
onClick={() => {
|
||||
window.open(service.href, "_blank").focus();
|
||||
}}
|
||||
className="flex-1 flex items-center justify-between rounded-r-md "
|
||||
>
|
||||
<div className="flex-1 px-2 py-2 text-sm">
|
||||
{service.name}
|
||||
<p className="text-theme-500 dark:text-theme-400 text-xs font-extralight">{service.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
{service.container && (
|
||||
<Disclosure.Button as="div" className="flex-shrink-0 flex items-center justify-center w-12 ">
|
||||
<Status service={service} />
|
||||
</Disclosure.Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Disclosure.Panel>
|
||||
<div className="w-full">
|
||||
<StatsList service={service} />
|
||||
</div>
|
||||
</Disclosure.Panel>
|
||||
|
||||
{service.widget && <Widget service={service} />}
|
||||
</div>
|
||||
</Disclosure>
|
||||
</li>
|
||||
);
|
||||
}
|
11
src/components/services/list.jsx
Normal file
11
src/components/services/list.jsx
Normal file
|
@ -0,0 +1,11 @@
|
|||
import Item from "components/services/item";
|
||||
|
||||
export default function List({ services }) {
|
||||
return (
|
||||
<ul role="list" className="mt-3 flex flex-col">
|
||||
{services.map((service) => (
|
||||
<Item key={service.name} service={service} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
70
src/components/services/stats/list.jsx
Normal file
70
src/components/services/stats/list.jsx
Normal file
|
@ -0,0 +1,70 @@
|
|||
import useSWR from "swr";
|
||||
import { calculateCPUPercent, formatBytes } from "utils/stats-helpers";
|
||||
import Stat from "./stat";
|
||||
|
||||
export default function Stats({ service }) {
|
||||
// fast
|
||||
const { data: statusData, error: statusError } = useSWR(
|
||||
`/api/docker/status/${service.container}/${service.server || ""}`,
|
||||
{
|
||||
refreshInterval: 1500,
|
||||
}
|
||||
);
|
||||
|
||||
// takes a full second to collect stats
|
||||
const { data: statsData, error: statsError } = useSWR(
|
||||
`/api/docker/stats/${service.container}/${service.server || ""}`,
|
||||
{
|
||||
refreshInterval: 1500,
|
||||
}
|
||||
);
|
||||
|
||||
// handle errors first
|
||||
if (statsError || statusError) {
|
||||
return (
|
||||
<div className="flex flex-row w-full">
|
||||
<Stat label="STATUS" value="Error Fetching Data" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// handle the case where we get a docker error
|
||||
if (statusData.status !== "running") {
|
||||
return (
|
||||
<div className="flex flex-row w-full">
|
||||
<Stat label="STATUS" value="Error Fetching Data" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// handle the case where the container is offline
|
||||
if (statusData.status !== "running") {
|
||||
return (
|
||||
<div className="flex flex-row w-full">
|
||||
<Stat label="STATUS" value="Offline" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// handle the case where we don't have anything yet
|
||||
if (!statsData || !statusData) {
|
||||
return (
|
||||
<div className="flex flex-row w-full">
|
||||
<Stat label="CPU" value="-" />
|
||||
<Stat label="MEM" value="-" />
|
||||
<Stat label="RX" value="-" />
|
||||
<Stat label="TX" value="-" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// we have stats and the container is running
|
||||
return (
|
||||
<div className="flex flex-row w-full">
|
||||
<Stat label="CPU" value={calculateCPUPercent(statsData.stats) + "%"} />
|
||||
<Stat label="MEM" value={formatBytes(statsData.stats.memory_stats.usage, 0)} />
|
||||
<Stat label="RX" value={formatBytes(statsData.stats.networks.eth0.rx_bytes, 0)} />
|
||||
<Stat label="TX" value={formatBytes(statsData.stats.networks.eth0.tx_bytes, 0)} />
|
||||
</div>
|
||||
);
|
||||
}
|
8
src/components/services/stats/stat.jsx
Normal file
8
src/components/services/stats/stat.jsx
Normal file
|
@ -0,0 +1,8 @@
|
|||
export default function Stat({ value, label }) {
|
||||
return (
|
||||
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
|
||||
<div className="font-thin text-sm">{value}</div>
|
||||
<div className="font-bold text-xs">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
29
src/components/services/status.jsx
Normal file
29
src/components/services/status.jsx
Normal file
|
@ -0,0 +1,29 @@
|
|||
import useSWR from "swr";
|
||||
|
||||
export default function Status({ service }) {
|
||||
const { data, error } = useSWR(
|
||||
`/api/docker/status/${service.container}/${service.server || ""}`
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="w-3 h-3 bg-rose-300 dark:bg-rose-500 rounded-full" />
|
||||
);
|
||||
}
|
||||
|
||||
if (data && data.status === "running") {
|
||||
return (
|
||||
<div className="w-3 h-3 bg-emerald-300 dark:bg-emerald-500 rounded-full" />
|
||||
);
|
||||
}
|
||||
|
||||
if (data && data.status === "not found") {
|
||||
return (
|
||||
<>
|
||||
<div className="h-2.5 w-2.5 bg-orange-400/50 dark:bg-yellow-200/40 -rotate-45"></div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className="w-3 h-3 bg-black/20 dark:bg-white/40 rounded-full" />;
|
||||
}
|
27
src/components/services/widget.jsx
Normal file
27
src/components/services/widget.jsx
Normal file
|
@ -0,0 +1,27 @@
|
|||
import Sonarr from "./widgets/sonarr";
|
||||
import Radarr from "./widgets/radarr";
|
||||
import Ombi from "./widgets/ombi";
|
||||
import Portainer from "./widgets/portainer";
|
||||
|
||||
const widgetMappings = {
|
||||
sonarr: Sonarr,
|
||||
radarr: Radarr,
|
||||
ombi: Ombi,
|
||||
portainer: Portainer,
|
||||
};
|
||||
|
||||
export default function Widget({ service }) {
|
||||
const ServiceWidget = widgetMappings[service.widget.type];
|
||||
|
||||
if (ServiceWidget) {
|
||||
return <ServiceWidget service={service} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
|
||||
<div className="font-thin text-sm">
|
||||
Missing Widget Type: <strong>{service.widget.type}</strong>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
71
src/components/services/widgets/ombi.jsx
Normal file
71
src/components/services/widgets/ombi.jsx
Normal file
|
@ -0,0 +1,71 @@
|
|||
import useSWR from "swr";
|
||||
|
||||
export default function Ombi({ service }) {
|
||||
const config = service.widget;
|
||||
|
||||
function buildApiUrl(endpoint) {
|
||||
const { url } = config;
|
||||
return `${url}/api/v1/${endpoint}`;
|
||||
}
|
||||
|
||||
const fetcher = (url) => {
|
||||
return fetch(url, {
|
||||
method: "GET",
|
||||
withCredentials: true,
|
||||
credentials: "include",
|
||||
headers: {
|
||||
ApiKey: `${config.key}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}).then((res) => res.json());
|
||||
};
|
||||
|
||||
const { data: statsData, error: statsError } = useSWR(
|
||||
buildApiUrl(`Request/count`),
|
||||
fetcher
|
||||
);
|
||||
|
||||
if (statsError) {
|
||||
return (
|
||||
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
|
||||
<div className="font-thin text-sm">Ombi API Error</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!statsData) {
|
||||
return (
|
||||
<div className="flex flex-row w-full">
|
||||
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
|
||||
<div className="font-thin text-sm">-</div>
|
||||
<div className="font-bold text-xs">COMPLETED</div>
|
||||
</div>
|
||||
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
|
||||
<div className="font-thin text-sm">-</div>
|
||||
<div className="font-bold text-xs">QUEUED</div>
|
||||
</div>
|
||||
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
|
||||
<div className="font-thin text-sm">-</div>
|
||||
<div className="font-bold text-xs">TOTAL</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-row w-full">
|
||||
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
|
||||
<div className="font-thin text-sm">{statsData.pending}</div>
|
||||
<div className="font-bold text-xs">PENDING</div>
|
||||
</div>
|
||||
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
|
||||
<div className="font-thin text-sm">{statsData.approved}</div>
|
||||
<div className="font-bold text-xs">APPROVED</div>
|
||||
</div>
|
||||
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
|
||||
<div className="font-thin text-sm">{statsData.available}</div>
|
||||
<div className="font-bold text-xs">AVAILABLE</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
81
src/components/services/widgets/portainer.jsx
Normal file
81
src/components/services/widgets/portainer.jsx
Normal file
|
@ -0,0 +1,81 @@
|
|||
import useSWR from "swr";
|
||||
|
||||
export default function Portainer({ service }) {
|
||||
const config = service.widget;
|
||||
|
||||
function buildApiUrl(endpoint) {
|
||||
const { url, env } = config;
|
||||
const reqUrl = new URL(`/api/endpoints/${env}/${endpoint}`, url);
|
||||
return `/api/proxy?url=${encodeURIComponent(reqUrl)}`;
|
||||
}
|
||||
|
||||
const fetcher = (url) => {
|
||||
return fetch(url, {
|
||||
method: "GET",
|
||||
withCredentials: true,
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"X-API-Key": `${config.key}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}).then((res) => res.json());
|
||||
};
|
||||
|
||||
const { data: containersData, error: containersError } = useSWR(buildApiUrl(`docker/containers/json`), fetcher);
|
||||
|
||||
if (containersError) {
|
||||
return (
|
||||
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
|
||||
<div className="font-thin text-sm">Portainer API Error</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!containersData) {
|
||||
return (
|
||||
<div className="flex flex-row w-full">
|
||||
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
|
||||
<div className="font-thin text-sm">-</div>
|
||||
<div className="font-bold text-xs">RUNNING</div>
|
||||
</div>
|
||||
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
|
||||
<div className="font-thin text-sm">-</div>
|
||||
<div className="font-bold text-xs">STOPPED</div>
|
||||
</div>
|
||||
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
|
||||
<div className="font-thin text-sm">-</div>
|
||||
<div className="font-bold text-xs">TOTAL</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (containersData.error) {
|
||||
return (
|
||||
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
|
||||
<div className="font-thin text-sm">Portainer API Error</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const running = containersData.filter((c) => c.State === "running").length;
|
||||
const stopped = containersData.filter((c) => c.State === "exited").length;
|
||||
const total = containersData.length;
|
||||
|
||||
return (
|
||||
<div className="flex flex-row w-full">
|
||||
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
|
||||
<div className="font-thin text-sm">{running}</div>
|
||||
<div className="font-bold text-xs">RUNNING</div>
|
||||
</div>
|
||||
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
|
||||
<div className="font-thin text-sm">{stopped}</div>
|
||||
<div className="font-bold text-xs">STOPPED</div>
|
||||
</div>
|
||||
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
|
||||
<div className="font-thin text-sm">{total}</div>
|
||||
<div className="font-bold text-xs">TOTAL</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
63
src/components/services/widgets/radarr.jsx
Normal file
63
src/components/services/widgets/radarr.jsx
Normal file
|
@ -0,0 +1,63 @@
|
|||
import useSWR from "swr";
|
||||
|
||||
export default function Radarr({ service }) {
|
||||
const config = service.widget;
|
||||
|
||||
function buildApiUrl(endpoint) {
|
||||
const { url, key } = config;
|
||||
return `${url}/api/v3/${endpoint}?apikey=${key}`;
|
||||
}
|
||||
|
||||
const { data: moviesData, error: moviesError } = useSWR(buildApiUrl("movie"));
|
||||
|
||||
const { data: queuedData, error: queuedError } = useSWR(
|
||||
buildApiUrl("queue/status")
|
||||
);
|
||||
|
||||
if (moviesError || queuedError) {
|
||||
return (
|
||||
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
|
||||
<div className="font-thin text-sm">Radarr API Error</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!moviesData || !queuedData) {
|
||||
return (
|
||||
<div className="flex flex-row w-full">
|
||||
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
|
||||
<div className="font-thin text-sm">-</div>
|
||||
<div className="font-bold text-xs">WANTED</div>
|
||||
</div>
|
||||
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
|
||||
<div className="font-thin text-sm">-</div>
|
||||
<div className="font-bold text-xs">QUEUED</div>
|
||||
</div>
|
||||
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
|
||||
<div className="font-thin text-sm">-</div>
|
||||
<div className="font-bold text-xs">MOVIES</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const wanted = moviesData.filter((movie) => movie.isAvailable === false);
|
||||
const have = moviesData.filter((movie) => movie.isAvailable === true);
|
||||
|
||||
return (
|
||||
<div className="flex flex-row w-full">
|
||||
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
|
||||
<div className="font-thin text-sm">{wanted.length}</div>
|
||||
<div className="font-bold text-xs">WANTED</div>
|
||||
</div>
|
||||
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
|
||||
<div className="font-thin text-sm">{queuedData.totalCount}</div>
|
||||
<div className="font-bold text-xs">QUEUED</div>
|
||||
</div>
|
||||
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
|
||||
<div className="font-thin text-sm">{moviesData.length}</div>
|
||||
<div className="font-bold text-xs">MOVIES</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
53
src/components/services/widgets/sonarr.jsx
Normal file
53
src/components/services/widgets/sonarr.jsx
Normal file
|
@ -0,0 +1,53 @@
|
|||
import useSWR from "swr";
|
||||
|
||||
export default function Sonarr({ service }) {
|
||||
const config = service.widget;
|
||||
|
||||
function buildApiUrl(endpoint) {
|
||||
const { url, key } = config;
|
||||
return `${url}/api/v3/${endpoint}?apikey=${key}`;
|
||||
}
|
||||
|
||||
const { data: wantedData, error: wantedError } = useSWR(
|
||||
buildApiUrl("wanted/missing")
|
||||
);
|
||||
|
||||
const { data: queuedData, error: queuedError } = useSWR(buildApiUrl("queue"));
|
||||
|
||||
const { data: seriesData, error: seriesError } = useSWR(
|
||||
buildApiUrl("series")
|
||||
);
|
||||
|
||||
if (wantedError || queuedError || seriesError) {
|
||||
return (
|
||||
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
|
||||
<div className="font-thin text-sm">Sonarr API Error</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!wantedData || !queuedData || !seriesData) {
|
||||
return (
|
||||
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
|
||||
<div className="font-thin text-sm">Loading</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-row w-full">
|
||||
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
|
||||
<div className="font-thin text-sm">{wantedData.totalRecords}</div>
|
||||
<div className="font-bold text-xs">WANTED</div>
|
||||
</div>
|
||||
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
|
||||
<div className="font-thin text-sm">{queuedData.totalRecords}</div>
|
||||
<div className="font-bold text-xs">QUEUED</div>
|
||||
</div>
|
||||
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-col items-center justify-center p-1">
|
||||
<div className="font-thin text-sm">{seriesData.length}</div>
|
||||
<div className="font-bold text-xs">SERIES</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
35
src/components/theme-toggle.jsx
Normal file
35
src/components/theme-toggle.jsx
Normal file
|
@ -0,0 +1,35 @@
|
|||
import { useContext } from "react";
|
||||
import {
|
||||
MdDarkMode,
|
||||
MdLightMode,
|
||||
MdToggleOff,
|
||||
MdToggleOn,
|
||||
} from "react-icons/md";
|
||||
|
||||
import { ThemeContext } from "utils/theme-context";
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const { theme, setTheme } = useContext(ThemeContext);
|
||||
|
||||
if (!theme) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-full flex self-end">
|
||||
<MdLightMode className="text-theme-800 dark:text-theme-200 w-5 h-5 m-1.5" />
|
||||
{theme === "dark" ? (
|
||||
<MdToggleOn
|
||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||
className="text-theme-800 dark:text-theme-200 w-8 h-8 cursor-pointer"
|
||||
/>
|
||||
) : (
|
||||
<MdToggleOff
|
||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||
className="text-theme-800 dark:text-theme-200 w-8 h-8 cursor-pointer"
|
||||
/>
|
||||
)}
|
||||
<MdDarkMode className="text-theme-800 dark:text-theme-200 w-5 h-5 m-1.5" />
|
||||
</div>
|
||||
);
|
||||
}
|
21
src/components/widget.jsx
Normal file
21
src/components/widget.jsx
Normal file
|
@ -0,0 +1,21 @@
|
|||
import Weather from "components/widgets/weather/weather";
|
||||
import Resources from "components/widgets/resources/resources";
|
||||
|
||||
const widgetMappings = {
|
||||
weather: Weather,
|
||||
resources: Resources,
|
||||
};
|
||||
|
||||
export default function Widget({ widget }) {
|
||||
const ServiceWidget = widgetMappings[widget.type];
|
||||
|
||||
if (ServiceWidget) {
|
||||
return <ServiceWidget options={widget.options} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-none flex flex-row items-center justify-center">
|
||||
Missing <strong>{widget.type}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
114
src/components/widgets/resources/resources.jsx
Normal file
114
src/components/widgets/resources/resources.jsx
Normal file
|
@ -0,0 +1,114 @@
|
|||
import useSWR from "swr";
|
||||
import { FiHardDrive, FiCpu } from "react-icons/fi";
|
||||
import { FaMemory } from "react-icons/fa";
|
||||
|
||||
export default function Resources({ options }) {
|
||||
const { data, error } = useSWR(
|
||||
`/api/widgets/resources?disk=${options.disk}`,
|
||||
{
|
||||
refreshInterval: 1500,
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return <div>failed to load</div>;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<>
|
||||
{options.disk && (
|
||||
<div className="flex-none flex flex-row items-center justify-center mr-5">
|
||||
<FiHardDrive className="text-theme-800 dark:text-theme-200 w-5 h-5" />
|
||||
<div className="flex flex-col ml-3 text-left font-mono">
|
||||
<span className="text-theme-800 dark:text-theme-200 text-xs">
|
||||
- GB free
|
||||
</span>
|
||||
<span className="text-theme-800 dark:text-theme-200 text-xs">
|
||||
- GB used
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{options.cpu && (
|
||||
<div className="flex-none flex flex-row items-center justify-center mr-5">
|
||||
<FiCpu className="text-theme-800 dark:text-theme-200 w-5 h-5" />
|
||||
<div className="flex flex-col ml-3 text-left font-mono">
|
||||
<span className="text-theme-800 dark:text-theme-200 text-xs">
|
||||
- Usage
|
||||
</span>
|
||||
<span className="text-theme-800 dark:text-theme-200 text-xs">
|
||||
- Load
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{options.memory && (
|
||||
<div className="flex-none flex flex-row items-center justify-center mr-5">
|
||||
<FaMemory className="text-theme-800 dark:text-theme-200 w-5 h-5" />
|
||||
<div className="flex flex-col ml-3 text-left font-mono">
|
||||
<span className="text-theme-800 dark:text-theme-200 text-xs">
|
||||
- GB Used
|
||||
</span>
|
||||
<span className="text-theme-800 dark:text-theme-200 text-xs">
|
||||
- GB Free
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (data.error) {
|
||||
return <div className="flex flex-col items-center justify-center"></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{options.disk && (
|
||||
<div className="flex-none flex flex-row items-center justify-center mr-5">
|
||||
<FiHardDrive className="text-theme-800 dark:text-theme-200 w-5 h-5" />
|
||||
<div className="flex flex-col ml-3 text-left font-mono">
|
||||
<span className="text-theme-800 dark:text-theme-200 text-xs">
|
||||
{Math.round(data.drive.freeGb)} GB free
|
||||
</span>
|
||||
<span className="text-theme-800 dark:text-theme-200 text-xs">
|
||||
{Math.round(data.drive.totalGb)} GB used
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{options.cpu && (
|
||||
<div className="flex-none flex flex-row items-center justify-center mr-5">
|
||||
<FiCpu className="text-theme-800 dark:text-theme-200 w-5 h-5" />
|
||||
<div className="flex flex-col ml-3 text-left font-mono">
|
||||
<span className="text-theme-800 dark:text-theme-200 text-xs">
|
||||
{Math.round(data.cpu.usage)}% Usage
|
||||
</span>
|
||||
<span className="text-theme-800 dark:text-theme-200 text-xs">
|
||||
{Math.round(data.cpu.load * 100) / 100} Load
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{options.memory && (
|
||||
<div className="flex-none flex flex-row items-center justify-center mr-5">
|
||||
<FaMemory className="text-theme-800 dark:text-theme-200 w-5 h-5" />
|
||||
<div className="flex flex-col ml-3 text-left font-mono">
|
||||
<span className="text-theme-800 dark:text-theme-200 text-xs">
|
||||
{Math.round((data.memory.usedMemMb / 1024) * 100) / 100} GB Used
|
||||
</span>
|
||||
<span className="text-theme-800 dark:text-theme-200 text-xs">
|
||||
{Math.round((data.memory.freeMemMb / 1024) * 100) / 100} GB Free
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
9
src/components/widgets/weather/icon.jsx
Normal file
9
src/components/widgets/weather/icon.jsx
Normal file
|
@ -0,0 +1,9 @@
|
|||
import mapIcon from "utils/condition-map";
|
||||
|
||||
export default function Icon({ condition, timeOfDay }) {
|
||||
const Icon = mapIcon(condition, timeOfDay);
|
||||
|
||||
return (
|
||||
<Icon className="mt-2 w-10 h-10 text-theme-800 dark:text-theme-200"></Icon>
|
||||
);
|
||||
}
|
41
src/components/widgets/weather/weather.jsx
Normal file
41
src/components/widgets/weather/weather.jsx
Normal file
|
@ -0,0 +1,41 @@
|
|||
import useSWR from "swr";
|
||||
import Icon from "./icon";
|
||||
|
||||
export default function Weather({ options }) {
|
||||
const { data, error } = useSWR(
|
||||
`/api/widgets/weather?lat=${options.latitude}&lon=${options.longitude}&apiKey=${options.apiKey}&duration=${options.cache}`,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return <div>failed to load</div>;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <div className="flex flex-col items-center justify-center"></div>;
|
||||
}
|
||||
|
||||
if (data.error) {
|
||||
return <div className="flex flex-col items-center justify-center"></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="order-last grow flex-none flex flex-row items-center justify-end">
|
||||
<Icon
|
||||
condition={data.current.condition.code}
|
||||
timeOfDay={data.current.is_day ? "day" : "night"}
|
||||
/>
|
||||
<div className="flex flex-col ml-3 text-left">
|
||||
<span className="text-theme-800 dark:text-theme-200 text-sm">
|
||||
{Math.round(data.current.temp_f)}°
|
||||
</span>
|
||||
<span className="text-theme-800 dark:text-theme-200 text-xs">
|
||||
{data.current.condition.text}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue