feat: cleanup

This commit is contained in:
Mathias Schopmans
2024-03-04 15:36:57 +01:00
committed by Mathias Schopmans
parent 1583363ab8
commit ab005e03b3
139 changed files with 0 additions and 39087 deletions

View File

@@ -1,141 +0,0 @@
import classNames from "classnames";
import React from "react";
import {
BrowserRouter,
Navigate,
Route,
Routes,
useParams,
} from "react-router-dom";
import { ConfigData, publicUrl } from "../config";
import { Messages, MessagesProvider } from "../context/MessagesContext";
import { useSearchParamState } from "../hooks/use-search-param-state";
import { Item, filteredOnly, getTags } from "../model";
import Footer from "./Footer/Footer";
import Header from "./Header/Header";
import Router from "./Router";
const useFetch = <D extends unknown>(url: string): D | undefined => {
const [data, setData] = React.useState<D>();
React.useEffect(() => {
fetch(url)
.then((response) => response.json())
.then((data: D) => {
setData(data);
})
.catch((error) => {
console.error(`fetch ${url} failed. Did the file exist?`, error);
});
}, [url]);
return data;
};
const usePage = (params: Record<string, string | undefined>) => {
return (params["*"] || "").replace(".html", "");
};
const useFilteredItems = ({ items }: { items: Item[] }) => {
const [searchParamState] = useSearchParamState();
const { tags } = searchParamState;
return tags ? filteredOnly(items, tags) : items;
};
const RouterWithPageParam = ({
items,
releases,
config,
}: {
items: Item[];
releases: string[];
config: ConfigData;
}) => {
const page = usePage(useParams());
const [searchParamState] = useSearchParamState();
const { search } = searchParamState;
const filteredItems = useFilteredItems({ items });
return (
<Router
pageName={page || ""}
search={search || ""}
items={filteredItems}
releases={releases}
config={config}
/>
);
};
const HeaderWithPageParam = ({ items }: { items: Item[] }) => {
const page = usePage(useParams());
const tags = getTags(items);
return <Header pageName={page || ""} tags={tags} />;
};
const FooterWithPageParam = ({ items }: { items: Item[] }) => {
const page = usePage(useParams());
const filteredItems = useFilteredItems({ items });
return <Footer pageName={page || ""} items={filteredItems} />;
};
interface Data {
items: Item[];
releases: string[];
}
export default function App() {
const data = useFetch<Data>(
`${publicUrl}rd.json?${process.env.REACT_APP_BUILDHASH}`
);
const messages = useFetch<Messages>(
`${publicUrl}messages.json?${process.env.REACT_APP_BUILDHASH}`
);
const config = useFetch<ConfigData>(
`${publicUrl}config.json?${process.env.REACT_APP_BUILDHASH}`
);
if (data && config) {
const { items, releases } = data;
return (
<MessagesProvider messages={messages}>
<BrowserRouter basename={`${publicUrl}`}>
<Routes>
<Route
path={"/*"}
element={
<div>
<div className="page">
<div className="page__header">
<HeaderWithPageParam items={items} />
</div>
<div className={classNames("page__content")}>
<RouterWithPageParam
config={config}
items={items}
releases={releases}
/>
</div>
<div className="page__footer">
<FooterWithPageParam items={items} />
</div>
</div>
</div>
}
/>
<Route
path={"/"}
element={<Navigate replace to={"/index.html"} />}
/>
</Routes>
</BrowserRouter>
</MessagesProvider>
);
}
return null;
}

View File

@@ -1,41 +0,0 @@
import classNames from "classnames";
import React, { MouseEventHandler } from "react";
import { ConfigData } from "../../config";
import "./badge.scss";
type BadgeProps = {
onClick?: MouseEventHandler;
big?: boolean;
type: "big" | "all" | "empty" | string;
config: ConfigData;
};
const badgeClass = (type: string, config: ConfigData) => {
if (!config.rings.includes(type)) {
return type;
}
return ["first", "second", "third", "fourth"][config.rings.indexOf(type)];
};
export default function Badge({
onClick,
big,
type,
config,
children,
}: React.PropsWithChildren<BadgeProps>) {
const Comp = onClick ? "a" : "span";
return (
<Comp
className={classNames("badge", `badge--${badgeClass(type, config)}`, {
"badge--big": big === true,
})}
onClick={onClick}
href={Comp === "a" ? "#" : undefined}
>
{children}
</Comp>
);
}

View File

@@ -1,43 +0,0 @@
.badge {
color: var(--color-white);
display: inline-block;
box-sizing: border-box;
padding: 0 15px;
text-transform: uppercase;
border-radius: 13px;
font-size: 12px;
line-height: 25px;
height: 25px;
overflow: hidden;
text-decoration: none;
border: 1px solid var(--color-gray-normal);
&--big {
border-radius: 15px;
font-size: 14px;
line-height: 30px;
height: 30px;
padding: 0 20px;
}
&--all {
background: var(--color-gray-normal);
border-color: var(--color-gray-normal);
}
&--first {
background: var(--color-green);
border-color: var(--color-green);
}
&--second {
background: var(--color-orange);
border-color: var(--color-orange);
}
&--third {
background: var(--color-blue);
border-color: var(--color-blue);
}
&--fourth {
background: var(--color-marine);
border-color: var(--color-marine);
}
}

View File

@@ -1,26 +0,0 @@
import classNames from "classnames";
import React from "react";
import "./branding.scss";
type BrandingProps = {
logoContent: React.ReactNode;
modifier?: "backlink" | "logo" | "content" | "footer";
};
export default function Branding({
logoContent,
modifier,
children,
}: React.PropsWithChildren<BrandingProps>) {
return (
<div
className={classNames("branding", {
[`branding--${modifier}`]: modifier,
})}
>
<div className="branding__logo">{logoContent}</div>
<div className="branding__content">{children}</div>
</div>
);
}

View File

@@ -1,46 +0,0 @@
@import "../../styles/sccs-vars.scss";
.branding {
margin: 40px 0;
display: flex;
align-items: center;
justify-content: space-between;
min-height: 60px;
transition: opacity 450ms cubic-bezier(0.24, 1.12, 0.71, 0.98);
opacity: 1;
&__backlink {
flex: 0 0 auto;
}
&__logo {
flex: 0 0 200px;
& img {
display: inline-block;
}
}
&__content {
flex: 0 1 auto;
}
&.is-hidden {
opacity: 0;
}
@media (max-width: $until-sm) {
&--footer {
display: block;
text-align: center;
.branding__logo {
margin: 0 0 15px;
}
}
}
@media (max-width: $until-xl) {
margin: 15px 0 0;
}
}

View File

@@ -1,44 +0,0 @@
import * as d3 from "d3";
import React, { useLayoutEffect, useRef } from "react";
export const YAxis: React.FC<{
scale: d3.ScaleLinear<number, number>;
}> = ({ scale }) => {
const ref = useRef<SVGSVGElement>(null);
useLayoutEffect(() => {
if (ref.current == null) {
return;
}
const axisGenerator = d3.axisLeft(scale).ticks(6);
d3.select(ref.current)
.attr("class", "y-axis")
.call(axisGenerator)
.call((g) => g.selectAll(".tick text").remove())
.call((g) => g.selectAll(".tick line").remove())
.call((g) => g.selectAll(".domain").remove());
}, [scale]);
return <g ref={ref} />;
};
export const XAxis: React.FC<{
scale: d3.ScaleLinear<number, number>;
}> = ({ scale }) => {
const ref = useRef<SVGSVGElement>(null);
useLayoutEffect(() => {
if (ref.current == null) {
return;
}
const axisGenerator = d3.axisBottom(scale).ticks(6);
d3.select(ref.current)
.attr("class", "x-axis")
.call(axisGenerator)
.call((g) => g.selectAll(".tick text").remove())
.call((g) => g.selectAll(".tick line").remove())
.call((g) => g.selectAll(".domain").remove());
}, [scale]);
return <g ref={ref} />;
};

View File

@@ -1,150 +0,0 @@
import { ScaleLinear } from "d3";
import React from "react";
import { ConfigData } from "../../config";
import { Blip, FlagType, Item, Point } from "../../model";
import Link from "../Link/Link";
import { ChangedBlip, DefaultBlip, NewBlip } from "./BlipShapes";
/*
See https://medium.com/create-code/build-a-radar-diagram-with-d3-js-9db6458a9248
for a good explanation of formulas used to calculate various things in this component
*/
function generateCoordinates(
blip: Blip,
xScale: ScaleLinear<number, number>,
yScale: ScaleLinear<number, number>,
config: ConfigData
): Point {
const pi = Math.PI,
ringRadius = config.chartConfig.ringsAttributes[blip.ringPosition].radius,
previousRingRadius =
blip.ringPosition === 0
? 0
: config.chartConfig.ringsAttributes[blip.ringPosition - 1].radius,
ringPadding = 0.7;
// radian between 0 and 90 degrees
const randomDegree =
((0.1 + (blip.angleFraction || Math.random()) * 0.8) * 90 * pi) / 180;
// random distance from the centre of the radar, but within given ring. Also, with some "padding" so the points don't touch ring borders.
const radius = pointBetween(
previousRingRadius + ringPadding,
ringRadius - ringPadding,
blip.radiusFraction || Math.random()
);
/*
Multiples of PI/2. To apply the calculated position to the specific quadrant.
Order here is counter-clockwise, so we need to "invert" quadrant positions (i.e. swap 2 with 4)
*/
const shift = (pi * [1, 4, 2, 3][blip.quadrantPosition - 1]) / 2;
return {
x: xScale(Math.cos(randomDegree + shift) * radius),
y: yScale(Math.sin(randomDegree + shift) * radius),
};
}
function pointBetween(min: number, max: number, amount: number): number {
return amount * (max - min) + min;
}
function distanceBetween(point1: Point, point2: Point): number {
const a = point2.x - point1.x;
const b = point2.y - point1.y;
return Math.sqrt(a * a + b * b);
}
function renderBlip(
blip: Blip,
index: number,
config: ConfigData
): JSX.Element {
const props = {
blip,
className: "blip",
fill: blip.colour,
"data-background-color": blip.colour,
"data-text-color": blip.txtColour,
"data-tip": blip.title,
key: index,
};
switch (blip.flag) {
case FlagType.new:
return <NewBlip {...props} config={config} />;
case FlagType.changed:
return <ChangedBlip {...props} config={config} />;
default:
return <DefaultBlip {...props} config={config} />;
}
}
const BlipPoints: React.FC<{
items: Item[];
xScale: ScaleLinear<number, number>;
yScale: ScaleLinear<number, number>;
config: ConfigData;
}> = ({ items, xScale, yScale, config }) => {
const blips: Blip[] = items.reduce((list: Blip[], item: Item) => {
if (!item.ring || !item.quadrant) {
// skip the blip if it doesn't have a ring or quadrant assigned
return list;
}
const quadrantConfig = config.quadrantsMap[item.quadrant];
if (!quadrantConfig) {
return list;
}
let blip: Blip = {
...item,
quadrantPosition: quadrantConfig.position,
ringPosition: config.rings.findIndex((r) => r === item.ring),
colour: quadrantConfig.colour,
txtColour: quadrantConfig.txtColour,
coordinates: { x: 0, y: 0 },
};
let point: Point;
let counter = 1;
let distanceBetweenCheck: boolean;
do {
const localpoint = generateCoordinates(blip, xScale, yScale, config);
point = localpoint;
counter++;
/*
Generate position of the new blip until it has a satisfactory distance to every other blip (so that they don't touch each other)
and quadrant borders (so that they don't overlap quadrants)
This feels pretty inefficient, but good enough for now.
*/
distanceBetweenCheck = list.some(
(b) =>
distanceBetween(localpoint, b.coordinates) <
config.chartConfig.blipSize + config.chartConfig.blipSize / 2
);
} while (
counter < 100 &&
(Math.abs(point.x - xScale(0)) < 15 ||
Math.abs(point.y - yScale(0)) < 15 ||
distanceBetweenCheck)
);
blip.coordinates = point;
list.push(blip);
return list;
}, []);
return (
<g className="blips">
{blips.map((blip, index) => (
<Link pageName={`${blip.quadrant}/${blip.name}`} key={index}>
{renderBlip(blip, index, config)}
</Link>
))}
</g>
);
};
export default BlipPoints;

View File

@@ -1,65 +0,0 @@
import React from "react";
import { ConfigData } from "../../config";
import { Blip } from "../../model";
type VisualBlipProps = {
className: string;
fill: string;
"data-background-color": string;
"data-text-color": string;
"data-tip": string;
key: number;
};
export const ChangedBlip: React.FC<
{ blip: Blip; config: ConfigData } & VisualBlipProps
> = ({ blip, config, ...props }) => {
const centeredX = blip.coordinates.x - config.chartConfig.blipSize / 2,
centeredY = blip.coordinates.y - config.chartConfig.blipSize / 2;
return (
<rect
transform={`rotate(-45 ${centeredX} ${centeredY})`}
x={centeredX}
y={centeredY}
width={config.chartConfig.blipSize}
height={config.chartConfig.blipSize}
rx="3"
{...props}
/>
);
};
export const NewBlip: React.FC<
{ blip: Blip; config: ConfigData } & VisualBlipProps
> = ({ blip, config, ...props }) => {
const centeredX = blip.coordinates.x - config.chartConfig.blipSize / 2,
centeredY = blip.coordinates.y - config.chartConfig.blipSize / 2;
/*
The below is a predefined path of a triangle with rounded corners.
I didn't find any more human friendly way of doing this as all examples I found have tons of lines of code
e.g. https://observablehq.com/@perlmonger42/interactive-rounded-corners-on-svg-polygons-using-d3-js
*/
return (
<path
transform={`translate(${centeredX}, ${centeredY})`}
d="M.247 10.212l5.02-8.697a2 2 0 013.465 0l5.021 8.697a2 2 0 01-1.732 3H1.98a2 2 0 01-1.732-3z"
{...props}
/>
);
};
export const DefaultBlip: React.FC<
{ blip: Blip; config: ConfigData } & VisualBlipProps
> = ({ blip, config, ...props }) => {
return (
<circle
r={config.chartConfig.blipSize / 2}
cx={blip.coordinates.x}
cy={blip.coordinates.y}
{...props}
/>
);
};

View File

@@ -1,92 +0,0 @@
import * as d3 from "d3";
import React from "react";
import { ConfigData } from "../../config";
import { QuadrantConfig } from "../../model";
const arcAngel = [
[(3 * Math.PI) / 2, (4 * Math.PI) / 2],
[0, Math.PI / 2],
[Math.PI, (Math.PI * 3) / 2],
[Math.PI / 2, Math.PI],
];
function arcPath(
quadrantPosition: number,
ringPosition: number,
xScale: d3.ScaleLinear<number, number>,
config: ConfigData
) {
const [startAngle, endAngle] = arcAngel[quadrantPosition - 1];
const arcAttrs = config.chartConfig.ringsAttributes[ringPosition],
ringRadiusPx = xScale(arcAttrs.radius) - xScale(0),
arc = d3.arc();
return (
arc({
innerRadius: ringRadiusPx - arcAttrs.arcWidth,
outerRadius: ringRadiusPx,
startAngle,
endAngle,
}) || undefined
);
}
const QuadrantRings: React.FC<{
quadrant: QuadrantConfig;
xScale: d3.ScaleLinear<number, number>;
config: ConfigData;
}> = ({ quadrant, xScale, config }) => {
// order from top-right clockwise
const gradientAttributes = [
{ x: 0, y: 0, cx: 1, cy: 1, r: 1 },
{ x: xScale(0), y: 0, cx: 0, cy: 1, r: 1 },
{ x: 0, y: xScale(0), cx: 1, cy: 0, r: 1 },
{ x: xScale(0), y: xScale(0), cx: 0, cy: 0, r: 1 },
];
const gradientId = `${quadrant.position}-radial-gradient`,
quadrantSize = config.chartConfig.size / 2;
return (
<g className="quadrant-ring">
{/* Definition of the quadrant gradient */}
<defs>
<radialGradient
id={gradientId}
{...gradientAttributes[quadrant.position - 1]}
>
<stop offset="0%" stopColor={quadrant.colour}></stop>
<stop
offset="100%"
stopColor={quadrant.colour}
stopOpacity="0"
></stop>
</radialGradient>
</defs>
{/* Gradient background area */}
<rect
width={quadrantSize}
height={quadrantSize}
x={gradientAttributes[quadrant.position - 1].x}
y={gradientAttributes[quadrant.position - 1].y}
fill={`url(#${gradientId})`}
style={{ opacity: 0.5 }}
/>
{/* Rings' arcs */}
{Array.from(config.rings).map((ringPosition, index) => (
<path
key={index}
fill={quadrant.colour}
d={arcPath(quadrant.position, index, xScale, config)}
style={{
transform: `translate(${quadrantSize}px, ${quadrantSize}px)`,
}}
/>
))}
</g>
);
};
export default QuadrantRings;

View File

@@ -1,113 +0,0 @@
import * as d3 from "d3";
import React from "react";
import ReactTooltip from "react-tooltip";
import { ConfigData } from "../../config";
import { Item } from "../../model";
import { XAxis, YAxis } from "./Axes";
import BlipPoints from "./BlipPoints";
import QuadrantRings from "./QuadrantRings";
import "./chart.scss";
const RingLabel: React.FC<{
ring: string;
xScale: d3.ScaleLinear<number, number>;
yScale: d3.ScaleLinear<number, number>;
config: ConfigData;
}> = ({ ring, xScale, yScale, config }) => {
const ringIndex = config.rings.findIndex((r) => r === ring);
const ringRadius = config.chartConfig.ringsAttributes[ringIndex].radius,
previousRingRadius =
ringIndex === 0
? 0
: config.chartConfig.ringsAttributes[ringIndex - 1].radius,
// middle point in between two ring arcs
distanceFromCentre =
previousRingRadius + (ringRadius - previousRingRadius) / 2;
return (
<g className="ring-label">
{/* Right hand-side label */}
<text
x={xScale(distanceFromCentre)}
y={yScale(0)}
textAnchor="middle"
dy=".35em"
>
{ring}
</text>
{/* Left hand-side label */}
<text
x={xScale(-distanceFromCentre)}
y={yScale(0)}
textAnchor="middle"
dy=".35em"
>
{ring}
</text>
</g>
);
};
const RadarChart: React.FC<{
items: Item[];
config: ConfigData;
}> = ({ items, config }) => {
const xScale = d3
.scaleLinear()
.domain(config.chartConfig.scale)
.range([0, config.chartConfig.size]);
const yScale = d3
.scaleLinear()
.domain(config.chartConfig.scale)
.range([config.chartConfig.size, 0]);
return (
<div className="chart" style={{ maxWidth: `${config.chartConfig.size}px` }}>
<svg
viewBox={`0 0 ${config.chartConfig.size} ${config.chartConfig.size}`}
>
<g transform={`translate(${xScale(0)}, 0)`}>
<YAxis scale={yScale} />
</g>
<g transform={`translate(0, ${yScale(0)})`}>
<XAxis scale={xScale} />
</g>
{Object.values(config.quadrantsMap).map((value, index) => {
console.log(value);
return null;
})}
{Object.values(config.quadrantsMap).map((value, index) => (
<QuadrantRings
key={index}
quadrant={value}
xScale={xScale}
config={config}
/>
))}
{Array.from(config.rings).map((ring: string, index) => (
<RingLabel
key={index}
ring={ring}
xScale={xScale}
yScale={yScale}
config={config}
/>
))}
<BlipPoints
items={items}
xScale={xScale}
yScale={yScale}
config={config}
/>
</svg>
<ReactTooltip className="tooltip" offset={{ top: -5 }} />
</div>
);
};
export default RadarChart;

View File

@@ -1,20 +0,0 @@
.chart {
fill: white;
font-size: 12px;
text-align: center;
position: relative;
margin: 0 auto;
}
.chart .blip:hover {
cursor: pointer;
}
.chart .tooltip {
padding: 5px 10px;
border-radius: 11px;
}
.chart .ring-label {
text-transform: uppercase;
}

View File

@@ -1,28 +0,0 @@
import React from "react";
import { Item } from "../../model";
import "./editButton.scss";
type EditButtonProps = {
baseUrl: string;
item: Item & { release?: string };
title?: string;
};
export default function EditButton({
baseUrl,
item,
title,
}: React.PropsWithChildren<EditButtonProps>) {
const href = `${baseUrl}/${item.release}/${item.name}.md`;
return (
<a
className="icon-link"
href={href}
target="_blank"
rel="noopener noreferrer"
>
{title || "Edit"}
</a>
);
}

View File

@@ -1,2 +0,0 @@
.edit-button {
}

View File

@@ -1,40 +0,0 @@
import classNames from "classnames";
import React, { useEffect, useState } from "react";
import "./fadeable.scss";
type FadeableProps = {
leaving: boolean;
onLeave: () => void;
};
export default function Fadeable({
leaving,
onLeave,
children,
}: React.PropsWithChildren<FadeableProps>) {
const [faded, setFaded] = useState(leaving);
useEffect(() => {
if (!faded && leaving) {
setFaded(true);
} else if (faded && !leaving) {
setFaded(false);
}
}, [faded, leaving]);
const handleTransitionEnd = () => {
if (faded) {
onLeave();
}
};
return (
<div
className={classNames("fadable", { "is-faded": faded })}
onTransitionEnd={handleTransitionEnd}
>
{children}
</div>
);
}

View File

@@ -1,8 +0,0 @@
.fadable {
opacity: 1;
transition: opacity 0.2s cubic-bezier(0.54, 0, 0.28, 1);
&.is-faded {
opacity: 0;
}
}

View File

@@ -1,31 +0,0 @@
import { FlagType } from "../../model";
import "./flag.scss";
interface ItemFlag {
flag: FlagType;
}
export default function Flag({
item,
short = false,
}: {
item: ItemFlag;
short?: boolean;
}) {
const ucFirst = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
if (item.flag !== FlagType.default) {
let name = item.flag.toUpperCase();
let title = ucFirst(item.flag);
if (short === true) {
name = title[0];
}
return (
<span className={`flag flag--${item.flag}`} title={title}>
{name}
</span>
);
}
return null;
}

View File

@@ -1,19 +0,0 @@
.flag {
font-size: 9px;
display: inline-block;
padding: 3px 8px;
border-radius: 10px;
position: relative;
vertical-align: top;
margin-top: -2px;
left: 5px;
color: var(--color-white);
&--new {
background: var(--color-red);
}
&--changed {
background: var(--color-blue);
}
}

View File

@@ -1,51 +0,0 @@
import classNames from "classnames";
import React from "react";
import { assetUrl, getItemPageNames, isMobileViewport } from "../../config";
import { useMessages } from "../../context/MessagesContext";
import { Item } from "../../model";
import { sanitize } from "../../sanitize";
import Branding from "../Branding/Branding";
import FooterEnd from "../FooterEnd/FooterEnd";
import "./footer.scss";
interface Props {
items: Item[];
pageName: string;
}
const Footer: React.FC<Props> = ({ items, pageName }) => {
const { footerFootnote } = useMessages();
return (
<div
className={classNames("footer", {
"is-hidden":
!isMobileViewport() && getItemPageNames(items).includes(pageName),
})}
>
{footerFootnote && (
<Branding
modifier="footer"
logoContent={
<img
src={assetUrl("logo.svg")}
width="150px"
height="60px"
alt=""
/>
}
>
<div
className="footnote"
dangerouslySetInnerHTML={sanitize(footerFootnote)}
></div>
</Branding>
)}
<FooterEnd />
</div>
);
};
export default Footer;

View File

@@ -1,11 +0,0 @@
.footer {
border-top: 1px solid var(--color-gray-normal);
transition: opacity 450ms cubic-bezier(0.24, 1.12, 0.71, 0.98) 1500ms;
opacity: 1;
backface-visibility: hidden;
&.is-hidden {
opacity: 0;
transition-delay: 0s;
}
}

View File

@@ -1,58 +0,0 @@
import classNames from "classnames";
import React from "react";
import { useMessages } from "../../context/MessagesContext";
import SocialLink from "../SocialLink/SocialLink";
import "./footerend.scss";
interface Props {
modifier?: "in-sidebar";
}
const FooterEnd: React.FC<Props> = ({ modifier }) => {
const {
socialLinksLabel,
socialLinks,
legalInformationLink,
legalInformationLabel,
} = useMessages();
return (
<div
className={classNames("footer-end", {
[`footer-end__${modifier}`]: modifier,
})}
>
<div className="footer-social">
{socialLinks && (
<>
<div className="footer-social__label">
<p>{socialLinksLabel ?? "Follow us:"}</p>
</div>
<div className="footer-social__links">
{socialLinks.map(({ href, iconName }) => (
<SocialLink href={href} iconName={iconName} key={iconName} />
))}
</div>
</>
)}
</div>
{legalInformationLink && (
<div className="footer-copyright">
<p>
<a
href={legalInformationLink}
target="_blank"
rel="noopener noreferrer"
>
{legalInformationLabel || "Legal Information"}
</a>
</p>
</div>
)}
</div>
);
};
export default FooterEnd;

View File

@@ -1,38 +0,0 @@
@import "../../styles/sccs-vars.scss";
.footer-end {
font-size: 12px;
color: var(--color-gray-normal);
display: flex;
flex-direction: row-reverse;
justify-content: space-between;
align-items: center;
padding: 0 0 10px 0;
& a {
color: var(--color-gray-normal);
}
&__in-sidebar {
flex-direction: column;
align-items: flex-start;
margin-top: 100px;
}
@media (max-width: $until-sm) {
flex-direction: column;
align-items: center;
margin: 20px 0 0;
}
}
.footer-social {
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
&__label {
margin: 0 10px 0 0;
}
}

View File

@@ -1,142 +0,0 @@
import classNames from "classnames";
import qs from "query-string";
import React, { useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { radarNameShort } from "../../config";
import { useMessages } from "../../context/MessagesContext";
import { useSearchParamState } from "../../hooks/use-search-param-state";
import { Tag } from "../../model";
import Branding from "../Branding/Branding";
import Link from "../Link/Link";
import LogoLink from "../LogoLink/LogoLink";
import Search from "../Search/Search";
import TagsModal from "../TagsModal/TagsModal";
export default function Header({
pageName,
tags,
}: {
pageName: string;
tags: Tag[];
}) {
const [searchOpen, setSearchOpen] = useState(false);
const [search, setSearch] = useState("");
const { searchLabel, pageHelp, pageOverview } = useMessages();
const navigate = useNavigate();
const searchRef = useRef<HTMLInputElement>(null);
const [searchParamState, setSearchParamsState] = useSearchParamState();
const openSearch = () => {
setSearchOpen(true);
};
const closeSearch = () => {
setSearchOpen(false);
};
const handleSearchChange = (value: string) => {
setSearch(value);
};
const handleSearchSubmit = () => {
let { tags } = searchParamState;
tags = Array.isArray(tags) ? tags.join("|") : tags;
navigate({
pathname: "/overview.html",
search: qs.stringify({ search: search, tags }),
});
setSearchOpen(false);
setSearch("");
};
const handleOpenClick = (e: React.MouseEvent<HTMLButtonElement>) => {
// e.preventDefault(); // todo used to be a link
openSearch();
setTimeout(() => {
searchRef?.current?.focus();
}, 0);
};
const handleTagChange = (tag: Tag) => {
const { search, tags = [] } = searchParamState;
let newTags;
// Toggle changed item in tags searchParam depends on type Array or String
if (Array.isArray(tags)) {
newTags = tags.includes(tag)
? tags.filter((item: string) => item !== tag)
: [...tags, tag];
} else {
newTags = tags !== tag ? [tags, tag] : [];
}
setSearchParamsState({
tags: newTags,
search,
});
};
const [modalIsOpen, setModalIsOpen] = useState(false);
const toggleModal = function () {
setModalIsOpen(!modalIsOpen);
};
const smallLogo = pageName !== "index";
return (
<Branding logoContent={<LogoLink small={smallLogo} />}>
<div className="nav">
{pageHelp && (
<div className="nav__item">
<Link pageName="help-and-about-tech-radar" className="icon-link">
<span className="icon icon--question icon-link__icon" />
{pageHelp.headlinePrefix || "How to use"} {radarNameShort}?
</Link>
</div>
)}
{Boolean(tags.length) && (
<div className="nav__item">
<button className="icon-link" onClick={toggleModal}>
<span className="icon icon--filter icon-link__icon" />
Filter
</button>
</div>
)}
<div className="nav__item">
<Link pageName="overview" className="icon-link">
<span className="icon icon--overview icon-link__icon" />
{pageOverview?.title || "Technologies Overview"}
</Link>
</div>
<div className="nav__item">
<button className="icon-link" onClick={handleOpenClick}>
<span className="icon icon--search icon-link__icon" />
{searchLabel || "Search"}
</button>
<div className={classNames("nav__search", { "is-open": searchOpen })}>
<Search
value={search}
onClose={closeSearch}
onSubmit={handleSearchSubmit}
onChange={handleSearchChange}
open={searchOpen}
ref={searchRef}
/>
</div>
{Boolean(tags.length) && (
<TagsModal
tags={tags}
isOpen={modalIsOpen}
closeModal={toggleModal}
handleTagChange={handleTagChange}
/>
)}
</div>
</div>
</Branding>
);
}

View File

@@ -1,23 +0,0 @@
import classNames from "classnames";
import React from "react";
import "./headline-group.scss";
interface Props {
secondary?: boolean;
}
const HeadlineGroup: React.FC<React.PropsWithChildren<Props>> = ({
children,
secondary = false,
}) => (
<div
className={classNames("headline-group", {
"headline-group--secondary": secondary,
})}
>
{children}
</div>
);
export default HeadlineGroup;

View File

@@ -1,17 +0,0 @@
@import "../../styles/sccs-vars.scss";
.headline-group {
margin: 0 0 60px;
@media (max-width: $until-sm) {
margin: 30px 0;
}
&--secondary {
margin: 10px 0;
@media (max-width: $until-sm) {
margin: 5px 0;
}
}
}

View File

@@ -1,18 +0,0 @@
import React from "react";
import "./hero-headline.scss";
interface Props {
alt?: string;
}
const HeroHeadline: React.FC<React.PropsWithChildren<Props>> = ({
children,
alt,
}) => (
<div className="hero-headline">
{children} <span className="hero-headline__alt">{alt}</span>
</div>
);
export default HeroHeadline;

View File

@@ -1,26 +0,0 @@
@import "../../styles/sccs-vars.scss";
.hero-headline {
font-size: 38px;
font-weight: 300;
line-height: 1.2;
color: var(--color-white);
padding: 0;
margin: 0;
&__alt {
color: var(--color-gray-light);
}
&--inverse {
color: var(--color-gray-light);
}
@media (max-width: $until-sm) {
font-size: 26px;
&__alt {
display: block;
}
}
}

View File

@@ -1,13 +0,0 @@
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import Item from "./Item";
import { item as testItem } from "./testData";
describe("Item", () => {
it("Should render the item", () => {
render(<Item item={testItem} />, { wrapper: MemoryRouter });
expect(screen.getByText(testItem.title)).toBeInTheDocument();
});
});

View File

@@ -1,44 +0,0 @@
import classNames from "classnames";
import React from "react";
import { Item as mItem } from "../../model";
import Flag from "../Flag/Flag";
import Link from "../Link/Link";
import "./item.scss";
type Props = {
item: mItem;
noLeadingBorder?: boolean;
active?: boolean;
style?: React.CSSProperties;
greyedOut?: boolean;
};
const Item: React.FC<Props> = ({
item,
noLeadingBorder = false,
active = false,
style = {},
greyedOut = false,
}) => (
<Link
className={classNames("item", {
"item--no-leading-border": noLeadingBorder,
"is-active": active,
})}
pageName={`${item.quadrant}/${item.name}`}
style={style}
>
<div
className={classNames("item__title", {
"greyed-out": greyedOut,
})}
>
{item.title}
<Flag item={item} />
</div>
{item.info && <div className="item__info">{item.info}</div>}
</Link>
);
export default Item;

View File

@@ -1,53 +0,0 @@
.item {
padding: 10px;
border-bottom: 1px solid var(--color-gray-normal);
display: block;
text-decoration: none;
transition: background 200ms ease-out;
color: var(--color-gray-normal);
box-sizing: border-box;
&.is-active {
background: var(--color-gray-dark-alt);
}
&:hover {
background: var(--color-gray-dark-alt2);
}
&:first-child {
border-top: 1px solid var(--color-gray-normal);
}
&--big {
min-height: 80px;
padding: 20px 10px;
}
&--no-leading-border {
&:first-child {
border-top: none;
}
}
&--no-trailing-border {
&:last-child {
border-bottom: none;
}
}
&__title {
font-size: 16px;
color: var(--color-white);
&.greyed-out {
color: var(--color-gray-light-alt);
}
}
&__info {
margin-top: 5px;
font-size: 12px;
color: var(--color-gray-normal);
}
}

View File

@@ -1,23 +0,0 @@
import { FlagType, Item } from "../../model";
export const item: Item = {
flag: FlagType.default,
featured: false,
revisions: [
{
name: "yarn",
release: "2018-03-01",
title: "Yarn",
ring: "trial",
quadrant: "tools",
fileName: "C:\\projects\\techradar\\radar\\2018-03-01\\yarn.md",
body: "<p>Yarn is a dependency management tool for frontend (node) projects similar to npm. It also uses the npm registry and \ninfrastructure. According to Yarn, the benefits are that Yarn is much faster, automatically writes a .lock file and \nbuilds up a local cache to be even faster when installing packages again.</p>\n<p>At AOE, we started using Yarn in different projects to evaluate if we can switch to Yarn for all projects.</p>\n",
},
],
name: "yarn",
title: "Yarn",
ring: "trial",
quadrant: "tools",
body: "<p>Yarn is a dependency management tool for frontend (node) projects similar to npm. It also uses the npm registry and \ninfrastructure. According to Yarn, the benefits are that Yarn is much faster, automatically writes a .lock file and \nbuilds up a local cache to be even faster when installing packages again.</p>\n<p>At AOE, we started using Yarn in different projects to evaluate if we can switch to Yarn for all projects.</p>\n",
info: "",
};

View File

@@ -1,57 +0,0 @@
import React from "react";
import { featuredOnly, Item as mItem, nonFeaturedOnly } from "../../model";
import Item from "../Item/Item";
import "./item-list.scss";
type ItemListProps = {
items: mItem[];
activeItem?: mItem;
noLeadingBorder?: boolean;
headerStyle?: React.CSSProperties;
itemStyle?: React.CSSProperties[];
};
const ItemList: React.FC<React.PropsWithChildren<ItemListProps>> = ({
children,
items,
activeItem,
noLeadingBorder,
headerStyle = {},
itemStyle = [],
}) => {
const featuredItems = featuredOnly(items);
const nonFeaturedItems = nonFeaturedOnly(items);
return (
<div className="item-list">
<div className="item-list__header" style={headerStyle}>
{children}
</div>
<div className="item-list__list">
{featuredItems.map((item, i) => (
<Item
key={item.name}
item={item}
noLeadingBorder={noLeadingBorder}
active={activeItem?.name === item.name}
style={itemStyle[i]}
greyedOut={false}
/>
))}
{nonFeaturedItems.map((item, i) => (
<Item
key={item.name}
item={item}
noLeadingBorder={noLeadingBorder}
active={activeItem?.name === item.name}
style={itemStyle[featuredItems.length + i]}
greyedOut={true}
/>
))}
</div>
</div>
);
};
export default ItemList;

View File

@@ -1,6 +0,0 @@
.item-list {
margin: 0 0 25px;
&__header {
margin-bottom: 20px;
}
}

View File

@@ -1,28 +0,0 @@
import { ConfigData } from "../../config";
import { formatRelease } from "../../date";
import { Revision } from "../../model";
import Badge from "../Badge/Badge";
export default function ItemRevision({
revision,
config,
dateFormat,
}: {
revision: Revision;
config: ConfigData;
dateFormat?: string;
}) {
return (
<div className="item-revision">
<div>
<Badge type={revision.ring} config={config}>
{revision.ring} | {formatRelease(revision.release, dateFormat)}
</Badge>
</div>
<div
className="markdown"
dangerouslySetInnerHTML={{ __html: revision.body }}
/>
</div>
);
}

View File

@@ -1,5 +0,0 @@
.item-revision {
& + .item-revision {
margin-top: 40px;
}
}

View File

@@ -1,36 +0,0 @@
import { ConfigData } from "../../config";
import { useMessages } from "../../context/MessagesContext";
import { Revision } from "../../model";
import HeadlineGroup from "../HeadlineGroup/HeadlineGroup";
import ItemRevision from "../ItemRevision/ItemRevision";
import "./item-revisions.scss";
export default function ItemRevisions({
revisions,
config,
dateFormat,
}: {
revisions: Revision[];
config: ConfigData;
dateFormat?: string;
}) {
const { revisionsText } = useMessages();
return (
<div className="item-revisions">
<HeadlineGroup secondary>
<h4 className="headline headline--dark">
{revisionsText ?? "Revisions:"}
</h4>
</HeadlineGroup>
{revisions.map((revision) => (
<ItemRevision
key={revision.release}
revision={revision}
dateFormat={dateFormat}
config={config}
/>
))}
</div>
);
}

View File

@@ -1,3 +0,0 @@
.item-revisions {
margin-top: 60px;
}

View File

@@ -1,17 +0,0 @@
import { Tag } from "../../model";
export default function ItemTags({ tags }: { tags?: Tag[] }) {
if (!tags) {
return null;
}
return (
<div className="markdown">
{"Tags: "}
{tags.map((tag, id) => [
id !== 0 && ", ",
<span key={tag}>"{tag}"</span>,
])}
</div>
);
}

View File

@@ -1,35 +0,0 @@
import React from "react";
import { Link as RLink } from "react-router-dom";
import { useSearchParamState } from "../../hooks/use-search-param-state";
import "./link.scss";
type LinkProps = {
pageName: string;
style?: React.CSSProperties;
className?: string;
};
function Link({
pageName,
children,
className,
style = {},
}: React.PropsWithChildren<LinkProps>) {
const [searchParamState] = useSearchParamState(undefined, {
parseOptions: { decode: false },
});
const { tags } = searchParamState;
return (
<RLink
to={tags ? `/${pageName}.html?tags=${tags}` : `/${pageName}.html`}
style={style}
{...{ className }}
>
{children}
</RLink>
);
}
export default Link;

View File

@@ -1,8 +0,0 @@
.link {
color: var(--color-white);
text-decoration: none;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}

View File

@@ -1,27 +0,0 @@
import classNames from "classnames";
import React from "react";
import { assetUrl, radarNameShort } from "../../config";
import Link from "../Link/Link";
import "./logo-link.scss";
export default function LogoLink({ small = false }: { small?: boolean }) {
return (
<Link
pageName="index"
className={classNames("logo-link", { "logo-link--small": small })}
>
<span className="logo-link__icon icon icon--back" />
<span className="logo-link__slide">
<img
className="logo-link__img"
src={assetUrl("logo.svg")}
width="150px"
height="60px"
alt={radarNameShort}
/>
<span className="logo-link__text">{radarNameShort}</span>
</span>
</Link>
);
}

View File

@@ -1,63 +0,0 @@
.logo-link {
display: inline-block;
transition: 200ms all ease-out;
width: 400px;
color: var(--color-white);
text-decoration: none;
position: relative;
&__slide {
transition: 400ms all cubic-bezier(0.54, 0, 0.28, 1);
display: block;
position: relative;
}
&__icon {
transition: 400ms opacity ease-out;
position: absolute;
top: 50%;
transform: translateY(-50%);
opacity: 0;
}
&__img {
transition: 400ms all cubic-bezier(0.54, 0, 0.28, 1);
}
&__text {
transition: 400ms all cubic-bezier(0.54, 0, 0.28, 1);
position: absolute;
top: 50%;
left: 130px;
white-space: nowrap;
font-size: 18px;
line-height: 1;
margin-top: -1px;
transform: translateY(-50%);
opacity: 0;
}
&--small {
.logo-link__img {
transform: scale(0.55);
}
.logo-link__text {
opacity: 0.8;
}
.logo-link__slide {
transform: translateX(-32px);
}
&:hover {
.logo-link__icon {
opacity: 1;
}
.logo-link__slide {
transform: translateX(0);
}
}
}
}

View File

@@ -1,95 +0,0 @@
import React from "react";
import { radarName } from "../../config";
import { useMessages } from "../../context/MessagesContext";
import { sanitize } from "../../sanitize";
import Fadeable from "../Fadeable/Fadeable";
import HeroHeadline from "../HeroHeadline/HeroHeadline";
import SetTitle from "../SetTitle";
interface Props {
leaving: boolean;
onLeave: () => void;
}
const PageHelp: React.FC<Props> = ({ leaving, onLeave }) => {
const { pageHelp } = useMessages();
if (pageHelp) {
const {
paragraphs,
quadrants,
rings,
sourcecodeLink,
headlinePrefix,
quadrantsPreDescription,
ringsPreDescription,
} = pageHelp;
const title = `${headlinePrefix || "How to use the"} ${radarName}`;
return (
<Fadeable leaving={leaving} onLeave={onLeave}>
<SetTitle title={title} />
<HeroHeadline>{title}</HeroHeadline>
<div className="fullpage-content">
{paragraphs.map(({ headline, values }) => (
<React.Fragment key={headline}>
<h3>{headline}</h3>
{values.map((element, index) => {
return (
<p
key={index}
dangerouslySetInnerHTML={sanitize(element)}
></p>
);
})}
</React.Fragment>
))}
<p>{quadrantsPreDescription ?? "The quadrants are:"}</p>
<ul>
{quadrants.map(({ name, description }) => (
<li key={name}>
<strong>{name}:</strong>{" "}
<span
dangerouslySetInnerHTML={sanitize(description, {})}
></span>
</li>
))}
</ul>
<p>
{ringsPreDescription ??
"Each of the items is classified in one of these rings:"}
</p>
<ul>
{rings.map(({ name, description }) => (
<li key={name}>
<strong>{name}:</strong>{" "}
<span
dangerouslySetInnerHTML={sanitize(description, {})}
></span>
</li>
))}
</ul>
{sourcecodeLink && (
<p>
{`${sourcecodeLink.description} `}
<a
href={sourcecodeLink.href}
target="_blank"
rel="noopener noreferrer"
>
{sourcecodeLink.name}
</a>
</p>
)}
</div>
</Fadeable>
);
}
return null;
};
export default PageHelp;

View File

@@ -1,56 +0,0 @@
import { MomentInput } from "moment";
import { ConfigData, radarName } from "../../config";
import { useMessages } from "../../context/MessagesContext";
import { formatRelease } from "../../date";
import { HomepageOption, Item, featuredOnly } from "../../model";
import Fadeable from "../Fadeable/Fadeable";
import HeroHeadline from "../HeroHeadline/HeroHeadline";
import QuadrantGrid from "../QuadrantGrid/QuadrantGrid";
import RadarGrid from "../RadarGrid/RadarGrid";
import SetTitle from "../SetTitle";
type PageIndexProps = {
leaving: boolean;
onLeave: () => void;
items: Item[];
config: ConfigData;
releases: MomentInput[];
};
export default function PageIndex({
leaving,
onLeave,
items,
config,
releases,
}: PageIndexProps) {
const { pageIndex } = useMessages();
const publishedLabel = pageIndex?.publishedLabel || "Published";
const newestRelease = releases.slice(-1)[0];
const numberOfReleases = releases.length;
const showChart =
config.homepageContent === HomepageOption.chart ||
config.homepageContent === HomepageOption.both;
const showColumns =
config.homepageContent === HomepageOption.columns ||
config.homepageContent === HomepageOption.both;
return (
<Fadeable leaving={leaving} onLeave={onLeave}>
<SetTitle />
<div className="headline-group">
<HeroHeadline alt={`Version #${numberOfReleases}`}>
{radarName}
</HeroHeadline>
</div>
{showChart && <RadarGrid items={featuredOnly(items)} config={config} />}
{showColumns && (
<QuadrantGrid items={featuredOnly(items)} config={config} />
)}
<div className="publish-date">
{publishedLabel} {formatRelease(newestRelease, config.dateFormat)}
</div>
</Fadeable>
);
}

View File

@@ -1,147 +0,0 @@
import React from "react";
import { ConfigData, translate } from "../../config";
import { useMessages } from "../../context/MessagesContext";
import { Item, groupByQuadrants } from "../../model";
import Badge from "../Badge/Badge";
import EditButton from "../EditButton/EditButton";
import FooterEnd from "../FooterEnd/FooterEnd";
import ItemList from "../ItemList/ItemList";
import ItemRevisions from "../ItemRevisions/ItemRevisions";
import ItemTags from "../ItemTags/ItemTags";
import Link from "../Link/Link";
import SetTitle from "../SetTitle";
import "./item-page.scss";
import { useAnimations } from "./useAnimations";
const getItem = (pageName: string, items: Item[]) => {
const [quadrantName, itemName] = pageName.split("/");
return items.filter(
(item) => item.quadrant === quadrantName && item.name === itemName
)[0];
};
const getItemsInRing = (pageName: string, items: Item[]) => {
const item = getItem(pageName, items);
return groupByQuadrants(items)[item.quadrant][item.ring];
};
type Props = {
pageName: string;
items: Item[];
config: ConfigData;
leaving: boolean;
onLeave: () => void;
};
const PageItem: React.FC<Props> = ({
pageName,
items,
config,
leaving,
onLeave,
}) => {
const { pageItem } = useMessages();
const quadrantOverview = pageItem?.quadrantOverview || "Quadrant Overview";
const itemsInRing = getItemsInRing(pageName, items);
const { getAnimationState, getAnimationStates } = useAnimations({
itemsInRing,
onLeave,
leaving,
});
const item = getItem(pageName, items);
const editButton = config.editLink ? (
<EditButton
baseUrl={config.editLink.radarLink}
item={item}
title={config.editLink.title}
/>
) : null;
return (
<>
<SetTitle title={item.title} />
<div className="item-page">
<div className="item-page__nav">
<div className="item-page__nav__inner">
<div
className="item-page__header"
style={getAnimationState("navHeader")}
>
<h3 className="headline">{translate(config, item.quadrant)}</h3>
</div>
<ItemList
items={itemsInRing}
activeItem={item}
headerStyle={getAnimationState("navHeader")}
itemStyle={getAnimationStates("items")}
>
<div className="split">
<div className="split__left">
<Badge big type={item.ring} config={config}>
{item.ring}
</Badge>
</div>
<div className="split__right">
<Link className="icon-link" pageName={item.quadrant}>
<span className="icon icon--pie icon-link__icon" />
{quadrantOverview}
</Link>
</div>
</div>
</ItemList>
<div
className="item-page__footer"
style={getAnimationState("footer")}
>
<FooterEnd modifier="in-sidebar" />
</div>
</div>
</div>
<div
className="item-page__content"
style={getAnimationState("background")}
>
<div
className="item-page__content__inner"
style={getAnimationState("text")}
>
<div className="item-page__header">
<div className="split">
<div className="split__left hero-headline__wrapper">
<h1 className="hero-headline hero-headline--inverse">
{item.title}
</h1>
{editButton}
</div>
<div className="split__right">
<Badge big type={item.ring} config={config}>
{item.ring}
</Badge>
</div>
</div>
</div>
<div
className="markdown"
dangerouslySetInnerHTML={{ __html: item.body }}
/>
<ItemTags tags={item.tags} />
{item.revisions.length > 1 && (
<ItemRevisions
revisions={item.revisions.slice(1)}
dateFormat={config.dateFormat}
config={config}
/>
)}
</div>
</div>
</div>
</>
);
};
export default PageItem;

View File

@@ -1,68 +0,0 @@
.item-page {
display: flex;
min-height: 100vh;
width: 100vw;
position: absolute;
top: 0;
left: 0;
z-index: 10;
&__nav,
&__content {
box-sizing: border-box;
padding-top: 130px;
min-height: 100vh;
position: relative;
}
&__header {
min-height: 40px;
margin: 0 0 25px;
}
&__nav {
background: var(--color-gray-dark);
flex-grow: 0;
flex-shrink: 0;
flex-basis: calc(((100vw - 1200px) / 2) + 400px);
}
&__nav__inner {
box-sizing: border-box;
float: right;
width: 410px;
padding: 0 40px 0 10px;
}
&__content {
transform: translate3d(0, 0, 0);
//flex: 0 0 calc((100vw - 1200px) / 2 + 800px);
flex-grow: 0;
flex-shrink: 0;
flex-basis: calc((100vw - 1200px) / 2 + 800px);
background: var(--color-white);
&__inner {
box-sizing: border-box;
width: 810px;
padding: 0 10px 0 100px;
}
}
}
.hero-headline__wrapper {
display: flex;
align-items: center;
gap: 15px;
}
.mobile-item-page {
background: #fff;
margin: 0 -15px;
padding: 15px;
min-height: 300px;
&__aside {
padding: 20px 0;
}
}

View File

@@ -1,183 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import {
Animation,
AnimationStates,
createAnimation,
createAnimationRunner,
} from "../../animation";
import { Item } from "../../model";
interface Props {
itemsInRing: Item[];
leaving: boolean;
onLeave: () => void;
}
export const useAnimations = ({ itemsInRing, leaving, onLeave }: Props) => {
type AnimationConfig = {
background: Animation;
navHeader: Animation;
text: Animation;
items: Animation[];
footer: Animation;
};
type AnimationNames = keyof AnimationConfig;
const animationsIn: AnimationConfig = useMemo(
() => ({
background: createAnimation(
{
transform: "translateX(calc((100vw - 1200px) / 2 + 800px))",
transition: "transform 450ms cubic-bezier(0.24, 1.12, 0.71, 0.98)",
},
{
transition: "transform 450ms cubic-bezier(0.24, 1.12, 0.71, 0.98)",
transform: "translateX(0)",
},
0
),
navHeader: createAnimation(
{
transform: "translateX(-40px)",
opacity: "0",
},
{
transition: "opacity 150ms ease-out, transform 300ms ease-out",
transform: "translateX(0px)",
opacity: "1",
},
300
),
text: createAnimation(
{
transform: "translateY(-20px)",
opacity: "0",
},
{
transition: "opacity 150ms ease-out, transform 300ms ease-out",
transform: "translateY(0px)",
opacity: "1",
},
600
),
items: itemsInRing.map((item, i) =>
createAnimation(
{
transform: "translateX(-40px)",
opacity: "0",
},
{
transition: "opacity 150ms ease-out, transform 300ms ease-out",
transform: "translateX(0px)",
opacity: "1",
},
400 + 100 * i
)
),
footer: createAnimation(
{
transition: "opacity 150ms ease-out, transform 300ms ease-out",
transform: "translateX(-40px)",
opacity: "0",
},
{
transition: "opacity 150ms ease-out, transform 300ms ease-out",
transform: "translateX(0px)",
opacity: "1",
},
600 + itemsInRing.length * 100
),
}),
[itemsInRing]
);
const animationsOut: AnimationConfig = useMemo(
() => ({
background: createAnimation(
animationsIn.background.stateB,
animationsIn.background.stateA,
300 + itemsInRing.length * 50
),
navHeader: createAnimation(
animationsIn.navHeader.stateB,
{
transition: "opacity 150ms ease-out, transform 300ms ease-out",
transform: "translateX(40px)",
opacity: "0",
},
0
),
text: createAnimation(
animationsIn.text.stateB,
{
transform: "translateY(20px)",
transition: "opacity 150ms ease-out, transform 300ms ease-out",
opacity: "0",
},
0
),
items: itemsInRing.map((item, i) =>
createAnimation(
animationsIn.items[i].stateB,
{
transition: "opacity 150ms ease-out, transform 300ms ease-out",
transform: "translateX(40px)",
opacity: "0",
},
100 + 50 * i
)
),
footer: createAnimation(
animationsIn.text.stateB,
{
transition: "opacity 150ms ease-out, transform 300ms ease-out",
transform: "translateX(40px)",
opacity: "0",
},
200 + itemsInRing.length * 50
),
}),
[itemsInRing, animationsIn]
);
const [animations, setAnimations] = useState<AnimationStates>(() => {
return leaving ? createAnimationRunner(animationsIn).getState() : {};
});
const [stateLeaving, setStateLeaving] = useState(leaving);
useEffect(() => {
if (!stateLeaving && leaving) {
let animationRunner = createAnimationRunner(animationsOut, () =>
setAnimations(animationRunner.getState)
);
animationRunner.run();
animationRunner.awaitAnimationComplete(onLeave);
setStateLeaving(true);
}
if (stateLeaving && !leaving) {
let animationRunner = createAnimationRunner(animationsIn, () =>
setAnimations(animationRunner.getState)
);
animationRunner.run();
setStateLeaving(false);
}
}, [stateLeaving, leaving, animationsIn, animationsOut, onLeave]);
const getAnimationStates = (name: AnimationNames) => animations[name];
const getAnimationState = (name: AnimationNames) => {
const animations = getAnimationStates(name);
if (animations === undefined || animations.length === 0) {
return undefined;
}
return animations[0];
};
return {
getAnimationStates,
getAnimationState,
};
};

View File

@@ -1,104 +0,0 @@
import { ConfigData, translate } from "../../config";
import { Item, groupByQuadrants } from "../../model";
import Badge from "../Badge/Badge";
import EditButton from "../EditButton/EditButton";
import Fadeable from "../Fadeable/Fadeable";
import ItemList from "../ItemList/ItemList";
import ItemRevisions from "../ItemRevisions/ItemRevisions";
import ItemTags from "../ItemTags/ItemTags";
import Link from "../Link/Link";
import SetTitle from "../SetTitle";
type PageItemMobileProps = {
pageName: string;
items: Item[];
config: ConfigData;
leaving: boolean;
onLeave: () => void;
};
export default function PageItemMobile({
pageName,
items,
config,
leaving,
onLeave,
}: PageItemMobileProps) {
const getItem = (pageName: string, items: Item[]) => {
const [quadrantName, itemName] = pageName.split("/");
const item = items.filter(
(item) => item.quadrant === quadrantName && item.name === itemName
)[0];
return item;
};
const getItemsInRing = (pageName: string, items: Item[]) => {
const item = getItem(pageName, items);
const itemsInRing = groupByQuadrants(items)[item.quadrant][item.ring];
return itemsInRing;
};
const item = getItem(pageName, items);
const itemsInRing = getItemsInRing(pageName, items);
const editButton = config.editLink ? (
<EditButton
baseUrl={config.editLink.radarLink}
item={item}
title={config.editLink.title}
/>
) : null;
return (
<Fadeable leaving={leaving} onLeave={onLeave}>
<SetTitle title={item.title} />
<div className="mobile-item-page">
<div className="mobile-item-page__content">
<div className="mobile-item-page__content__inner">
<div className="mobile-item-page__header">
<div className="split">
<div className="split__left">
<h3 className="headline">
{translate(config, item.quadrant)}
</h3>
<h1 className="hero-headline hero-headline--inverse">
{item.title}
</h1>
{editButton}
</div>
<div className="split__right">
<Badge big type={item.ring} config={config}>
{item.ring}
</Badge>
</div>
</div>
</div>
<div
className="markdown"
dangerouslySetInnerHTML={{ __html: item.body }}
/>
<ItemTags tags={item.tags} />
{item.revisions.length > 1 && (
<ItemRevisions
revisions={item.revisions.slice(1)}
config={config}
/>
)}
</div>
</div>
</div>
<aside className="mobile-item-page__aside">
<ItemList items={itemsInRing} activeItem={item}>
<div className="split">
<div className="split__left">
<h3 className="headline">{translate(config, item.quadrant)}</h3>
</div>
<div className="split__right">
<Link className="icon-link" pageName={item.quadrant}>
<span className="icon icon--pie icon-link__icon"></span>Zoom In
</Link>
</div>
</div>
</ItemList>
</aside>
</Fadeable>
);
}

View File

@@ -1,158 +0,0 @@
import { useEffect, useState } from "react";
import { ConfigData, translate } from "../../config";
import { useMessages } from "../../context/MessagesContext";
import { Item, groupByFirstLetter } from "../../model";
import Badge from "../Badge/Badge";
import Fadeable from "../Fadeable/Fadeable";
import Flag from "../Flag/Flag";
import HeadlineGroup from "../HeadlineGroup/HeadlineGroup";
import HeroHeadline from "../HeroHeadline/HeroHeadline";
import Link from "../Link/Link";
import Search from "../Search/Search";
import SetTitle from "../SetTitle";
const containsSearchTerm = (text = "", term = "") => {
// TODO search refinement
return (
text.trim().toLocaleLowerCase().indexOf(term.trim().toLocaleLowerCase()) !==
-1
);
};
type PageOverviewProps = {
rings: readonly ("all" | string)[];
search: string;
items: Item[];
config: ConfigData;
leaving: boolean;
onLeave: () => void;
};
export default function PageOverview({
rings,
search: searchProp,
items,
config,
leaving,
onLeave,
}: PageOverviewProps) {
const [ring, setRing] = useState<string | "all">("all");
const [search, setSearch] = useState(searchProp);
const { pageOverview } = useMessages();
const title = pageOverview?.title || "Technologies Overview";
useEffect(() => {
setSearch(searchProp);
}, [searchProp]);
const handleRingClick = (ring: string) => () => {
setRing(ring);
};
const isRingActive = (ringName: string) => ring === ringName;
const itemMatchesRing = (item: Item) => ring === "all" || item.ring === ring;
const itemMatchesSearch = (item: Item) => {
return (
search.trim() === "" ||
containsSearchTerm(item.title, search) ||
containsSearchTerm(item.body, search) ||
containsSearchTerm(item.info, search)
);
};
const isItemVisible = (item: Item) =>
itemMatchesRing(item) && itemMatchesSearch(item);
const getFilteredAndGroupedItems = () => {
const groups = groupByFirstLetter(items);
const groupsFiltered = groups.map((group) => ({
...group,
items: group.items.filter(isItemVisible),
}));
const nonEmptyGroups = groupsFiltered.filter(
(group) => group.items.length > 0
);
return nonEmptyGroups;
};
const handleSearchTermChange = setSearch;
const groups = getFilteredAndGroupedItems();
return (
<Fadeable leaving={leaving} onLeave={onLeave}>
<SetTitle title={title} />
<HeadlineGroup>
<HeroHeadline>{title}</HeroHeadline>
</HeadlineGroup>
<div className="filter">
<div className="split split--filter">
<div className="split__left">
<Search onChange={handleSearchTermChange} value={search} />
</div>
<div className="split__right">
<div className="nav">
{["all", ...rings].map((ringName) => (
<div className="nav__item" key={ringName}>
<Badge
big
onClick={handleRingClick(ringName)}
type={isRingActive(ringName) ? ringName : "empty"}
config={config}
>
{ringName}
</Badge>
</div>
))}
</div>
</div>
</div>
</div>
<div className="letter-index">
{groups.map(({ letter, items }) => (
<div key={letter} className="letter-index__group">
<div className="letter-index__letter">{letter}</div>
<div className="letter-index__items">
<div className="item-list">
<div className="item-list__list">
{items.map((item) => (
<Link
key={item.name}
className="item item--big item--no-leading-border item--no-trailing-border"
pageName={`${item.quadrant}/${item.name}`}
>
<div className="split split--overview">
<div className="split__left">
<div className="item__title">
{item.title}
<Flag item={item} />
</div>
</div>
<div className="split__right">
<div className="nav nav--relations">
<div className="nav__item">
{translate(config, item.quadrant)}
</div>
<div className="nav__item">
<Badge type={item.ring} config={config}>
{item.ring}
</Badge>
</div>
</div>
</div>
</div>
</Link>
))}
</div>
</div>
</div>
</div>
))}
</div>
</Fadeable>
);
}

View File

@@ -1,41 +0,0 @@
import React from "react";
import { ConfigData, translate } from "../../config";
import { Item, featuredOnly, groupByQuadrants } from "../../model";
import Fadeable from "../Fadeable/Fadeable";
import HeadlineGroup from "../HeadlineGroup/HeadlineGroup";
import HeroHeadline from "../HeroHeadline/HeroHeadline";
import QuadrantSection from "../QuadrantSection/QuadrantSection";
import SetTitle from "../SetTitle";
type PageQuadrantProps = {
leaving: boolean;
onLeave: () => void;
pageName: string;
items: Item[];
config: ConfigData;
};
export default function PageQuadrant({
leaving,
onLeave,
pageName,
items,
config,
}: PageQuadrantProps) {
const groups = groupByQuadrants(featuredOnly(items));
return (
<Fadeable leaving={leaving} onLeave={onLeave}>
<SetTitle title={translate(config, pageName)} />
<HeadlineGroup>
<HeroHeadline>{translate(config, pageName)}</HeroHeadline>
</HeadlineGroup>
<QuadrantSection
groups={groups}
quadrantName={pageName}
config={config}
big
/>
</Fadeable>
);
}

View File

@@ -1,39 +0,0 @@
import React from "react";
import { ConfigData } from "../../config";
import { Group, Item, groupByQuadrants } from "../../model";
import QuadrantSection from "../QuadrantSection/QuadrantSection";
import "./quadrant-grid.scss";
const renderQuadrant = (
quadrantName: string,
groups: Group,
config: ConfigData
) => {
return (
<div key={quadrantName} className="quadrant-grid__quadrant">
<QuadrantSection
quadrantName={quadrantName}
groups={groups}
config={config}
/>
</div>
);
};
export default function QuadrantGrid({
items,
config,
}: {
items: Item[];
config: ConfigData;
}) {
const groups = groupByQuadrants(items);
return (
<div className="quadrant-grid">
{Object.keys(config.quadrants).map((quadrantName: string) =>
renderQuadrant(quadrantName, groups, config)
)}
</div>
);
}

View File

@@ -1,16 +0,0 @@
@import "../../styles/sccs-vars.scss";
.quadrant-grid {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
&__quadrant {
flex: 0 0 45%;
margin-bottom: 40px;
@media (max-width: $until-lg) {
flex-basis: 100%;
}
}
}

View File

@@ -1,104 +0,0 @@
import { ConfigData, translate } from "../../config";
import { Group } from "../../model";
import Badge from "../Badge/Badge";
import Flag from "../Flag/Flag";
import ItemList from "../ItemList/ItemList";
import Link from "../Link/Link";
import "./quadrant-section.scss";
const renderList = (
ringName: string,
quadrantName: string,
groups: Group,
config: ConfigData,
big: boolean
) => {
const itemsInRing = groups[quadrantName][ringName] || [];
if (big) {
return (
<ItemList items={itemsInRing} noLeadingBorder>
<Badge type={ringName} big={big} config={config}>
{ringName}
</Badge>
</ItemList>
);
}
return (
<div className="ring-list">
<div className="ring-list__header">
<Badge type={ringName} config={config}>
{ringName}
</Badge>
</div>
{itemsInRing.map((item) => (
<span key={item.name} className="ring-list__item">
<Link className="link" pageName={`${item.quadrant}/${item.name}`}>
{item.title}
<Flag item={item} short />
</Link>
</span>
))}
</div>
);
};
const renderRing = (
ringName: string,
quadrantName: string,
groups: Group,
config: ConfigData,
big: boolean
) => {
if (
!config.showEmptyRings &&
(!groups[quadrantName] ||
!groups[quadrantName][ringName] ||
groups[quadrantName][ringName].length === 0)
) {
return null;
}
return (
<div key={ringName} className="quadrant-section__ring">
{renderList(ringName, quadrantName, groups, config, big)}
</div>
);
};
export default function QuadrantSection({
quadrantName,
groups,
config,
big = false,
}: {
quadrantName: string;
groups: Group;
config: ConfigData;
big?: boolean;
}) {
return (
<div className="quadrant-section">
<div className="quadrant-section__header">
<div className="split">
<div className="split__left">
<h4 className="headline">{translate(config, quadrantName)}</h4>
</div>
{!big && (
<div className="split__right">
<Link className="icon-link" pageName={`${quadrantName}`}>
<span className="icon icon--pie icon-link__icon" />
Zoom In
</Link>
</div>
)}
</div>
</div>
<div className="quadrant-section__rings">
{config.rings.map((ringName: string) =>
renderRing(ringName, quadrantName, groups, config, big)
)}
</div>
</div>
);
}

View File

@@ -1,23 +0,0 @@
@import "../../styles/sccs-vars.scss";
.quadrant-section {
&__header {
margin-bottom: 20px;
}
&__rings {
display: flex;
flex-wrap: wrap;
}
&__ring {
box-sizing: border-box;
padding: 0 8px;
flex: 0 0 25%;
margin: 0 0 25px;
@media (max-width: $until-md) {
flex-basis: 50%;
}
}
}

View File

@@ -1,83 +0,0 @@
import React from "react";
import { ConfigData } from "../../config";
import { Item, QuadrantConfig } from "../../model";
import RadarChart from "../Chart/RadarChart";
import Link from "../Link/Link";
import "./radar-grid.scss";
const QuadrantLabel: React.FC<{
quadrantConfig: QuadrantConfig;
quadrantName: string;
quadrantLabel: string;
}> = ({ quadrantConfig, quadrantName, quadrantLabel }) => {
const stylesMap = [
{ top: 0, left: 0 },
{ top: 0, right: 0 },
{ bottom: 0, left: 0 },
{ bottom: 0, right: 0 },
];
return (
<div
className="quadrant-label"
style={stylesMap[quadrantConfig.position - 1]}
>
<div className="split">
<div className="split__left">
<small>Quadrant {quadrantConfig.position}</small>
</div>
<div className="split__right">
<Link className="icon-link" pageName={`${quadrantName}`}>
<span className="icon icon--pie icon-link__icon" />
Zoom In
</Link>
</div>
</div>
<hr style={{ borderColor: quadrantConfig.colour }} />
<h4 className="headline">{quadrantLabel}</h4>
<div className="description">{quadrantConfig.description}</div>
</div>
);
};
const Legend: React.FC = () => {
return (
<div className="radar-legend">
<div className="wrapper">
<span className="icon icon--blip_new"></span>
New in this version
</div>
<div className="wrapper">
<span className="icon icon--blip_changed"></span>
Recently changed
</div>
<div className="wrapper">
<span className="icon icon--blip_default"></span>
Unchanged
</div>
</div>
);
};
const RadarGrid: React.FC<{ items: Item[]; config: ConfigData }> = ({
items,
config,
}) => {
return (
<div className="radar-grid">
<RadarChart items={items} config={config} />
{Object.entries(config.quadrantsMap).map(([name, quadrant], index) => (
<QuadrantLabel
key={index}
quadrantConfig={quadrant}
quadrantName={name}
quadrantLabel={config.quadrants[name]}
/>
))}
<Legend />
</div>
);
};
export default RadarGrid;

View File

@@ -1,59 +0,0 @@
.radar-grid {
position: relative;
margin-bottom: 50px;
color: white;
display: none;
}
@media screen and (min-width: 800px) {
.radar-grid {
display: block;
}
}
.radar-grid .quadrant-label {
position: absolute;
width: 20%;
}
.quadrant-label .split {
font-size: 12 px;
text-transform: uppercase;
}
.quadrant-label hr {
width: 100%;
margin: 10px 0 10px 0;
}
.quadrant-label .description {
font-size: 14px;
color: #a6b1bb
}
.quadrant-label .icon-link {
font-size: 12px;
}
.quadrant-label .icon-link .icon {
background-size: 18px 18px;
width: 18px;
height: 18px;
}
.radar-grid .radar-legend {
position: absolute;
width: 15%;
right: 0;
top: 45%;
}
.radar-legend .wrapper {
margin-bottom: 10px;
}
.radar-legend .icon {
background-position: center;
margin-right: 5px;
}

View File

@@ -1,145 +0,0 @@
import { useEffect, useState } from "react";
import { ConfigData, getItemPageNames, isMobileViewport } from "../config";
import { Item } from "../model";
import PageHelp from "./PageHelp/PageHelp";
import PageIndex from "./PageIndex/PageIndex";
import PageItem from "./PageItem/PageItem";
import PageItemMobile from "./PageItemMobile/PageItemMobile";
import PageOverview from "./PageOverview/PageOverview";
import PageQuadrant from "./PageQuadrant/PageQuadrant";
type RouterProps = {
pageName: string;
items: Item[];
releases: string[];
search: string;
config: ConfigData;
};
enum page {
index,
overview,
help,
quadrant,
itemMobile,
item,
notFound,
}
const getPageByName = (
items: Item[],
pageName: string,
config: ConfigData
): page => {
if (pageName === "index") {
return page.index;
}
if (pageName === "overview") {
return page.overview;
}
if (pageName === "help-and-about-tech-radar") {
return page.help;
}
if (Object.keys(config.quadrants).includes(pageName)) {
return page.quadrant;
}
if (getItemPageNames(items).includes(pageName)) {
return isMobileViewport() ? page.itemMobile : page.item;
}
return page.notFound;
};
export default function Router({
pageName,
items,
releases,
search,
config,
}: RouterProps) {
const [statePageName, setStatePageName] = useState(pageName);
const [leaving, setLeaving] = useState(false);
const [nextPageName, setNextPageName] = useState<string>("");
useEffect(() => {
const nowLeaving =
getPageByName(items, pageName, config) !==
getPageByName(items, statePageName, config);
if (nowLeaving) {
setLeaving(true);
setNextPageName(pageName);
} else {
setStatePageName(pageName);
}
}, [pageName, items, config, statePageName]);
const handlePageLeave = () => {
setStatePageName(nextPageName);
setNextPageName("");
window.setTimeout(() => {
window.requestAnimationFrame(() => {
setLeaving(false);
});
}, 0);
};
switch (getPageByName(items, statePageName, config)) {
case page.index:
return (
<PageIndex
leaving={leaving}
items={items}
config={config}
onLeave={handlePageLeave}
releases={releases}
/>
);
case page.overview:
return (
<PageOverview
items={items}
config={config}
rings={config.rings}
search={search}
leaving={leaving}
onLeave={handlePageLeave}
/>
);
case page.help:
return <PageHelp leaving={leaving} onLeave={handlePageLeave} />;
case page.quadrant:
return (
<PageQuadrant
leaving={leaving}
onLeave={handlePageLeave}
items={items}
config={config}
pageName={statePageName}
/>
);
case page.itemMobile:
return (
<PageItemMobile
items={items}
config={config}
pageName={statePageName}
leaving={leaving}
onLeave={handlePageLeave}
/>
);
case page.item:
return (
<PageItem
items={items}
config={config}
pageName={statePageName}
leaving={leaving}
onLeave={handlePageLeave}
/>
);
default:
return <div />;
}
}

View File

@@ -1,71 +0,0 @@
import classNames from "classnames";
import React, { FormEvent } from "react";
import { useMessages } from "../../context/MessagesContext";
import "./search.scss";
type SearchProps = {
onClose?: () => void;
onSubmit?: () => void;
value: string;
onChange: (value: string) => void;
open?: boolean;
};
export default React.forwardRef((props: SearchProps, ref) => {
return Search(props, ref);
});
function Search(
{ value, onChange, onClose, open = false, onSubmit = () => {} }: SearchProps,
ref: any
) {
const { searchLabel, searchPlaceholder } = useMessages();
const closable = onClose !== undefined;
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
onSubmit();
};
const handleClose = (e: React.MouseEvent) => {
e.preventDefault();
if (onClose != null) {
onClose();
}
};
return (
<form
className={classNames("search", { "search--closable": closable })}
onSubmit={handleSubmit}
>
<input
value={value}
type="text"
onChange={(e) => {
onChange(e.target.value);
}}
className="search__field"
placeholder={searchPlaceholder}
ref={ref}
/>
<span className={classNames("search__button", { "is-open": open })}>
<button type="submit" className="button">
<span className="icon icon--search button__icon" />
{searchLabel}
</button>
</span>
{closable && (
<button
className={classNames("search__close link-button", {
"is-open": open,
})}
onClick={handleClose}
>
<span className="icon icon--close" />
</button>
)}
</form>
);
}

View File

@@ -1,73 +0,0 @@
.search {
box-sizing: border-box;
width: 600px;
height: 50px;
position: relative;
margin: 0;
padding: 0;
&__field {
height: 100%;
width: 100%;
box-sizing: border-box;
padding: 10px 120px 10px 20px;
background: #3a444a;
display: block;
border: none;
color: var(--color-white);
font-size: 16px;
line-height: 1;
font-family: "DIN";
font-weight: normal;
&::placeholder {
color: var(--color-gray-normal);
}
&:focus {
outline: none;
background: #2f393f;
}
}
&__button {
position: absolute;
top: 50%;
margin-top: -18px;
right: 7px;
}
&--closable {
.search {
&__field {
padding-right: 160px;
}
&__button {
right: 50px;
transform: translateX(20px);
transition: transform 450ms cubic-bezier(0.24, 1.12, 0.71, 0.98) 100ms;
&.is-open {
transform: translateX(0);
transition-delay: 250ms;
}
}
&__close {
position: absolute;
padding: 10px;
top: 50%;
margin-top: -21px;
right: 4px;
transform: scale(0.2);
transition: transform 400ms cubic-bezier(0.24, 1.12, 0.71, 0.98);
&.is-open {
transform: rotate(180deg) scale(1);
transition-delay: 300ms;
}
}
}
}
}

View File

@@ -1,15 +0,0 @@
import { useEffect } from "react";
import { setTitle } from "../config";
type SetTitleProps = {
title?: string;
};
export default function SetTitle({ title }: SetTitleProps) {
useEffect(() => {
setTitle(document, title);
}, [title]);
return null;
}

View File

@@ -1,43 +0,0 @@
import React from "react";
import {
FaExternalLinkAlt,
FaFacebookF,
FaGithub,
FaInstagram,
FaLinkedinIn,
FaTwitter,
FaXing,
FaYoutube,
} from "react-icons/fa";
const icons = {
facebook: FaFacebookF,
twitter: FaTwitter,
linkedIn: FaLinkedinIn,
xing: FaXing,
instagram: FaInstagram,
youtube: FaYoutube,
github: FaGithub,
};
export interface Props {
href: string;
iconName: keyof typeof icons;
}
const SocialLink: React.FC<Props> = ({ href, iconName }) => {
const Icon = icons[iconName] || FaExternalLinkAlt;
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="social-icon-a"
aria-label={iconName}
>
<Icon className="social-icon" />
</a>
);
};
export default SocialLink;

View File

@@ -1,90 +0,0 @@
import { ChangeEvent } from "react";
import ReactModal from "react-modal";
import { useSearchParamState } from "../../hooks/use-search-param-state";
import { Tag } from "../../model";
import "./tags-modal.scss";
const customStyles = {
content: {
top: "50%",
left: "50%",
right: "auto",
bottom: "auto",
marginRight: "-50%",
transform: "translate(-50%, -50%)",
backgroundColor: "var(--color-gray-dark)",
color: "var(--color-white)",
padding: 0,
borderRadius: "20px",
},
overlay: {
zIndex: 999,
backgroundColor: "rgba(71, 81, 87, 0.6)",
},
};
ReactModal.setAppElement("#root");
type TagsModalProps = {
tags: Tag[];
isOpen: boolean;
closeModal: () => void;
handleTagChange: (tag: Tag) => void;
};
export default function TagsModal({
tags,
isOpen,
closeModal,
handleTagChange,
}: TagsModalProps) {
const [searchParamState] = useSearchParamState();
let { tags: tagsFromURL } = searchParamState;
tagsFromURL = Array.isArray(tagsFromURL) ? tagsFromURL : [tagsFromURL];
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
const { value } = event.target;
handleTagChange(value);
};
return (
<ReactModal
isOpen={isOpen}
onRequestClose={closeModal}
style={customStyles}
contentLabel="Filters Modal"
closeTimeoutMS={300}
>
<div className="tags-modal">
<button
onClick={closeModal}
className="tags-modal__close-button link-button"
>
<span className="icon icon--close" />
</button>
<h4 className="headline tags-modal__title">Choose your filters</h4>
<ul
className="tags-modal__list"
style={{ columns: Math.ceil(tags.length / 8) }}
>
{tags.map((tag, index) => (
<li key={index} className="tags-modal__list-item">
<input
type="checkbox"
id={`tag-checkbox-${index}`}
className="tags-modal__list-item-checkbox"
name={tag}
value={tag}
checked={tagsFromURL.includes(tag)}
onChange={handleChange}
/>
<label htmlFor={`tag-checkbox-${index}`}>{tag}</label>
</li>
))}
</ul>
</div>
</ReactModal>
);
}

View File

@@ -1,44 +0,0 @@
.tags-modal {
font-size: 16px;
position: relative;
padding: 50px;
&__close-button {
position: absolute;
top: 10px;
right: 30px;
transform: translateX(20px);
}
&__title {
width: 100%;
}
&__list {
padding: 15px 0 0;
list-style-type: none;
margin: 0;
}
&__list-item {
margin-bottom: 5px;
display: flex;
}
&__list-item-checkbox {
margin-right: 10px;
}
}
.ReactModal__Overlay {
opacity: 0;
transition: opacity 300ms ease-in-out;
}
.ReactModal__Overlay--after-open{
opacity: 1;
}
.ReactModal__Overlay--before-close{
opacity: 0;
}