28 changed files with 1995 additions and 1674 deletions
@ -1,5 +1,8 @@
@@ -1,5 +1,8 @@
|
||||
{ |
||||
"editor.formatOnSave": true, |
||||
"editor.defaultFormatter": "dbaeumer.vscode-eslint", |
||||
"eslint.format.enable": true |
||||
"eslint.format.enable": true, |
||||
"[json]": { |
||||
"editor.defaultFormatter": "esbenp.prettier-vscode" |
||||
} |
||||
} |
||||
|
@ -1,20 +0,0 @@
@@ -1,20 +0,0 @@
|
||||
{ |
||||
"name": "movie-web", |
||||
"short_name": "movie-web", |
||||
"icons": [ |
||||
{ |
||||
"src": "/android-chrome-192x192.png", |
||||
"sizes": "192x192", |
||||
"type": "image/png" |
||||
}, |
||||
{ |
||||
"src": "/android-chrome-512x512.png", |
||||
"sizes": "512x512", |
||||
"type": "image/png" |
||||
} |
||||
], |
||||
"theme_color": "#E880C5", |
||||
"background_color": "#16171D", |
||||
"display": "standalone", |
||||
"start_url": "/" |
||||
} |
@ -0,0 +1,28 @@
@@ -0,0 +1,28 @@
|
||||
import { Icon, Icons } from "@/components/Icon"; |
||||
import { useBanner } from "@/hooks/useBanner"; |
||||
|
||||
export function Banner(props: { children: React.ReactNode; type: "error" }) { |
||||
const [ref] = useBanner<HTMLDivElement>("internet"); |
||||
const styles = { |
||||
error: "bg-[#C93957] text-white", |
||||
}; |
||||
const icons = { |
||||
error: Icons.CIRCLE_EXCLAMATION, |
||||
}; |
||||
|
||||
return ( |
||||
<div ref={ref}> |
||||
<div |
||||
className={[ |
||||
styles[props.type], |
||||
"flex items-center justify-center p-1", |
||||
].join(" ")} |
||||
> |
||||
<div className="flex items-center space-x-3"> |
||||
<Icon icon={icons[props.type]} /> |
||||
<div>{props.children}</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,61 @@
@@ -0,0 +1,61 @@
|
||||
import { |
||||
ReactNode, |
||||
createContext, |
||||
useState, |
||||
useMemo, |
||||
Dispatch, |
||||
SetStateAction, |
||||
useEffect, |
||||
useContext, |
||||
} from "react"; |
||||
import { useMeasure } from "react-use"; |
||||
|
||||
interface BannerInstance { |
||||
id: string; |
||||
height: number; |
||||
} |
||||
|
||||
const BannerContext = createContext< |
||||
[BannerInstance[], Dispatch<SetStateAction<BannerInstance[]>>] |
||||
>(null as any); |
||||
|
||||
export function BannerContextProvider(props: { children: ReactNode }) { |
||||
const [state, setState] = useState<BannerInstance[]>([]); |
||||
const memod = useMemo< |
||||
[BannerInstance[], Dispatch<SetStateAction<BannerInstance[]>>] |
||||
>(() => [state, setState], [state]); |
||||
|
||||
return ( |
||||
<BannerContext.Provider value={memod}> |
||||
{props.children} |
||||
</BannerContext.Provider> |
||||
); |
||||
} |
||||
|
||||
export function useBanner<T extends Element>(id: string) { |
||||
const [ref, { height }] = useMeasure<T>(); |
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [_, set] = useContext(BannerContext); |
||||
|
||||
useEffect(() => { |
||||
set((v) => [...v, { id, height: 0 }]); |
||||
set((value) => { |
||||
const v = value.find((item) => item.id === id); |
||||
if (v) { |
||||
v.height = height; |
||||
} |
||||
return value; |
||||
}); |
||||
return () => { |
||||
set((v) => v.filter((item) => item.id !== id)); |
||||
}; |
||||
}, [height, id, set]); |
||||
|
||||
return [ref]; |
||||
} |
||||
|
||||
export function useBannerSize() { |
||||
const [val] = useContext(BannerContext); |
||||
|
||||
return val.reduce((a, v) => a + v.height, 0); |
||||
} |
@ -0,0 +1,41 @@
@@ -0,0 +1,41 @@
|
||||
import { useEffect, useRef, useState } from "react"; |
||||
|
||||
export function useIsOnline() { |
||||
const [online, setOnline] = useState<boolean | null>(true); |
||||
const ref = useRef<boolean>(true); |
||||
|
||||
useEffect(() => { |
||||
let counter = 0; |
||||
|
||||
let abort: null | AbortController = null; |
||||
const interval = setInterval(() => { |
||||
// if online try once every 10 iterations intead of every iteration
|
||||
counter += 1; |
||||
if (ref.current) { |
||||
if (counter < 10) return; |
||||
} |
||||
counter = 0; |
||||
|
||||
if (abort) abort.abort(); |
||||
abort = new AbortController(); |
||||
const signal = abort.signal; |
||||
fetch("/ping.txt", { signal }) |
||||
.then(() => { |
||||
setOnline(true); |
||||
ref.current = true; |
||||
}) |
||||
.catch((err) => { |
||||
if (err.name === "AbortError") return; |
||||
setOnline(false); |
||||
ref.current = false; |
||||
}); |
||||
}, 5000); |
||||
|
||||
return () => { |
||||
clearInterval(interval); |
||||
if (abort) abort.abort(); |
||||
}; |
||||
}, []); |
||||
|
||||
return online; |
||||
} |
@ -0,0 +1,27 @@
@@ -0,0 +1,27 @@
|
||||
import { Banner } from "@/components/Banner"; |
||||
import { useBannerSize } from "@/hooks/useBanner"; |
||||
import { useIsOnline } from "@/hooks/usePing"; |
||||
import { ReactNode } from "react"; |
||||
import { useTranslation } from "react-i18next"; |
||||
|
||||
export function Layout(props: { children: ReactNode }) { |
||||
const { t } = useTranslation(); |
||||
const isOnline = useIsOnline(); |
||||
const bannerSize = useBannerSize(); |
||||
|
||||
return ( |
||||
<div> |
||||
<div className="fixed inset-x-0 z-[1000]"> |
||||
{!isOnline ? <Banner type="error">{t("errors.offline")}</Banner> : null} |
||||
</div> |
||||
<div |
||||
style={{ |
||||
paddingTop: `${bannerSize}px`, |
||||
}} |
||||
className="flex min-h-screen flex-col" |
||||
> |
||||
{props.children} |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
Loading…
Reference in new issue