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 1072 additions and 154 deletions
......@@ -2,7 +2,7 @@
<html lang="en">
<head>
<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="theme-color" content="#000000" />
<meta
......@@ -24,7 +24,7 @@
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`.
-->
<title>React App</title>
<title>Infoscreen</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
......
......@@ -16,3 +16,85 @@
* {
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}) => {
return (
<div className={"px-6 py-4 text-zinc-400 flex flex-row justify-between"}>
<h2>{props.title}</h2>
{props.info && (
<p>{props.info}</p>
)}
</div>
);
const PanelTitle = (props: { title: string; info?: string }) => {
return (
<div
className={
"px-6 pt-4 pb-2 text-zinc-400 flex flex-row justify-between"
}
>
<h2>{props.title}</h2>
{props.info && <p>{props.info}</p>}
</div>
);
};
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 = {
url: string,
fit?: "fill" | "fit",
title?: string,
description?: string
description?: string,
gradient?: boolean
}
const BildPanel = (props: {definition: BildPanelDefinition}) => {
......@@ -31,7 +32,10 @@ const BildPanel = (props: {definition: BildPanelDefinition}) => {
>
{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>
<PanelContent className={"absolute inset-0 w-full h-full flex flex-col justify-end"}>
......
......@@ -23,7 +23,8 @@ type Route = {
name: string,
arrival: Date,
delay?: number,
countdown: number
countdown: number,
cancelled: boolean
}[],
countdown: number
}
......@@ -70,7 +71,8 @@ const FahrplanPanel = (props: {definition: FahrplanPanelDefinition}) => {
name: departure.internal.stop,
arrival,
delay,
countdown: parseInt(departure.countdown)
countdown: parseInt(departure.countdown),
cancelled: departure.is_cancelled === 1
}
],
countdown: parseInt(departure.countdown)
......@@ -81,7 +83,8 @@ const FahrplanPanel = (props: {definition: FahrplanPanelDefinition}) => {
name: departure.internal.stop,
arrival,
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
......@@ -170,8 +173,6 @@ async function getStopData(stop: string): Promise<StationResponse> {
}
}))
console.log(data);
return data as StationResponse;
}
......
......@@ -10,7 +10,8 @@ const PlanElement = (props: {
stops: {
name: string,
arrival: Date,
delay?: number
delay?: number,
cancelled: boolean
}[]
}) => {
const [shown, setShown] = useState<boolean>(true);
......@@ -87,6 +88,7 @@ const PlanElement = (props: {
name={deDortmund(stop.name)}
arrival={stop.arrival}
delay={stop.delay}
cancelled={stop.cancelled}
/>
))
}
......
......@@ -5,7 +5,8 @@ type ProgressIndicatorProps = {
id: string,
name: string,
arrival: Date,
delay?: number
delay?: number,
cancelled: boolean
}
export default function ProgressIndicator(props: Readonly<ProgressIndicatorProps>) {
......@@ -18,7 +19,10 @@ export default function ProgressIndicator(props: Readonly<ProgressIndicatorProps
<div className={"flex-1"}></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"}>
<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">
......@@ -31,7 +35,7 @@ export default function ProgressIndicator(props: Readonly<ProgressIndicatorProps
{props.name}
</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 ? (
<span className={"text-red-400"}>
(+{props.delay})&nbsp;&nbsp;
......
import React, { useEffect, useRef, useState } from 'react'
import React, { useEffect, useRef, useState } from "react";
import PanelWrapper from "../../meta/PanelWrapper";
import PanelContent from "../../meta/PanelContent";
import PanelTitle from '../../meta/PanelTitle'
import PanelTitle from "../../meta/PanelTitle";
import QRCode from "react-qr-code";
import { Clock, MapPin } from '@phosphor-icons/react'
import { Clock, MapPin } from "@phosphor-icons/react";
export type GremiumPanelDefinition = {
gremien: [Gremium]
}
gremien: [Gremium];
};
type Gremium = {
name: string,
description: string,
link: string,
time: string,
location: string
}
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);
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]);
console.log(gremium);
console.log(cycle.current);
}
useEffect(() => {
const update = async () => {
setGremium(
props.definition.gremien[
cycle.current++ % props.definition.gremien.length
],
);
};
update();
const interval = setInterval(update, 20 * 1000);
update();
const interval = setInterval(update, 20 * 1000);
return () => {
clearInterval(interval);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
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>
);
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;
......@@ -4,11 +4,17 @@ 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>[]
typeIcons: React.FC<any>[],
price: {
"student": string,
"staff": string,
"guest": string
},
}
type MensaPanelDefinition = {
......@@ -61,8 +67,6 @@ const MensaplanPanel = (props: {definition: MensaPanelDefinition}) => {
const data = await request.json() as CanteenAPIResponse;
console.log(data);
const old_menus_count = menus.current.length;
const old_specials_count = menus.current.length;
......@@ -77,13 +81,16 @@ const MensaplanPanel = (props: {definition: MensaPanelDefinition}) => {
name: (d.title.de
.split(" | ")
.at(0) ?? "Name nicht bekannt")
.replace(" nach Wahl", ""),
.replace(" nach Wahl", "")
.replaceAll(/\(.*\)/g, ""),
details: d.title.de
.split(" | ")
.slice(1, -1)
.join(", ")
.replace(" nach Wahl", ""),
typeIcons: d.type.map(typeToIcon).filter(i => i !== null) as unknown as React.FC<any>[]
.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
......@@ -95,13 +102,16 @@ const MensaplanPanel = (props: {definition: MensaPanelDefinition}) => {
name: (d.title.de
.split(" | ")
.at(0) ?? "Name nicht bekannt")
.replace(" nach Wahl", ""),
.replace(" nach Wahl", "")
.replaceAll(/\(.*\)/g, ""),
details: d.title.de
.split(" | ")
.slice(1, -1)
.join(", ")
.replace(" nach Wahl", ""),
typeIcons: d.type.map(typeToIcon).filter(i => i !== null) as unknown as React.FC<any>[]
.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
......@@ -127,56 +137,59 @@ const MensaplanPanel = (props: {definition: MensaPanelDefinition}) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const update = async () => {
switch (cycle.current % 2) {
case 0:
setGroupName("Menüs");
setDishes(menus.current);
break;
case 1:
setGroupName("Aktionsteller");
setDishes(specials.current);
break;
}
cycle.current = (cycle.current + 1) % 2;
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;
}
}
update();
const interval = setInterval(update, 20 * 1000);
return () => {
clearInterval(interval);
}
}, []);
return (
<PanelWrapper>
<PanelTitle title={"Mensaplan für " + relativeDay} info={groupName} />
<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 => (
<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 */}
<div className={"flex flex-row gap-2 mr-2"}>
{dish.typeIcons.map((Icon, index) => (
<Icon key={Icon.name + index} size={32} className={"text-zinc-400"}/>
))}
</div>
<h3 className={"text-xl leading-tight"}>
{dish.name}
</h3>
<p className={"text-sm text-zinc-300 leading-tight"}>
{dish.details}
</p>
</div>
))}
</div>
<>
{/* 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"}>
{
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"}>
......@@ -186,6 +199,7 @@ const MensaplanPanel = (props: {definition: MensaPanelDefinition}) => {
</div>
)}
</PanelContent>
<ProgressBar duration={20000} idName={'mena-progress'} callbackFunction={updateGroup} />
</PanelWrapper>
);
};
......@@ -193,7 +207,7 @@ const MensaplanPanel = (props: {definition: MensaPanelDefinition}) => {
export default MensaplanPanel;
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 {
......
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 @@
*/
import React from "react";
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 BildPanel from "./Bild/BildPanel";
import MensaplanPanel from "./Mensaplan/MensaplanPanel";
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";
/*
......@@ -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
* `| "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
* 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`.
*/
export const PanelRenderers: {[panelType: string]: React.FC<any & {definition: PanelDefinition<any>}>} = {
"fahrplan": FahrplanPanel,
"bild": BildPanel,
"mensaplan": MensaplanPanel,
"callout": CalloutPanel,
"gremium": GremiumPanel,
"placeholder": () => (
<PanelWrapper className={"flex flex-col items-center justify-center text-zinc-400"}>
export const PanelRenderers: {
[panelType: string]: React.FC<any & { definition: PanelDefinition<any> }>;
} = {
fahrplan: FahrplanPanel,
nextbike: NextbikePanel,
bild: BildPanel,
mensaplan: MensaplanPanel,
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
</PanelWrapper>
)
),
};
/*
* That should have been it. Now, have fun implementing your renderer!
*/
......@@ -9,21 +9,24 @@ const NO_LAYOUT_CONFIG: LayoutConfig = {
}
export class LayoutService {
static configs: LayoutConfig[] = [];
public static configs: LayoutConfig[] = [];
static async init(): Promise<void> {
try {
const activeConfigs = await fetch("/activeConfigs.json").then(content => content.json());
const configFetches = (activeConfigs as string[])
.map(configPath => fetch(configPath).then(content => content.json()));
LayoutService.configs = await Promise.all(configFetches);
LayoutService.configs = await LayoutService.fetchActiveConfigs();
const autoRefreshInterval = 1000 * 60 * 10;
setInterval(() => {
LayoutService.fetchActiveConfigs()
.then(cfg => LayoutService.configs = cfg)
.catch(_ => console.warn("cannot refresh layout config"));
}, autoRefreshInterval);
} catch (e) {
console.error("LayoutService could not init", e)
}
}
static getActiveLayout(): LayoutConfig {
public static getActiveLayout(): LayoutConfig {
const now = new Date();
const activeConfigs = this.configs.filter(config => {
......@@ -37,10 +40,17 @@ export class LayoutService {
}, 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,
consider yourself warned now and don't blame me */
return activeConfigs.at(0) ?? NO_LAYOUT_CONFIG;
const configFetches = (activeConfigs as string[])
.map(configPath => fetch(configPath).then(content => content.json()));
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;