Skip to content
Snippets Groups Projects

Compare revisions

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

Source

Select target project
No results found
Select Git revision

Target

Select target project
  • julianbohnenkaemper/infoscreen
  • acul/infoscreen-new
  • tudo-fsinfo/infoscreen/infoscreen
  • alexr/infoscreen
  • fabianvanrissenbeck/infoscreen
  • evysgarden/infoscreen
  • falk-pages/infoscreen
  • smmokerg/infoscreen
  • smmivog2/infoscreen-fussball
  • smmivog2/infoscreen-update-preisliste
10 results
Select Git revision
Show changes
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;
......@@ -17,7 +17,16 @@ module.exports = {
'3xl': '1.953rem',
'4xl': '2.441rem',
'5xl': '3.052rem'
}
},
animation: {
marquee: 'marquee 20s linear infinite'
},
keyframes: {
marquee: {
'0%': { transform: 'translateX(0)' },
'100%': { transform: 'translateX(-100%)' },
}
},
}
},
plugins: [],
......