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 496 additions and 98 deletions
File added
File added
File added
File added
File added
File added
File added
......@@ -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>
......
......@@ -4,8 +4,8 @@ import ScreenWrapper from "./meta/ScreenWrapper";
import useLayout from "./hooks/useLayout";
import {PanelRenderers} from "./panels/_Panels";
import LayoutElement from "./layout/LayoutElement";
import ErrorPanel from "./panels/Error";
import {ErrorBoundary} from "react-error-boundary";
import ErrorPanel from "./panels/Error/ErrorPanel";
function App() {
const layout = useLayout();
......
......@@ -12,3 +12,89 @@
.bg-lower-gradient {
background-image: linear-gradient(180deg, rgba(24, 24, 27, 0) 0%, rgba(24, 24, 27, 0.70) 60%, rgba(24, 24, 27, 1.00) 100%)
}
* {
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}) => {
return (
<div className={"px-5 py-4 text-zinc-400"}>
<h2>{props.title}</h2>
</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;
import React from 'react';
import PanelWrapper from "../meta/PanelWrapper";
import classNames from "../util/classNames";
import PanelContent from "../meta/PanelContent";
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
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"}>
......
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";
import PanelWrapper from "../../meta/PanelWrapper";
const ErrorPanel = (props: {message?: string}) => {
const message = props.message ?? "Failed to render Panel";
......
......@@ -4,7 +4,8 @@ import PlanElement from "./components/PlanElement";
import PanelTitle from "../../meta/PanelTitle";
import PanelContent from "../../meta/PanelContent";
import {StationResponse} from "./types/vrrfAPI";
import {Warning} from "phosphor-react";
import {Warning} from "@phosphor-icons/react";
import {motion} from 'framer-motion';
export type FahrplanPanelDefinition = {
stops: string[],
......@@ -21,8 +22,11 @@ type Route = {
stops: {
name: string,
arrival: Date,
delay?: number
}[]
delay?: number,
countdown: number,
cancelled: boolean
}[],
countdown: number
}
const FahrplanPanel = (props: {definition: FahrplanPanelDefinition}) => {
......@@ -39,43 +43,68 @@ const FahrplanPanel = (props: {definition: FahrplanPanelDefinition}) => {
// Determine stop data from the departures
for(let departure of departures) {
// First throw away all data that belongs to a filtered category
if((props.definition.filter.types ?? []).includes(departure.type)) {
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.lineref.identifier)
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);
console.log(arrival)
// 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,
heading: departure.lineref.direction,
uid: departure.key + "-" + departure.lineref.identifier,
heading: departure.destination,
identifier: departure.line,
stops: [
{
name: departure.internal.stop,
arrival,
delay
delay,
countdown: parseInt(departure.countdown),
cancelled: departure.is_cancelled === 1
}
]
],
countdown: parseInt(departure.countdown)
})
} else {
// If it doesn't, just add a stop to the existing route
// 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)
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);
......@@ -87,13 +116,14 @@ const FahrplanPanel = (props: {definition: FahrplanPanelDefinition}) => {
return () => {
clearInterval(interval);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<PanelWrapper>
<PanelTitle title={"ÖPNV Monitor"}/>
<PanelContent className={"flex flex-col"}>
<div className={"flex-1 flex flex-col gap-3"}>
<motion.div layout transition={{duration: .3, ease: "easeOut"}} className={"flex-1 flex flex-col gap-3"}>
{routes.map((route) => (
<PlanElement
key={route.uid}
......@@ -114,7 +144,7 @@ const FahrplanPanel = (props: {definition: FahrplanPanelDefinition}) => {
</div>
</div>
)}
</div>
</motion.div>
</PanelContent>
</PanelWrapper>
......@@ -143,48 +173,22 @@ async function getStopData(stop: string): Promise<StationResponse> {
}
}))
console.log(data);
return data as StationResponse;
}
function stringToDelay(input: string): number | undefined {
try {
const delay = parseInt(input);
if(delay === 0) {
return undefined;
}
return delay;
} catch (e) {
console.warn("While parsing delay, the string was not interpretable", input);
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(".");
console.log(date, time, "to", `${d_parts[2]}-${d_parts[1]}-${d_parts[0]} ${time}`);
return new Date(`${d_parts[2]}-${d_parts[1]}-${d_parts[0]} ${time}`);
}
// function sortData(data: any) {
// for (var i = 0; i < data.length; ++i) {
// data[i]['stops'] = data[i]['stops'].sort(sortFn);
// data[i]['timeValue'] = data[i]['stops'][0]['timeValue'];
// }
// return data.sort(sortFn);
// }
//
// function sortFn(a: any, b: any) {
// return a['timeValue'] - b['timeValue'];
// }
//
// function calcDateValue(_year: string, _month: string, _day: string, _hour: string, _minute: string): number {
// const year = parseInt(_year) * 12 * 31 * 24 * 60;
// const month = parseInt(_month) * 31 * 24 * 60;
// const day = parseInt(_day) * 24 * 60;
// const hour = parseInt(_hour) * 60;
// const minute = parseInt(_minute);
// return year+month+day+hour+minute;
// }
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";
......@@ -10,11 +10,61 @@ const PlanElement = (props: {
stops: {
name: string,
arrival: Date,
delay?: number
delay?: number,
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={classNames(
"flex justify-center items-center text-lg h-10 w-20 leading-none",
......@@ -22,8 +72,8 @@ const PlanElement = (props: {
)}>
{props.trainIdentifier}
</div>
<h3 className={"text-xl"}>
{props.trainHeading}
<h3 className={"text-xl leading-9"}>
{deDortmund(props.trainHeading)}
</h3>
</div>
......@@ -32,15 +82,18 @@ const PlanElement = (props: {
{
props.stops.map((stop, index) => (
<ProgressIndicator
key={stop.name}
first={index === 0}
id={stop.name}
name={stop.name}
name={deDortmund(stop.name)}
arrival={stop.arrival}
delay={stop.delay}
cancelled={stop.cancelled}
/>
))
}
</motion.ol>
</div>
</motion.div>
);
};
......@@ -48,18 +101,32 @@ export default PlanElement;
const trainIdentifierToColor = (identifier: string): string => {
if(identifier.startsWith("S")) {
if (identifier.startsWith("S")) {
return "bg-green-700";
}
if(identifier.startsWith("X")) {
if (identifier.startsWith("X")) {
return "bg-pink-700";
}
// Fixme: This should just be "if first is number"
if(identifier.startsWith("4")) {
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";
import classNames from "../../../util/classNames";
export default function ProgressIndicator(props: {first?: boolean, id: string, name: string, arrival: Date, delay?: number}) {
// 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();
// }, []);
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-2"} text-sm text-zinc-300`} key={props.id}>
<div className={"flex flex-row gap-4"}>
......@@ -27,7 +19,10 @@ export default function ProgressIndicator(props: {first?: boolean, id: string, n
<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">
......@@ -40,12 +35,12 @@ export default function ProgressIndicator(props: {first?: boolean, id: string, n
{props.name}
</div>
<div className={"tabular-nums"}>
{props.delay && (
<div className={classNames("tabular-nums", (props.delay || props.cancelled) ? "text-red-400" : "text-zinc-300")}>
{props.delay ? (
<span className={"text-red-400"}>
(+2)&nbsp;&nbsp;
(+{props.delay})&nbsp;&nbsp;
</span>
)}
) : null}
{renderTime(props.arrival)}
</div>
</div>
......@@ -54,5 +49,5 @@ export default function ProgressIndicator(props: {first?: boolean, id: string, n
}
const renderTime = (date: Date) => {
return `${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")} Uhr`
return `${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}`
}
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;