Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found
Select Git revision

Target

Select target project
  • julianbohnenkaemper/infoscreen
  • acul/infoscreen-new
  • tudo-fsinfo/infoscreen/infoscreen
  • alexr/infoscreen
  • fabianvanrissenbeck/infoscreen
  • evysgarden/infoscreen
  • falk-pages/infoscreen
  • smmokerg/infoscreen
  • smmivog2/infoscreen-fussball
  • smmivog2/infoscreen-update-preisliste
10 results
Select Git revision
Show changes
Showing
with 1072 additions and 154 deletions
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" /> <link rel="icon" href="https://static.fachschaften.org/icon/tight/color/fsorg-icon-color-tight.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" /> <meta name="theme-color" content="#000000" />
<meta <meta
...@@ -24,7 +24,7 @@ ...@@ -24,7 +24,7 @@
work correctly both with client-side routing and a non-root public URL. work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`. Learn how to configure a non-root public URL by running `npm run build`.
--> -->
<title>React App</title> <title>Infoscreen</title>
</head> </head>
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
......
...@@ -16,3 +16,85 @@ ...@@ -16,3 +16,85 @@
* { * {
cursor: none; cursor: none;
} }
.progress-bar {
justify-content: center;
border-radius: 10px;
padding: 0 5px;
display: flex;
height: 10px;
width: 100%;;
}
.progress-bar-fill {
border-radius: 10px;
height: 10px;
width: 0;
animation-timing-function: linear;
}
.progress-bar-animate {
animation-name: progress-bar-animation;
}
@keyframes progress-bar-animation {
0% { width: 0; }
100% { width: 100%; }
}
/* inter-200 - latin_latin-ext */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'Inter';
font-style: normal;
font-weight: 200;
src: url('./../public/fonts/inter/inter-v13-latin-200.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* inter-300 - latin_latin-ext */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'Inter';
font-style: normal;
font-weight: 300;
src: url('./../public/fonts/inter/inter-v13-latin-300.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* inter-regular - latin_latin-ext */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'Inter';
font-style: normal;
font-weight: 400;
src: url('./../public/fonts/inter/inter-v13-latin-regular.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* inter-500 - latin_latin-ext */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'Inter';
font-style: normal;
font-weight: 500;
src: url('./../public/fonts/inter/inter-v13-latin-500.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* inter-600 - latin_latin-ext */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'Inter';
font-style: normal;
font-weight: 600;
src: url('./../public/fonts/inter/inter-v13-latin-600.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* inter-700 - latin_latin-ext */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'Inter';
font-style: normal;
font-weight: 700;
src: url('./../public/fonts/inter/inter-v13-latin-700.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* inter-800 - latin_latin-ext */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'Inter';
font-style: normal;
font-weight: 800;
src: url('./../public/fonts/inter/inter-v13-latin-800.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
import React from 'react'; import React from "react";
const PanelTitle = (props: {title: string, info?: string}) => { const PanelTitle = (props: { title: string; info?: string }) => {
return ( return (
<div className={"px-6 py-4 text-zinc-400 flex flex-row justify-between"}> <div
<h2>{props.title}</h2> className={
{props.info && ( "px-6 pt-4 pb-2 text-zinc-400 flex flex-row justify-between"
<p>{props.info}</p> }
)} >
</div> <h2>{props.title}</h2>
); {props.info && <p>{props.info}</p>}
</div>
);
}; };
export default PanelTitle; export default PanelTitle;
const ProgressBar = (props: { duration: number, idName: string, callbackFunction: () => void }) => {
const runCallback = () => {
props.callbackFunction();
const el = document.getElementById(props.idName);
if (el) {
el.classList.remove('progress-bar-animate');
void el.offsetWidth;
el.classList.add('progress-bar-animate');
}
}
return (
<div className="progress-bar">
<div
id={props.idName}
className={'progress-bar-fill progress-bar-animate bg-zinc-700'}
style={{ animationDuration: `${props.duration}ms` }}
onAnimationEnd={() => runCallback()}
/>
</div>
);
};
export default ProgressBar;
...@@ -7,7 +7,8 @@ export type BildPanelDefinition = { ...@@ -7,7 +7,8 @@ export type BildPanelDefinition = {
url: string, url: string,
fit?: "fill" | "fit", fit?: "fill" | "fit",
title?: string, title?: string,
description?: string description?: string,
gradient?: boolean
} }
const BildPanel = (props: {definition: BildPanelDefinition}) => { const BildPanel = (props: {definition: BildPanelDefinition}) => {
...@@ -31,7 +32,10 @@ const BildPanel = (props: {definition: BildPanelDefinition}) => { ...@@ -31,7 +32,10 @@ const BildPanel = (props: {definition: BildPanelDefinition}) => {
> >
{props.definition.title && ( {props.definition.title && (
<> <>
<div className={"absolute inset-0 w-full h-full bg-lower-gradient"}> <div className={classNames(
"absolute inset-0 w-full h-full",
(props.definition.gradient ?? true) ? "bg-lower-gradient" : ""
)}>
</div> </div>
<PanelContent className={"absolute inset-0 w-full h-full flex flex-col justify-end"}> <PanelContent className={"absolute inset-0 w-full h-full flex flex-col justify-end"}>
......
...@@ -23,7 +23,8 @@ type Route = { ...@@ -23,7 +23,8 @@ type Route = {
name: string, name: string,
arrival: Date, arrival: Date,
delay?: number, delay?: number,
countdown: number countdown: number,
cancelled: boolean
}[], }[],
countdown: number countdown: number
} }
...@@ -70,7 +71,8 @@ const FahrplanPanel = (props: {definition: FahrplanPanelDefinition}) => { ...@@ -70,7 +71,8 @@ const FahrplanPanel = (props: {definition: FahrplanPanelDefinition}) => {
name: departure.internal.stop, name: departure.internal.stop,
arrival, arrival,
delay, delay,
countdown: parseInt(departure.countdown) countdown: parseInt(departure.countdown),
cancelled: departure.is_cancelled === 1
} }
], ],
countdown: parseInt(departure.countdown) countdown: parseInt(departure.countdown)
...@@ -81,7 +83,8 @@ const FahrplanPanel = (props: {definition: FahrplanPanelDefinition}) => { ...@@ -81,7 +83,8 @@ const FahrplanPanel = (props: {definition: FahrplanPanelDefinition}) => {
name: departure.internal.stop, name: departure.internal.stop,
arrival, arrival,
delay: stringToDelay(departure.delay), delay: stringToDelay(departure.delay),
countdown: parseInt(departure.countdown) countdown: parseInt(departure.countdown),
cancelled: departure.is_cancelled === 1
}) })
newRoutes[existing_ind].stops = newRoutes[existing_ind].stops newRoutes[existing_ind].stops = newRoutes[existing_ind].stops
...@@ -170,8 +173,6 @@ async function getStopData(stop: string): Promise<StationResponse> { ...@@ -170,8 +173,6 @@ async function getStopData(stop: string): Promise<StationResponse> {
} }
})) }))
console.log(data);
return data as StationResponse; return data as StationResponse;
} }
......
...@@ -10,7 +10,8 @@ const PlanElement = (props: { ...@@ -10,7 +10,8 @@ const PlanElement = (props: {
stops: { stops: {
name: string, name: string,
arrival: Date, arrival: Date,
delay?: number delay?: number,
cancelled: boolean
}[] }[]
}) => { }) => {
const [shown, setShown] = useState<boolean>(true); const [shown, setShown] = useState<boolean>(true);
...@@ -87,6 +88,7 @@ const PlanElement = (props: { ...@@ -87,6 +88,7 @@ const PlanElement = (props: {
name={deDortmund(stop.name)} name={deDortmund(stop.name)}
arrival={stop.arrival} arrival={stop.arrival}
delay={stop.delay} delay={stop.delay}
cancelled={stop.cancelled}
/> />
)) ))
} }
......
...@@ -5,7 +5,8 @@ type ProgressIndicatorProps = { ...@@ -5,7 +5,8 @@ type ProgressIndicatorProps = {
id: string, id: string,
name: string, name: string,
arrival: Date, arrival: Date,
delay?: number delay?: number,
cancelled: boolean
} }
export default function ProgressIndicator(props: Readonly<ProgressIndicatorProps>) { export default function ProgressIndicator(props: Readonly<ProgressIndicatorProps>) {
...@@ -18,7 +19,10 @@ export default function ProgressIndicator(props: Readonly<ProgressIndicatorProps ...@@ -18,7 +19,10 @@ export default function ProgressIndicator(props: Readonly<ProgressIndicatorProps
<div className={"flex-1"}></div> <div className={"flex-1"}></div>
</div> </div>
<div className="relative flex flex-row items-center gap-4"> <div className={classNames("relative flex flex-row items-center gap-4", props.cancelled ? "text-red-400" : "")}
style={{
textDecoration: props.cancelled ? "line-through" : "inherit"
}}>
<div className={"w-20 flex flex-row justify-center"}> <div className={"w-20 flex flex-row justify-center"}>
<span className="h-[2.6rem] flex items-center" aria-hidden="true"> <span className="h-[2.6rem] flex items-center" aria-hidden="true">
<span className="relative z-10 w-[1.5rem] h-[1.5rem] flex items-center justify-center bg-zinc-400 rounded-full"> <span className="relative z-10 w-[1.5rem] h-[1.5rem] flex items-center justify-center bg-zinc-400 rounded-full">
...@@ -31,7 +35,7 @@ export default function ProgressIndicator(props: Readonly<ProgressIndicatorProps ...@@ -31,7 +35,7 @@ export default function ProgressIndicator(props: Readonly<ProgressIndicatorProps
{props.name} {props.name}
</div> </div>
<div className={classNames("tabular-nums", props.delay ? "text-red-400" : "text-zinc-300")}> <div className={classNames("tabular-nums", (props.delay || props.cancelled) ? "text-red-400" : "text-zinc-300")}>
{props.delay ? ( {props.delay ? (
<span className={"text-red-400"}> <span className={"text-red-400"}>
(+{props.delay})&nbsp;&nbsp; (+{props.delay})&nbsp;&nbsp;
......
import React, { useEffect, useRef, useState } from 'react' import React, { useEffect, useRef, useState } from "react";
import PanelWrapper from "../../meta/PanelWrapper"; import PanelWrapper from "../../meta/PanelWrapper";
import PanelContent from "../../meta/PanelContent"; import PanelContent from "../../meta/PanelContent";
import PanelTitle from '../../meta/PanelTitle' import PanelTitle from "../../meta/PanelTitle";
import QRCode from "react-qr-code"; import QRCode from "react-qr-code";
import { Clock, MapPin } from '@phosphor-icons/react' import { Clock, MapPin } from "@phosphor-icons/react";
export type GremiumPanelDefinition = { export type GremiumPanelDefinition = {
gremien: [Gremium] gremien: [Gremium];
} };
type Gremium = { type Gremium = {
name: string, name: string;
description: string, description: string;
link: string, link: string;
time: string, time: string;
location: string location: string;
} };
const GremiumPanel = (props: {definition: GremiumPanelDefinition}) => { const GremiumPanel = (props: { definition: GremiumPanelDefinition }) => {
const [gremium, setGremium] = useState<Gremium>(props.definition.gremien[0]); const [gremium, setGremium] = useState<Gremium>(props.definition.gremien[0]);
const cycle = useRef<number>(0); const cycle = useRef<number>(0);
useEffect(() => { useEffect(() => {
const update = async () => { const update = async () => {
setGremium(props.definition.gremien[cycle.current++ % props.definition.gremien.length]); setGremium(
console.log(gremium); props.definition.gremien[
console.log(cycle.current); cycle.current++ % props.definition.gremien.length
} ],
);
};
update(); update();
const interval = setInterval(update, 20 * 1000); const interval = setInterval(update, 20 * 1000);
return () => { return () => {
clearInterval(interval); clearInterval(interval);
} };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
return ( return (
<PanelWrapper> <PanelWrapper>
<PanelTitle title={"Termine"}/> <PanelTitle title={"Termine"} />
<PanelContent> <PanelContent>
<div className={"flex flex-row"}> <div className={"flex flex-row"}>
<div className={"flex-1"}> <div className={"flex-1"}>
<h2 className={"text-xl font-semibold mt-2"}>{gremium.description}</h2> <h2 className={"text-xl font-semibold mt-2"}>
<div className={"flex flex-row gap-4"}> {gremium.description}
<p className={"text-sm text-gray-400"}> </h2>
<Clock size={20} className={"inline mb-1.5 mr-1"}/> <div className={"flex flex-row gap-4"}>
{gremium.time} <p className={"text-sm text-gray-400"}>
</p> <Clock size={20} className={"inline mb-1.5 mr-1"} />
<p className={"text-sm text-gray-400"}> {gremium.time}
<MapPin size={20} className={"inline mb-1.5 mr-1"}/> </p>
{gremium.location} <p className={"text-sm text-gray-400"}>
</p> <MapPin size={20} className={"inline mb-1.5 mr-1"} />
</div> {gremium.location}
</div> </p>
<div className={""}> </div>
<QRCode </div>
value={gremium.link} <div className={""}>
className={"h-24 w-24"} <QRCode
fgColor={"#ffffff"} value={gremium.link}
bgColor={"#18181b"} className={"h-24 w-24"}
/> fgColor={"#ffffff"}
</div> bgColor={"#18181b"}
</div> />
</PanelContent> </div>
</PanelWrapper> </div>
); </PanelContent>
</PanelWrapper>
);
}; };
export default GremiumPanel; export default GremiumPanel;
import { useEffect, useState } from "react";
import PanelWrapper from "../../meta/PanelWrapper";
import PanelTitle from "../../meta/PanelTitle";
import PanelContent from "../../meta/PanelContent";
type MensaJetztAPIResponse = {
day: string,
date: string,
attendance: Attendee[],
}
type Attendee = {
"name": string,
"name_modifiers": string,
"time": string,
"canteen": string,
"color": string,
}
type Attendance = { [time: string]: Attendee[] }
const MensaJetztPanel = () => {
const [marqueeContent, setMarqueeContent] = useState<string>("Niemand :(");
useEffect(() => {
const update = async () => {
try {
const request = await fetch(`https://mensa.jetzt/api/entries/`);
if (request.status !== 200) {
setMarqueeContent("API Error :(");
console.error("mensa.jetzt API returned error code");
return;
}
const data = await request.json() as MensaJetztAPIResponse;
let marquee = Object.entries(
data.attendance
.sort((a, b) => a.time.localeCompare(b.time))
.reduce((acc, curr) => {
if (acc[curr.time]) {
acc[curr.time].push(curr);
} else {
acc[curr.time] = [curr];
}
return acc;
}, {} as Attendance)
).map(([time, attendees]) => `${time} - ${attendees.map(a => a.name).join(", ")}`)
.join(" | ");
if (marquee) {
marquee += " |";
} else {
marquee = "Niemand :(";
}
setMarqueeContent(marquee.trim());
}
catch (e) {
console.warn("mensa.jetzt not showing data because", e);
setMarqueeContent("Niemand :(");
}
}
update();
const interval = setInterval(update, 5 * 60 * 1000);
return () => {
clearInterval(interval);
}
}, []);
return (
<PanelWrapper className={"relative"}>
<PanelTitle title={"Mensa.jetzt"}/>
<PanelContent>
{marqueeContent.includes("Uhr") ? (
<div className={"flex overflow-hidden whitespace-nowrap"}>
<div className={"animate-marquee"}>
{marqueeContent}
</div>
<div className={"animate-marquee ml-1"}>
{marqueeContent}
</div>
</div>
) : (
<div className={"text-center"}>{marqueeContent}</div>
)}
</PanelContent>
</PanelWrapper>
);
};
export default MensaJetztPanel;
...@@ -4,11 +4,17 @@ import PanelTitle from "../../meta/PanelTitle"; ...@@ -4,11 +4,17 @@ import PanelTitle from "../../meta/PanelTitle";
import PanelContent from "../../meta/PanelContent"; import PanelContent from "../../meta/PanelContent";
import {CanteenAPIResponse} from "./types/canteenAPI"; import {CanteenAPIResponse} from "./types/canteenAPI";
import {Leaf, Plant, Bone, Record, Fish, ForkKnife} from "@phosphor-icons/react"; import {Leaf, Plant, Bone, Record, Fish, ForkKnife} from "@phosphor-icons/react";
import ProgressBar from "../../meta/ProgressBar";
type Dish = { type Dish = {
name: string, name: string,
details: string, details: string,
typeIcons: React.FC<any>[] typeIcons: React.FC<any>[],
price: {
"student": string,
"staff": string,
"guest": string
},
} }
type MensaPanelDefinition = { type MensaPanelDefinition = {
...@@ -61,8 +67,6 @@ const MensaplanPanel = (props: {definition: MensaPanelDefinition}) => { ...@@ -61,8 +67,6 @@ const MensaplanPanel = (props: {definition: MensaPanelDefinition}) => {
const data = await request.json() as CanteenAPIResponse; const data = await request.json() as CanteenAPIResponse;
console.log(data);
const old_menus_count = menus.current.length; const old_menus_count = menus.current.length;
const old_specials_count = menus.current.length; const old_specials_count = menus.current.length;
...@@ -77,13 +81,16 @@ const MensaplanPanel = (props: {definition: MensaPanelDefinition}) => { ...@@ -77,13 +81,16 @@ const MensaplanPanel = (props: {definition: MensaPanelDefinition}) => {
name: (d.title.de name: (d.title.de
.split(" | ") .split(" | ")
.at(0) ?? "Name nicht bekannt") .at(0) ?? "Name nicht bekannt")
.replace(" nach Wahl", ""), .replace(" nach Wahl", "")
.replaceAll(/\(.*\)/g, ""),
details: d.title.de details: d.title.de
.split(" | ") .split(" | ")
.slice(1, -1) .slice(1, -1)
.join(", ") .join(", ")
.replace(" nach Wahl", ""), .replace(" nach Wahl", "")
typeIcons: d.type.map(typeToIcon).filter(i => i !== null) as unknown as React.FC<any>[] .replaceAll(/\(.*\)/g, ""),
typeIcons: d.type.map(typeToIcon).filter(i => i !== null) as unknown as React.FC<any>[],
price: d.price
})) }))
specials.current = data specials.current = data
...@@ -95,13 +102,16 @@ const MensaplanPanel = (props: {definition: MensaPanelDefinition}) => { ...@@ -95,13 +102,16 @@ const MensaplanPanel = (props: {definition: MensaPanelDefinition}) => {
name: (d.title.de name: (d.title.de
.split(" | ") .split(" | ")
.at(0) ?? "Name nicht bekannt") .at(0) ?? "Name nicht bekannt")
.replace(" nach Wahl", ""), .replace(" nach Wahl", "")
.replaceAll(/\(.*\)/g, ""),
details: d.title.de details: d.title.de
.split(" | ") .split(" | ")
.slice(1, -1) .slice(1, -1)
.join(", ") .join(", ")
.replace(" nach Wahl", ""), .replace(" nach Wahl", "")
typeIcons: d.type.map(typeToIcon).filter(i => i !== null) as unknown as React.FC<any>[] .replaceAll(/\(.*\)/g, ""),
typeIcons: d.type.map(typeToIcon).filter(i => i !== null) as unknown as React.FC<any>[],
price: d.price
})) }))
// If the count of menus and specials changed, reset the cycler // If the count of menus and specials changed, reset the cycler
...@@ -127,56 +137,59 @@ const MensaplanPanel = (props: {definition: MensaPanelDefinition}) => { ...@@ -127,56 +137,59 @@ const MensaplanPanel = (props: {definition: MensaPanelDefinition}) => {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
useEffect(() => { const updateGroup = () => {
const update = async () => { cycle.current = (cycle.current + 1) % 2;
switch (cycle.current % 2) { switch (cycle.current) {
case 0: case 0:
setGroupName("Menüs"); setGroupName("Menüs");
setDishes(menus.current); setDishes(menus.current);
break; break;
case 1: case 1:
setGroupName("Aktionsteller"); setGroupName("Aktionsteller");
setDishes(specials.current); setDishes(specials.current);
break; break;
} default:
console.error("You fucked up bad!");
cycle.current = (cycle.current + 1) % 2; break;
} }
}
update();
const interval = setInterval(update, 20 * 1000);
return () => {
clearInterval(interval);
}
}, []);
return ( return (
<PanelWrapper> <PanelWrapper>
<PanelTitle title={"Mensaplan für " + relativeDay} info={groupName} /> <PanelTitle title={"Mensaplan für " + relativeDay} info={groupName} />
<PanelContent> <PanelContent>
<div className={"flex flex-col gap-4"}> <div className={"grid gap-y-1 gap-x-1.5"} style={{gridTemplateColumns: "1fr 10fr 1fr"}}>
{dishes.map(dish => ( {dishes.map(dish => (
<div key={dish.name} className={"flex flex-row items-center gap-4"}> <>
{/* Fixme: This shifts the name out of line if there is more than one */} {/* Fixme: This shifts the name out of line if there is more than one */}
<div className={"flex flex-row gap-2 mr-2"}> <div className={"flex flex-row gap-1.5"}>
{dish.typeIcons.map((Icon, index) => ( {dish.typeIcons.map((Icon) => (
<Icon key={Icon.name + index} size={32} className={"text-zinc-400"}/> <Icon key={Icon.name} size={32} className={"text-zinc-400"}/>
))} ))}
</div> </div>
<h3 className={"text-xl leading-tight"}> <h3 className={"text-xl leading-tight"}>
{dish.name} {dish.name}
</h3> </h3>
<p className={"text-sm text-zinc-300 leading-tight"}>
{dish.details} <p className={"text-sm text-zinc-400 leading-tight text-right"}>
</p> {dish.price.student}
</div> </p>
))}
</div> <div>
</div>
<p className={"text-sm text-zinc-400 leading-tight col-span-2 mb-4"}>
{dish.details}
</p>
</>
))}
</div>
{dishes.length === 0 && ( {
<div className={"h-full w-full flex justify-center items-center"}> dishes.length === 0 && (
<div className={"h-full w-full flex justify-center items-center"}>
<div className={"mb-10 flex flex-col items-center"}> <div className={"mb-10 flex flex-col items-center"}>
<ForkKnife size={48} className={"mb-3"}/> <ForkKnife size={48} className={"mb-3"}/>
<p className={"text-center text-zinc-400"}> <p className={"text-center text-zinc-400"}>
...@@ -186,6 +199,7 @@ const MensaplanPanel = (props: {definition: MensaPanelDefinition}) => { ...@@ -186,6 +199,7 @@ const MensaplanPanel = (props: {definition: MensaPanelDefinition}) => {
</div> </div>
)} )}
</PanelContent> </PanelContent>
<ProgressBar duration={20000} idName={'mena-progress'} callbackFunction={updateGroup} />
</PanelWrapper> </PanelWrapper>
); );
}; };
...@@ -193,7 +207,7 @@ const MensaplanPanel = (props: {definition: MensaPanelDefinition}) => { ...@@ -193,7 +207,7 @@ const MensaplanPanel = (props: {definition: MensaPanelDefinition}) => {
export default MensaplanPanel; export default MensaplanPanel;
function toYYYYMMDD(input: Date): string { function toYYYYMMDD(input: Date): string {
return `${input.getFullYear()}-${input.getMonth() + 1}-${input.getDate()}` return `${input.getFullYear().toString().padStart(4, "20")}-${(input.getMonth() + 1).toString().padStart(2, "0")}-${input.getDate().toString().padStart(2, "0")}`
} }
function typeToIcon(type: string): React.FC | null { function typeToIcon(type: string): React.FC | null {
......
import { useEffect, useState } from "react";
import PanelWrapper from "../../meta/PanelWrapper";
import PanelTitle from "../../meta/PanelTitle";
import PanelContent from "../../meta/PanelContent";
import StationElement from "./components/StationElement";
type NextbikeAPIResponse = {
countries: {
cities: {
places: {
number: number,
bikes_available_to_rent: number
}[]
}[]
}[]
}
type NextbikePanelDefinition = {
city: string;
station_ids: number[];
station_names: string[];
}
type NextbikeStation = {
id: number,
name: string,
available: number
}
const NextbikePanel = (props: {definition: NextbikePanelDefinition}) => {
const [stations, setStations] = useState<NextbikeStation[]>([]);
useEffect(() => {
const update = async () => {
try {
const request = await fetch(`https://api.nextbike.net/maps/nextbike-live.json?city=${props.definition.city}`);
if (request.status !== 200) {
return;
}
const data = await request.json() as NextbikeAPIResponse;
const all_stations = data.countries[0].cities[0].places;
const stations = all_stations.filter((station: any) => {
return props.definition.station_ids.includes(station.number);
});
// sort as in definition
stations.sort((a: any, b: any) => {
return props.definition.station_ids.indexOf(a.number) - props.definition.station_ids.indexOf(b.number);
});
const available_bikes = stations.map((station: any) => {
return {
id: station.number,
name: props.definition.station_names[props.definition.station_ids.indexOf(station.number)],
available: station.bikes_available_to_rent
}
});
setStations(available_bikes);
}
catch (e) {
console.warn("NextbikePanel not showing data because", e);
}
}
update();
const interval = setInterval(update, 2 * 60 * 1000);
return () => {
clearInterval(interval);
}
}, [props]);
return (
<PanelWrapper>
<PanelTitle title={"Nextbike Monitor"}/>
<PanelContent className={"flex flex-col"}>
{stations.length > 0 ? (
<div className={"flex-1 flex flex-col gap-3"}>
{stations.map((station) => (
<StationElement
key={station.id}
stationId={station.id}
stationName={station.name}
availableBikes={station.available}
/>
))}
</div>
) : (
<div className={"flex-1 flex justify-center items-center"}>
<div className={"mb-10 flex flex-col items-center"}>
<p className={"max-w-xs text-center text-zinc-400"}>
Aktuell sind keine Daten verfügbar.
</p>
</div>
</div>
)}
</PanelContent>
</PanelWrapper>
)
}
export default NextbikePanel;
const StationElement = (props: {
stationName: string,
stationId: number,
availableBikes: number
}) => {
return (
<div className={"flex flex-row gap-4 items-center"}>
<div className={"text-lg font-semibold h-10 w-20 leading-none text-white flex justify-center items-center"}
style={{ "background": "#ec7100" }}>
{props.stationId}
</div>
<h3 className={"text font-semibold leading-9 flex-1"}>
{props.stationName}
</h3>
<p className={"text-zinc-400"}>
{props.availableBikes} verfügbar
</p>
</div>
);
};
export default StationElement;
\ No newline at end of file
# Termine-Panel
### Neuen Kalender hinzufügen
- `public/config/default.json`
- Format: `{'calendar_name': 'XXX', 'url': 'XXX', 'webcal_url': 'XXX'}`
- Name wird neben Beschreibung angezeigt
- URL wird als QR dargestellt, außer Termin hat eigenen Link
- Webcal-URLs: wegen CORS-Problem wird URL durch Proxy geleitet
- Daher Domain weglassen, zB: `/remote.php/dav/public-calendars/ABCDEFG/?export` bei Kalendern von https://cloud.fachschaften.org
- Bei Kalendern aus neuer Quelle müssen Admins dies in Reverse-Proxy eintragen -> bitte melden, zB an root@oh14.de
@keyframes marquee {
0%, /* Start with delay 0% */
25% {
transform: translateX(0);
}
55%, /* Short delay in middle */
65% {
transform: translateX(var(--marquee-translate));
}
95% {
transform: translateX(0);
}
100% {
transform: translateX(0);
}
}
.marquee {
animation: marquee 20s linear infinite;
display: inline-block;
will-change: transform;
white-space: nowrap;
}
import { useEffect, useRef, useState } from "react";
import PanelWrapper from "../../meta/PanelWrapper";
import PanelContent from "../../meta/PanelContent";
import PanelTitle from "../../meta/PanelTitle";
import QRCode from "react-qr-code";
import { Clock, MapPin } from "@phosphor-icons/react";
import { Termin } from "../../util/icalParser";
import parseICal from "../../util/icalParser";
import styles from "./TerminePanel.module.css";
export type TerminePanelDefinition = {
calendars: [
{
calendar_name: string;
url: string;
webcal_url: string;
},
];
days: number;
};
type TerminRefined = {
summary: string;
description: string | null;
location: string | null;
startDate: string;
endDate: string | null;
link: string | null;
termineCount: number;
termineIndex: number;
};
const DEFAULT_TERMIN: Termin = {
summary: "--- Keine Termine ---",
description: "Fehler beim Laden oder keine Termine vorhanden.",
location: null,
startDate: new Date(0),
endDate: null,
link: null,
};
const DATE_OPTIONS: Intl.DateTimeFormatOptions = {
weekday: "short", // abbreviated day of the week
day: "2-digit", // two-digit day
month: "2-digit", // two-digit month
year: "2-digit", // two-digit year
hour: "2-digit", // two-digit hour
minute: "2-digit", // two-digit minute
hour12: false, // use 24-hour clock
};
function getTerminRefined(termine: Termin[], current: number): TerminRefined {
const termin = termine[current % termine.length];
const startDateRefined = new Intl.DateTimeFormat(
"de-DE",
DATE_OPTIONS,
).format(termin.startDate);
const endDateRefined = termin.endDate
? termin.endDate.getDate() === termin.startDate.getDate()
? new Intl.DateTimeFormat("de-DE", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(termin.endDate)
: new Intl.DateTimeFormat("de-DE", DATE_OPTIONS).format(termin.endDate)
: null;
const terminRefined: TerminRefined = {
summary: termin.summary,
description: termin.description,
location: termin.location,
startDate: startDateRefined,
endDate: endDateRefined,
link: termin.link,
termineCount: termine.length,
termineIndex: current % termine.length,
};
return terminRefined;
}
const TerminePanel = (props: { definition: TerminePanelDefinition }) => {
const [termine, setTermine] = useState<Termin[]>([DEFAULT_TERMIN]);
const [currentTermin, setCurrentTermin] = useState<TerminRefined>(() => {
return {
summary: "",
description: null,
location: null,
startDate: "",
endDate: null,
link: null,
termineCount: 0,
termineIndex: 0,
};
});
const cycle = useRef<number>(0);
const summaryRef = useRef<HTMLSpanElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const update = async () => {
let termine: Termin[] = [];
// fetch calendars asynchronously
const calendars = await Promise.all(
props.definition.calendars.map(async (calendar) => {
try {
const response = await fetch(calendar.webcal_url);
if (!response.ok) {
throw new Error(`Fetch error code ${response.status}`);
}
const data = await response.text();
return { calendar, data };
} catch (error) {
console.error(
`Error fetching calendar ${calendar.calendar_name}:`,
error,
);
const data = "";
return { calendar, data }; // empty string as data if error
}
}),
);
// parse iCal data
for (const { calendar, data } of calendars) {
if (data === "") {
continue;
}
try {
const events = parseICal(
data,
calendar.calendar_name,
calendar.url,
props.definition.days,
);
termine = termine.concat(events);
} catch (error) {
console.error(
`Error parsing calendar ${calendar.calendar_name}:`,
error,
);
}
}
if (termine.length === 0) {
termine.push(DEFAULT_TERMIN);
}
// sort termine by start date
termine.sort((a, b) => {
return a.startDate.getTime() - b.startDate.getTime();
});
setTermine(termine);
};
update();
const interval = setInterval(update, 5 * 60 * 1000);
return () => {
clearInterval(interval);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props]);
useEffect(() => {
const updateCurrentTermin = () => {
const currentRefined: TerminRefined = getTerminRefined(
termine,
cycle.current,
);
cycle.current++;
setCurrentTermin(currentRefined);
};
updateCurrentTermin();
const interval = setInterval(updateCurrentTermin, 20 * 1000);
return () => {
clearInterval(interval);
};
}, [termine]);
useEffect(() => {
const updateMarquee = () => {
if (summaryRef.current && containerRef.current) {
const textWidth = summaryRef.current.scrollWidth;
const containerWidth = containerRef.current.clientWidth;
const translateDistance = containerWidth - textWidth;
summaryRef.current.style.setProperty(
"--marquee-translate",
`${translateDistance}px`,
);
if (translateDistance < 0) {
summaryRef.current.classList.add(styles.marquee);
} else {
summaryRef.current.classList.remove(styles.marquee);
}
}
};
updateMarquee();
window.addEventListener("resize", updateMarquee);
return () => {
window.removeEventListener("resize", updateMarquee);
};
}, [currentTermin]);
return (
<PanelWrapper>
<PanelTitle
title={
"Termine " +
(currentTermin.termineCount > 1
? "(" +
(currentTermin.termineIndex + 1) +
"/" +
currentTermin.termineCount +
")"
: "")
}
/>
<PanelContent>
<div className={"flex flex-row"}>
<div className={"flex-1 w-8/12"}>
<h2
className={
"text-xl font-semiboldrelative w-full overflow-hidden whitespace-nowrap "
}
ref={containerRef}
>
<span className="inline-block" ref={summaryRef}>
{currentTermin.summary}
</span>
</h2>
<p className={"text-sm text-gray-400"}>
{currentTermin.description}
<br></br>
</p>
<div className={"flex flex-row gap-4"}>
<p className={"text-sm text-gray-400"}>
<Clock size={20} className={"inline mb-1.5 mr-1"} />
{currentTermin.endDate
? currentTermin.startDate + " - " + currentTermin.endDate
: currentTermin.startDate}
</p>
<p className={"text-sm text-gray-400"}>
{currentTermin.location && (
<>
<MapPin size={20} className={"inline mb-1.5 mr-1"} />
{currentTermin.location}
</>
)}
</p>
</div>
</div>
<div>
{currentTermin.link && (
<QRCode
value={currentTermin.link ?? "https://fachschaften.org"}
className={"h-28 w-28 ml-2"}
fgColor={"#ffffff"}
bgColor={"#18181b"}
/>
)}
</div>
</div>
</PanelContent>
</PanelWrapper>
);
};
export default TerminePanel;
import { useEffect, useState } from 'react';
import PanelWrapper from '../../meta/PanelWrapper';
import PanelTitle from '../../meta/PanelTitle';
import PanelContent from '../../meta/PanelContent';
import { Cloud, CloudFog, CloudLightning, CloudRain, CloudSnow, CloudSun, Icon, Sun } from '@phosphor-icons/react';
import { fetchWeatherApi } from 'openmeteo';
export type WetterPanelDefinition = {
latitude: number,
longitude: number,
}
const WetterPanel = (props: { definition: WetterPanelDefinition }) => {
const [temperature, setTemperature] = useState<number>(0);
const [weatherCode, setWeatherCode] = useState<number>(0);
// TODO: how long will it rain
useEffect(() => {
// this function will be called every hour
const update = async () => {
// query open-meteo (https://github.com/open-meteo/typescript)
const params = {
latitude: [props.definition.latitude],
longitude: [props.definition.longitude],
current: 'temperature_2m,precipitation,weather_code,rain,showers',
forecast_days: 1,
}
const url = 'https://api.open-meteo.com/v1/forecast';
const currentWeather = (await fetchWeatherApi(url, params))[0].current()!;
setTemperature(Math.round(currentWeather.variables(0)!.value()))
setWeatherCode(currentWeather.variables(1)!.value())
}
// call it manually the first time
update();
const interval = setInterval(update, 1000 * 60 * 60);
return () => {
// clear up old handle in case this component is cleaned up
clearInterval(interval)
}
});
const renderWeather = (weatherCode: number, temperature: number) => {
const [WeatherIcon, text] = wcToIconText(weatherCode)!;
return (<div className='clex-1 flex flex-row gap-2 items-center'>
<WeatherIcon size={32} />
<p>{text} <span className='text-gray-400'>{temperature}°C</span></p>
</div>);
};
return (
<PanelWrapper className={"relative"}>
<PanelTitle title={"Wetter"} />
<PanelContent>
<div className={"flex flex-row gap-4 items-center"}>
{renderWeather(weatherCode, temperature)}
</div>
</PanelContent>
</PanelWrapper>
);
};
/**
* Take a weather code and give an icon and text for the weather
* @param weather_code weather code (see https://open-meteo.com/en/docs)
* @returns Tuple of Icon and text or undefined
*/
function wcToIconText(weather_code: number): [Icon, string] | undefined {
switch (true) {
case weather_code === 0: return [Sun, "Sonnig"]
case weather_code <= 2: return [CloudSun, "Bewölkt"]
case weather_code <= 3: return [Cloud, "Bedeckt"]
case weather_code <= 48: return [CloudFog, "Nebel"]
case weather_code <= 67: return [CloudRain, "Regen"]
case weather_code <= 77: return [CloudSnow, "Schneefall"]
case weather_code <= 82: return [CloudRain, "Starker Regen"]
case weather_code <= 86: return [CloudSnow, "Starker Schneefall"]
case weather_code <= 99: return [CloudLightning, "Gewitter"]
default: return undefined
}
}
export default WetterPanel;
...@@ -5,11 +5,15 @@ ...@@ -5,11 +5,15 @@
*/ */
import React from "react"; import React from "react";
import FahrplanPanel from "./Fahrplan/FahrplanPanel"; import FahrplanPanel from "./Fahrplan/FahrplanPanel";
import {PanelDefinition} from "../types/LayoutConfig"; import NextbikePanel from "./Nextbike/NextbikePanel";
import { PanelDefinition } from "../types/LayoutConfig";
import PanelWrapper from "../meta/PanelWrapper"; import PanelWrapper from "../meta/PanelWrapper";
import BildPanel from "./Bild/BildPanel"; import BildPanel from "./Bild/BildPanel";
import MensaplanPanel from "./Mensaplan/MensaplanPanel"; import MensaplanPanel from "./Mensaplan/MensaplanPanel";
import CalloutPanel from "./Callout/CalloutPanel"; import CalloutPanel from "./Callout/CalloutPanel";
import TerminePanel from "./Termine/TerminePanel";
import WetterPanel from "./Wetter/WetterPanel";
import MensaJetztPanel from "./MensaJetzt/MensaJetztPanel";
import GremiumPanel from "./Gremium/GremiumPanel"; import GremiumPanel from "./Gremium/GremiumPanel";
/* /*
...@@ -17,27 +21,44 @@ import GremiumPanel from "./Gremium/GremiumPanel"; ...@@ -17,27 +21,44 @@ import GremiumPanel from "./Gremium/GremiumPanel";
* precise. So if you want to call your panel "My awesome Panel", please claim "my-awesome-panel". Add it by adding * precise. So if you want to call your panel "My awesome Panel", please claim "my-awesome-panel". Add it by adding
* `| "my-awesome-panel"` before the semicolon in the type below this comment. * `| "my-awesome-panel"` before the semicolon in the type below this comment.
*/ */
export type PanelTypes = "fahrplan" | "bild" | "mensaplan" | "callout" | "gremium";
export type PanelTypes =
| "fahrplan"
| "nextbike"
| "bild"
| "mensaplan"
| "callout"
| "gremium"
| "termine"
| "wetter"
| "mensa-jetzt";
/* /*
* Next, add your renderer. You'll get the definition that was written in the layout config as a prop. If you'd like to * Next, add your renderer. You'll get the definition that was written in the layout config as a prop. If you'd like to
* provide custom settings, you may add an object with these settings as the generic into the PanelDefinition. * provide custom settings, you may add an object with these settings as the generic into the PanelDefinition.
* It will then be available as `definition.config`. * It will then be available as `definition.config`.
*/ */
export const PanelRenderers: {[panelType: string]: React.FC<any & {definition: PanelDefinition<any>}>} = { export const PanelRenderers: {
"fahrplan": FahrplanPanel, [panelType: string]: React.FC<any & { definition: PanelDefinition<any> }>;
"bild": BildPanel, } = {
"mensaplan": MensaplanPanel, fahrplan: FahrplanPanel,
"callout": CalloutPanel, nextbike: NextbikePanel,
"gremium": GremiumPanel, bild: BildPanel,
"placeholder": () => ( mensaplan: MensaplanPanel,
<PanelWrapper className={"flex flex-col items-center justify-center text-zinc-400"}> callout: CalloutPanel,
gremium: GremiumPanel,
termine: TerminePanel,
wetter: WetterPanel,
"mensa-jetzt": MensaJetztPanel,
placeholder: () => (
<PanelWrapper
className={"flex flex-col items-center justify-center text-zinc-400"}
>
Dieses Panel wird noch entwickelt Dieses Panel wird noch entwickelt
</PanelWrapper> </PanelWrapper>
) ),
}; };
/* /*
* That should have been it. Now, have fun implementing your renderer! * That should have been it. Now, have fun implementing your renderer!
*/ */
...@@ -9,21 +9,24 @@ const NO_LAYOUT_CONFIG: LayoutConfig = { ...@@ -9,21 +9,24 @@ const NO_LAYOUT_CONFIG: LayoutConfig = {
} }
export class LayoutService { export class LayoutService {
static configs: LayoutConfig[] = []; public static configs: LayoutConfig[] = [];
static async init(): Promise<void> { static async init(): Promise<void> {
try { try {
const activeConfigs = await fetch("/activeConfigs.json").then(content => content.json()); LayoutService.configs = await LayoutService.fetchActiveConfigs();
const configFetches = (activeConfigs as string[]) const autoRefreshInterval = 1000 * 60 * 10;
.map(configPath => fetch(configPath).then(content => content.json()));
setInterval(() => {
LayoutService.configs = await Promise.all(configFetches); LayoutService.fetchActiveConfigs()
.then(cfg => LayoutService.configs = cfg)
.catch(_ => console.warn("cannot refresh layout config"));
}, autoRefreshInterval);
} catch (e) { } catch (e) {
console.error("LayoutService could not init", e) console.error("LayoutService could not init", e)
} }
} }
static getActiveLayout(): LayoutConfig { public static getActiveLayout(): LayoutConfig {
const now = new Date(); const now = new Date();
const activeConfigs = this.configs.filter(config => { const activeConfigs = this.configs.filter(config => {
...@@ -37,10 +40,17 @@ export class LayoutService { ...@@ -37,10 +40,17 @@ export class LayoutService {
}, false); }, false);
}); });
console.log(activeConfigs) const defaultConfig = this.configs.filter(config => config.id === "default").at(0);
return activeConfigs.at(0) ?? defaultConfig ?? NO_LAYOUT_CONFIG;
}
public static async fetchActiveConfigs(): Promise<LayoutConfig[]> {
const activeConfigs = await fetch("/activeConfigs.json").then(content => content.json());
/* ToDo: This is not great, as it assumes there is always an active layout. If you don't configure this correctly, const configFetches = (activeConfigs as string[])
consider yourself warned now and don't blame me */ .map(configPath => fetch(configPath).then(content => content.json()));
return activeConfigs.at(0) ?? NO_LAYOUT_CONFIG;
return (await Promise.all(configFetches)).map(item => item as LayoutConfig);
} }
} }
import ICAL from "ical.js";
type Termin = {
summary: string;
description: string | null;
location: string | null;
startDate: Date;
endDate: Date | null;
link: string | null;
};
// convert ICAL event to local event format Termin
// tries to populate link property from event
// special handling for kolloquium.cs.tu-dortmund.de to hide duplicate information
function eventToTermin(event: ICAL.Event): Termin {
// extract link from event
// use url property if available
let link = event.component.getFirstPropertyValue("url");
// if url property is not available, try to extract link from description
if (link === null) {
const description = event.description;
if (typeof description === "string") {
const regex = /https?:\/\/[^\s]+/g;
const match = regex.exec(description.toString());
if (match !== null) {
link = match[0];
event.description = description.replace(link, "");
}
}
}
// remove summary from description, if the description contains the summary
if (event.description && event.summary) {
const description = event.description.toString();
const summary = event.summary.toString();
if (description.includes(summary)) {
// some special behavior for http://kolloquium.cs.tu-dortmund.de/
// structure:
// summary: "<Title> (<Type>)"
// description: "<Person>: <Title> (<Type>)"
// new description: "<Person>: <Type>"
const regex =
/([A-Za-zÄÖÜäöüß ]+): ([A-Za-zÄÖÜäöüß:,„“"\- ]+) \(([A-Za-zÄÖÜäöüß\- ]+)\)/;
const match = regex.exec(description);
if (match !== null) {
event.description = match[1] + ": " + match[3];
event.summary = summary.replace("(" + match[3] + ")", "");
} else {
event.description = description.replace(summary, "");
}
}
}
return {
summary: event.summary,
description: event.description,
location: event.location,
startDate: event.startDate.toJSDate(),
endDate: event.endDate.toJSDate(),
link: link ? link.toString() : null,
};
}
// parse iCal content and return list of events in local format Termin
// resolves recurring events, filters out all events that end before filterStartDate (now) or that start after filterEndDate (now + future_days)
// adds calendar name to every event description
// adds default link to every event that has no link
function parseICal(
content: string,
cal_name: string,
default_link: string,
future_days: number,
): Termin[] {
const filterStartDate = new Date();
const filterEndDate = new Date(
filterStartDate.getTime() + future_days * 24 * 60 * 60 * 1000,
);
const jCalData = ICAL.parse(content);
const comp = new ICAL.Component(jCalData);
const vevents = comp.getAllSubcomponents("vevent");
const raw_events = vevents.map((vevent) => {
return new ICAL.Event(vevent);
});
// expand recurring events
var termine = [];
for (const event of raw_events) {
// check whether to skip event
if (event.startDate.toJSDate() > filterEndDate) continue;
if (event.isRecurring()) {
const iter = event.iterator();
let next;
while ((next = iter.next()) && next.toJSDate() < filterEndDate) {
// if event end date is after filterStartDate, add it
if (
next.toUnixTime() + event.duration.toSeconds() >
Math.floor(filterStartDate.getTime() / 1000)
) {
let newEvent = eventToTermin(event);
newEvent.startDate = next.toJSDate();
newEvent.endDate = next.toJSDate();
newEvent.endDate.setSeconds(
newEvent.endDate.getSeconds() + event.duration.toSeconds(),
);
termine.push(newEvent);
}
}
} else {
// if event end date is after filterStartDate, add it
if (event.endDate.toJSDate() > filterStartDate)
termine.push(eventToTermin(event));
}
}
// add calendar name + default link to each event
termine.forEach((termin) => {
termin.description =
cal_name + (termin.description ? " | " + termin.description : "");
if (termin.link === null) termin.link = default_link;
});
return termine;
}
export type { Termin };
export default parseICal;