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
  • fix/sitzungstermin
  • main
  • marquee
  • master
  • punctuation-workaround
5 results

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
  • 1-issue-czi-wtf
  • master
  • update-deps
3 results
Show changes
Showing
with 1458 additions and 89 deletions
import React from 'react';
import ScreenBar from "./ScreenBar";
const ScreenWrapper = (props: {children: any}) => {
const ScreenWrapper = (props: {children: any, backgroundImage?: string}) => {
return (
<div className={"h-full w-full flex flex-col"} style={{backgroundImage: "/logo512.png"}}>
<ScreenBar />
<div className={"flex-1 px-6 pb-6"}>
{props.children}
<div className={"relative"}>
<div
className={"absolute inset-0 h-screen w-screen bg-cover blur-lg backdrop-brightness-75"}
style={{backgroundImage: `url('${props.backgroundImage}')`}}
>
</div>
<div className={"absolute inset-0 h-screen w-screen flex flex-col"}>
<ScreenBar />
<div
className={"grid layout-grid flex-1 px-8 pb-8 pt-4 bg-contain bg-no-repeat bg-center"}
style={{
backgroundImage: `url('${props.backgroundImage}')`
}}
>
{props.children}
</div>
</div>
</div>
);
......
import React from 'react';
import PanelWrapper from "../../meta/PanelWrapper";
import classNames from "../../util/classNames";
import PanelContent from "../../meta/PanelContent";
export type BildPanelDefinition = {
url: string,
fit?: "fill" | "fit",
title?: string,
description?: string,
gradient?: boolean
}
const BildPanel = (props: {definition: BildPanelDefinition}) => {
return (
<PanelWrapper className={"relative"}>
<div
className={"absolute inset-0 h-full w-full bg-cover blur-lg backdrop-brightness-75"}
style={{backgroundImage: `url('${props.definition.url}')`}}
>
</div>
<div className={"absolute inset-0 h-full w-full flex"}>
<div
className={classNames(
"bg-no-repeat bg-center flex-1 relative",
(props.definition.fit === "fit") ? "bg-contain" : "bg-cover"
)}
style={{
backgroundImage: `url('${props.definition.url}')`
}}
>
{props.definition.title && (
<>
<div className={classNames(
"absolute inset-0 w-full h-full",
(props.definition.gradient ?? true) ? "bg-lower-gradient" : ""
)}>
</div>
<PanelContent className={"absolute inset-0 w-full h-full flex flex-col justify-end"}>
<h2 className={"text-xl font-semibold"}>
{props.definition.title}
</h2>
{props.definition.description && (
<p className={"mt-1 text-sm max-w-5xl"}>
{props.definition.description}
</p>
)}
</PanelContent>
</>
)}
</div>
</div>
</PanelWrapper>
);
};
export default BildPanel;
import React from 'react';
import PanelWrapper from "../../meta/PanelWrapper";
import PanelContent from "../../meta/PanelContent";
import {Warning} from "@phosphor-icons/react";
export type CalloutPanelDefinition = {
type?: "warning",
title?: string,
description?: string
}
const CalloutPanel = (props: {definition: CalloutPanelDefinition}) => {
return (
<PanelWrapper>
<div className={"flex flex-row h-full w-full"}>
<div
className={"bg-yellow-500 aspect-square flex justify-center items-center"}
>
<Warning size={48} className={"text-zinc-900"} />
</div>
<PanelContent className={"flex-1 flex justify-start items-center"} padding={false}>
<div className={"px-6 py-5"}>
{props.definition.title && (
<h2 className={"text-lg font-semibold"}>
{props.definition.title}
</h2>
)}
{props.definition.description && (
<p className={"mt-1 text-sm"}>
{props.definition.description}
</p>
)}
</div>
</PanelContent>
</div>
</PanelWrapper>
);
};
export default CalloutPanel;
import React from 'react';
import PanelWrapper from "../../meta/PanelWrapper";
const ErrorPanel = (props: {message?: string}) => {
const message = props.message ?? "Failed to render Panel";
return (
<PanelWrapper className={"bg-red-500 px-4 py-3 flex items-center justify-center"}>
{message}
</PanelWrapper>
);
};
export default ErrorPanel;
import React from 'react';
import React, {useEffect, useState} from 'react';
import PanelWrapper from "../../meta/PanelWrapper";
import PlanElement from "./components/PlanElement";
import PanelTitle from "../../meta/PanelTitle";
import PanelContent from "../../meta/PanelContent";
import {StationResponse} from "./types/vrrfAPI";
import {Warning} from "@phosphor-icons/react";
import {motion} from 'framer-motion';
export type FahrplanPanelDefinition = {
stops: string[],
filter: {
types?: string[],
destinations?: string[]
}
}
type Route = {
uid: string,
identifier: string,
heading: string,
stops: {
name: string,
arrival: Date,
delay?: number,
countdown: number,
cancelled: boolean
}[],
countdown: number
}
const FahrplanPanel = (props: {definition: FahrplanPanelDefinition}) => {
const [routes, setRoutes] = useState<Route[]>([]);
useEffect(() => {
const update = async () => {
const departures = (await Promise.all(props.definition.stops.map(getStopData)))
.map(d => d.raw)
.flat();
let newRoutes: Route[] = [];
// Determine stop data from the departures
for(let departure of departures) {
// First throw away all data that belongs to a filtered category
const filterTypes = props.definition.filter.types ?? [];
if(filterTypes.includes(departure.type)) {
continue;
}
// Find existing route with same uid
const existing_ind = newRoutes.findIndex(r => r.uid === departure.key + "-" + departure.lineref.identifier)
// Pre-compute values that will be needed regardless
const delay = stringToDelay(departure.delay);
const arrival = processArrival(departure.sched_date, departure.time);
// Throw away stops that are five hours in the future as keys start colliding at some point
if(arrival.getTime() >= 5 * 60 * 60 * 1000 + (new Date()).getTime()) {
continue;
}
if(existing_ind === -1) {
// If it does not exist, create a new route
newRoutes.push({
uid: departure.key + "-" + departure.lineref.identifier,
heading: departure.destination,
identifier: departure.line,
stops: [
{
name: departure.internal.stop,
arrival,
delay,
countdown: parseInt(departure.countdown),
cancelled: departure.is_cancelled === 1
}
],
countdown: parseInt(departure.countdown)
})
} else {
// If it does, just add a stop to the existing route
newRoutes[existing_ind].stops.push({
name: departure.internal.stop,
arrival,
delay: stringToDelay(departure.delay),
countdown: parseInt(departure.countdown),
cancelled: departure.is_cancelled === 1
})
newRoutes[existing_ind].stops = newRoutes[existing_ind].stops
.sort((a, b) => a.countdown - b.countdown)
}
}
// Sort the output
newRoutes = newRoutes.sort((a, b) => {
const diff = a.stops[0].arrival.getTime() - b.stops[0].arrival.getTime();
if(diff !== 0) {
return diff;
}
const latestA = Math.max(...a.stops.map(s => s.arrival.getTime()));
const latestB = Math.max(...b.stops.map(s => s.arrival.getTime()));
return latestA - latestB;
})
// Write to the display
setRoutes(newRoutes);
}
update();
const interval = setInterval(update, 2 * 60 * 1000);
return () => {
clearInterval(interval);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const FahrplanPanel = () => {
return (
<PanelWrapper>
<PanelTitle title={"ÖPNV Monitor"}/>
<PanelContent>
<div className={"flex flex-col gap-3"}>
<PlanElement trainIdentifier={"S1"} trainHeading={"Dortmund Hbf"} stops={[
{
name: "Essen",
time: "10:00",
delay: 20
},
{
name: "Nicht Essen",
time: "10:00",
delay: 20
}
]}/>
<PlanElement trainIdentifier={"S1"} trainHeading={"Dortmund Hbf"} stops={[
{
name: "Essen",
time: "10:00",
delay: 20
},
{
name: "Nicht Essen",
time: "10:00",
delay: 20
}
]}/>
</div>
<PanelContent className={"flex flex-col"}>
<motion.div layout transition={{duration: .3, ease: "easeOut"}} className={"flex-1 flex flex-col gap-3"}>
{routes.map((route) => (
<PlanElement
key={route.uid}
tripId={route.uid}
trainIdentifier={route.identifier}
trainHeading={route.heading}
stops={route.stops}
/>
))}
{routes.length === 0 && (
<div className={"flex-1 flex justify-center items-center"}>
<div className={"mb-10 flex flex-col items-center"}>
<Warning size={48} className={"mb-3"}/>
<p className={"max-w-xs text-center text-zinc-400"}>
Aktuell sind keine Abfahrtsdaten verfügbar.
</p>
</div>
</div>
)}
</motion.div>
</PanelContent>
</PanelWrapper>
......@@ -44,3 +153,42 @@ const FahrplanPanel = () => {
};
export default FahrplanPanel;
async function getStopData(stop: string): Promise<StationResponse> {
const request = await fetch(`https://vrrf.finalrewind.org/${encodeURIComponent(stop)}.json`);
if(!request.ok) {
throw new Error("Converting stop did not work");
}
const data = await request.json();
if(data.error) {
console.warn("Stop data for", stop, "could not be fetched");
}
// Add internal reference data
data.raw = data.raw.map((r: any) => ({
...r,
internal: {
stop
}
}))
return data as StationResponse;
}
function stringToDelay(input: string): number | undefined {
const delay = parseInt(input);
if(delay === 0) {
return undefined;
}
if(isNaN(delay)) {
console.warn("While parsing delay, the string was not interpretable as number", input);
return undefined;
}
return delay;
}
function processArrival(date: string, time: string): Date {
const d_parts = date.split(".");
return new Date(`${d_parts[2]}-${d_parts[1]}-${d_parts[0]} ${time}`);
}
import { motion } from 'framer-motion';
import React from 'react';
import {motion} from 'framer-motion';
import React, {useEffect, useState} from 'react';
import ProgressIndicator from "./ProgressIndicator";
import classNames from "../../../util/classNames";
const PlanElement = (props: {
tripId: string,
trainIdentifier: string,
trainHeading: string,
stops: {
time: string,
name: string,
arrival: Date,
delay?: number,
name: string
cancelled: boolean
}[]
}) => {
const [shown, setShown] = useState<boolean>(true);
useEffect(() => {
const update = () => {
const latestStop = props.stops.reduce((accu, curr) => {
if (curr.arrival >= accu) {
return curr.arrival;
}
return accu;
}, new Date(0));
if ((new Date()).getTime() <= latestStop.getTime()) {
setShown(true);
} else {
setShown(false);
}
};
update();
const updateInterval = setInterval(update, 1000);
return () => {
clearInterval(updateInterval);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div>
<motion.div
className={"overflow-hidden"}
initial={{
opacity: 1,
translateY: "0%",
marginTop: "0rem"
}}
animate={shown ? {
opacity: 1,
translateY: "0%",
marginTop: "0rem"
} : {
opacity: 0,
translateY: "-100%",
height: 0,
marginTop: "-0.75rem"
}}
transition={{
duration: .5,
ease: "easeOut",
staggerChildren: .3
}}
>
<div className={"flex flex-row gap-4 items-center font-semibold"}>
<div className={"flex justify-center items-center bg-green-700 text-lg h-8 w-16 leading-none"}>
<div className={classNames(
"flex justify-center items-center text-lg h-10 w-20 leading-none",
trainIdentifierToColor(props.trainIdentifier)
)}>
{props.trainIdentifier}
</div>
<h3 className={"text-xl"}>
{props.trainHeading}
<h3 className={"text-xl leading-9"}>
{deDortmund(props.trainHeading)}
</h3>
</div>
<motion.ol role="list" className="overflow-hidden" aria-hidden="true">
<ProgressIndicator first={true} id={"a"} name={"Meitnerweg"} arrival={new Date("Mon Jul 17 2023 23:56:56 GMT+0200 (Central European Summer Time)")}/>
<ProgressIndicator id={"b"} name={"Dortmund Universität S"} arrival={new Date("Mon Jul 17 2023 23:57:07 GMT+0200 (Central European Summer Time)")}/>
<motion.ol role="list" className="overflow-hidden" aria-hidden="true">
{
props.stops.map((stop, index) => (
<ProgressIndicator
key={stop.name}
first={index === 0}
id={stop.name}
name={deDortmund(stop.name)}
arrival={stop.arrival}
delay={stop.delay}
cancelled={stop.cancelled}
/>
))
}
</motion.ol>
</div>
</motion.div>
);
};
export default PlanElement;
const trainIdentifierToColor = (identifier: string): string => {
if (identifier.startsWith("S")) {
return "bg-green-700";
}
if (identifier.startsWith("X")) {
return "bg-pink-700";
}
// Fixme: This should just be "if first is number"
if (identifier.startsWith("4")) {
return "bg-sky-700";
}
return "bg-zinc-700";
}
const deDortmund = (input: string): string => {
// Don't remove the city from central station location
if (input.includes("Hbf")) {
return input;
}
return input
// In all other cases, remove dortmund
.replaceAll("Dortmund/Joseph Fraunhofer", "Joseph-von-Fraunhofer")
// Special case: Because the API is dumb, translate JvF to be consistent
.replaceAll("Dortmund ", "");
}
import TimeAgo from 'javascript-time-ago'
import de from 'javascript-time-ago/locale/de'
import {useEffect, useState} from "react";
export default function ProgressIndicator(props: {first?: boolean, id: string, name: string, arrival: Date}) {
const [timeUntil, setTimeUntil] = useState<string>("");
useEffect(() => {
// Setup TimeAgo
TimeAgo.addDefaultLocale(de);
const timeAgo = new TimeAgo('de-DE')
const update = () => {
setTimeUntil(timeAgo.format(new Date(), {future: true}));
}
setInterval(update, 1000);
update();
}, []);
import classNames from "../../../util/classNames";
type ProgressIndicatorProps = {
first?: boolean,
id: string,
name: string,
arrival: Date,
delay?: number,
cancelled: boolean
}
export default function ProgressIndicator(props: Readonly<ProgressIndicatorProps>) {
return (
<li className={`relative ${!(props.first ?? false) ? "-mt-3.5" : "-mt-1"} text-sm text-zinc-300`} key={props.id}>
<li className={`relative ${!(props.first ?? false) ? "-mt-3.5" : "-mt-2"} text-sm text-zinc-300`} key={props.id}>
<div className={"flex flex-row gap-4"}>
<div className={"w-16 flex flex-row justify-center"}>
<div className="w-[3px] h-6 -mb-5 bg-zinc-400" aria-hidden="true" />
<div className={"w-20 flex flex-row justify-center"}>
<div className="w-[.25rem] h-6 -mb-5 bg-zinc-400" aria-hidden="true" />
</div>
<div className={"flex-1"}></div>
</div>
<div className="relative flex flex-row items-center gap-4">
<div className={"w-16 flex flex-row justify-center"}>
<span className="h-9 flex items-center" aria-hidden="true">
<span className="relative z-10 w-[18px] h-[18px] flex items-center justify-center bg-zinc-400 rounded-full">
<span className="h-[12px] w-[12px] bg-zinc-900 rounded-full" />
<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"}>
<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="h-[1rem] w-[1rem] bg-zinc-900 rounded-full" />
</span>
</span>
</div>
......@@ -40,10 +35,19 @@ export default function ProgressIndicator(props: {first?: boolean, id: string, n
{props.name}
</div>
<div>
{timeUntil}
<div className={classNames("tabular-nums", (props.delay || props.cancelled) ? "text-red-400" : "text-zinc-300")}>
{props.delay ? (
<span className={"text-red-400"}>
(+{props.delay})&nbsp;&nbsp;
</span>
) : null}
{renderTime(props.arrival)}
</div>
</div>
</li>
)
}
const renderTime = (date: Date) => {
return `${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}`
}
export type StationResponse = {
error: string | null,
preformatted: string[][],
raw: Departure[],
version: string
}
export type Departure = {
countdown: string,
date: string,
delay: string,
destination: string,
info: string,
is_cancelled: number,
key: string,
line: string,
lineref: {
direction: string,
identifier: string,
mot: string,
name: string,
operator: string,
route: string,
type: string,
valid: string
},
mot: string,
next_route: any[],
occupancy: null,
platform: string,
platform_db: number,
platform_name: string,
prev_route: any[],
sched_date: string,
sched_time: string,
time: string,
train_no: null,
type: string,
internal: {
stop: string
}
}
import React, { 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";
export type GremiumPanelDefinition = {
gremien: [Gremium];
};
type Gremium = {
name: string;
description: string;
link: string;
time: string;
location: string;
};
const GremiumPanel = (props: { definition: GremiumPanelDefinition }) => {
const [gremium, setGremium] = useState<Gremium>(props.definition.gremien[0]);
const cycle = useRef<number>(0);
useEffect(() => {
const update = async () => {
setGremium(
props.definition.gremien[
cycle.current++ % props.definition.gremien.length
],
);
};
update();
const interval = setInterval(update, 20 * 1000);
return () => {
clearInterval(interval);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<PanelWrapper>
<PanelTitle title={"Termine"} />
<PanelContent>
<div className={"flex flex-row"}>
<div className={"flex-1"}>
<h2 className={"text-xl font-semibold mt-2"}>
{gremium.description}
</h2>
<div className={"flex flex-row gap-4"}>
<p className={"text-sm text-gray-400"}>
<Clock size={20} className={"inline mb-1.5 mr-1"} />
{gremium.time}
</p>
<p className={"text-sm text-gray-400"}>
<MapPin size={20} className={"inline mb-1.5 mr-1"} />
{gremium.location}
</p>
</div>
</div>
<div className={""}>
<QRCode
value={gremium.link}
className={"h-24 w-24"}
fgColor={"#ffffff"}
bgColor={"#18181b"}
/>
</div>
</div>
</PanelContent>
</PanelWrapper>
);
};
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;
import React from 'react';
const Mensaplan = () => {
return (
<div>
Mensaplan
</div>
);
};
export default Mensaplan;
import React, {useEffect, useRef, useState} from 'react';
import PanelWrapper from "../../meta/PanelWrapper";
import PanelTitle from "../../meta/PanelTitle";
import PanelContent from "../../meta/PanelContent";
import {CanteenAPIResponse} from "./types/canteenAPI";
import {Leaf, Plant, Bone, Record, Fish, ForkKnife} from "@phosphor-icons/react";
import ProgressBar from "../../meta/ProgressBar";
type Dish = {
name: string,
details: string,
typeIcons: React.FC<any>[],
price: {
"student": string,
"staff": string,
"guest": string
},
}
type MensaPanelDefinition = {
canteenId: number,
closingTime: {
hours: number,
minutes: number
}
}
const MensaplanPanel = (props: {definition: MensaPanelDefinition}) => {
const menus = useRef<Dish[]>([]);
const specials = useRef<Dish[]>([]);
const [relativeDay, setRelativeDay] = useState<string>("heute");
const [groupName, setGroupName] = useState<string>("Menüs")
const cycle = useRef<number>(0);
const [dishes, setDishes] = useState<Dish[]>([]);
useEffect(() => {
const update = async () => {
try {
// Determine day to fetch for
const now = new Date();
let fetchFor: string;
if (
now.getHours() > props.definition.closingTime.hours || (
now.getHours() === props.definition.closingTime.hours &&
now.getMinutes() > props.definition.closingTime.minutes
)
) {
// After closing, fetch for next day
setRelativeDay("morgen");
const tomorrow = new Date(now.setTime(now.getTime() + 24 * 60 * 60 * 1000));
fetchFor = toYYYYMMDD(tomorrow);
} else {
// otherwise, fetch for today
setRelativeDay("heute");
fetchFor = toYYYYMMDD(now);
}
// Request the API
const request = await fetch(`/canteen-menu/v3/canteens/${props.definition.canteenId}/${fetchFor}`);
if (request.status !== 200) {
menus.current = [];
specials.current = [];
return;
}
const data = await request.json() as CanteenAPIResponse;
const old_menus_count = menus.current.length;
const old_specials_count = menus.current.length;
// ToDo: This needs to be cleaned up!
menus.current = data
.filter(d => d.counter !== "Beilagen")
.filter(d => d.counter !== "Aktionsteller")
.sort((a, b) => {
return a.position - b.position
})
.map(d => ({
name: (d.title.de
.split(" | ")
.at(0) ?? "Name nicht bekannt")
.replace(" nach Wahl", "")
.replaceAll(/\(.*\)/g, ""),
details: d.title.de
.split(" | ")
.slice(1, -1)
.join(", ")
.replace(" nach Wahl", "")
.replaceAll(/\(.*\)/g, ""),
typeIcons: d.type.map(typeToIcon).filter(i => i !== null) as unknown as React.FC<any>[],
price: d.price
}))
specials.current = data
.filter(d => d.counter === "Aktionsteller")
.sort((a, b) => {
return a.position - b.position
})
.map(d => ({
name: (d.title.de
.split(" | ")
.at(0) ?? "Name nicht bekannt")
.replace(" nach Wahl", "")
.replaceAll(/\(.*\)/g, ""),
details: d.title.de
.split(" | ")
.slice(1, -1)
.join(", ")
.replace(" nach Wahl", "")
.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 (menus.current.length !== old_menus_count || specials.current.length !== old_specials_count) {
setDishes(menus.current);
cycle.current = 0;
setGroupName("Menüs")
}
}
catch (e) {
console.warn("MensaPlan not showing data because", e);
menus.current = [];
specials.current = [];
}
}
update();
const interval = setInterval(update, 1 * 60 * 60 * 1000);
return () => {
clearInterval(interval);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const updateGroup = () => {
cycle.current = (cycle.current + 1) % 2;
switch (cycle.current) {
case 0:
setGroupName("Menüs");
setDishes(menus.current);
break;
case 1:
setGroupName("Aktionsteller");
setDishes(specials.current);
break;
default:
console.error("You fucked up bad!");
break;
}
}
return (
<PanelWrapper>
<PanelTitle title={"Mensaplan für " + relativeDay} info={groupName} />
<PanelContent>
<div className={"grid gap-y-1 gap-x-1.5"} style={{gridTemplateColumns: "1fr 10fr 1fr"}}>
{dishes.map(dish => (
<>
{/* Fixme: This shifts the name out of line if there is more than one */}
<div className={"flex flex-row gap-1.5"}>
{dish.typeIcons.map((Icon) => (
<Icon key={Icon.name} size={32} className={"text-zinc-400"}/>
))}
</div>
<h3 className={"text-xl leading-tight"}>
{dish.name}
</h3>
<p className={"text-sm text-zinc-400 leading-tight text-right"}>
{dish.price.student}
</p>
<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"}>
<div className={"mb-10 flex flex-col items-center"}>
<ForkKnife size={48} className={"mb-3"}/>
<p className={"text-center text-zinc-400"}>
Es werden keine Gerichte in dieser Kategorie angeboten
</p>
</div>
</div>
)}
</PanelContent>
<ProgressBar duration={20000} idName={'mena-progress'} callbackFunction={updateGroup} />
</PanelWrapper>
);
};
export default MensaplanPanel;
function toYYYYMMDD(input: Date): string {
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 {
switch (type) {
case "N":
// Vegan
return Plant
case "V":
// Vegetarisch
return Leaf
case "G":
// Geflügel
return Bone
case "K":
// Kosher
// ToDo: Work out proper icon
return Record
case "S":
// Schwein
return Bone
case "R":
// Rind (geraten)
return Bone
case "W":
// Wild
return Bone
case "F":
// Fisch (geraten)
return Fish
default:
return null
}
}
export type CanteenAPIResponse = CanteenAPIDish[];
export type CanteenAPIDish = {
additives: string[],
category: string,
counter: string,
dispoId: string,
position: number,
price: {
student: string,
staff: string,
guest: string
},
title: {
de: string,
en: string
},
type: string[]
}
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 React from 'react';
const Uhr = () => {
const UhrPanel = () => {
return (
<div>
Uhr
......@@ -8,4 +8,4 @@ const Uhr = () => {
);
};
export default Uhr;
export default UhrPanel;
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;