feat: cleanup
135
src/animation.ts
@@ -1,135 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
export interface AnimationConfig {
|
||||
[k: string]: Animation | Animation[];
|
||||
}
|
||||
|
||||
export type Animations = {
|
||||
[k: string]: Animation[];
|
||||
};
|
||||
|
||||
export type AnimationStates = {
|
||||
[k: string]: React.CSSProperties[];
|
||||
};
|
||||
|
||||
export type Animation = {
|
||||
stateA: React.CSSProperties;
|
||||
stateB: React.CSSProperties;
|
||||
delay: number;
|
||||
run?(callback: (state: any) => any): any; // todo fix
|
||||
prepare?(callback: (state: any) => any): any; // todo fix
|
||||
};
|
||||
|
||||
export type AnimationRunner = {
|
||||
getState(): AnimationStates;
|
||||
run(): any;
|
||||
awaitAnimationComplete(callback: () => void): any;
|
||||
};
|
||||
|
||||
export const createAnimation = (
|
||||
stateA: React.CSSProperties,
|
||||
stateB: React.CSSProperties,
|
||||
delay: number
|
||||
): Animation => ({
|
||||
stateA,
|
||||
stateB,
|
||||
delay,
|
||||
});
|
||||
|
||||
const getAnimationStates = (
|
||||
animations: Animation[],
|
||||
stateName: "stateA" | "stateB" = "stateA"
|
||||
): React.CSSProperties[] => {
|
||||
return animations.map((animation) => animation[stateName]);
|
||||
};
|
||||
|
||||
const getMaxTransitionTime = (transition: string) => {
|
||||
const re = /(\d+)ms/g;
|
||||
const times: number[] = [];
|
||||
let matches;
|
||||
while ((matches = re.exec(transition)) != null) {
|
||||
times.push(parseInt(matches[1], 10));
|
||||
}
|
||||
return Math.max(...times);
|
||||
};
|
||||
|
||||
const getAnimationDuration = (animation: Animation | Animation[]): number => {
|
||||
if (animation instanceof Array) {
|
||||
return animation.reduce((maxDuration, a) => {
|
||||
const duration = getAnimationDuration(a);
|
||||
if (duration > maxDuration) {
|
||||
return duration;
|
||||
}
|
||||
return maxDuration;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
const state = animation.stateB;
|
||||
const maxTransition = state.transition
|
||||
? getMaxTransitionTime(state.transition)
|
||||
: 0;
|
||||
return maxTransition + animation.delay;
|
||||
};
|
||||
|
||||
const getMaxAnimationsDuration = (animations: Animations) =>
|
||||
Math.max(
|
||||
...Object.values(animations).map((animations) =>
|
||||
getAnimationDuration(Object.values(animations))
|
||||
)
|
||||
);
|
||||
|
||||
export const createAnimationRunner = (
|
||||
animationsIn: AnimationConfig,
|
||||
subscriber: () => void = () => {}
|
||||
): AnimationRunner => {
|
||||
const animations = Object.entries(animationsIn).reduce(
|
||||
(state, [name, animation]) => ({
|
||||
...state,
|
||||
[name]:
|
||||
animation instanceof Array ? animation : ([animation] as Animation[]),
|
||||
}),
|
||||
{} as Animations
|
||||
);
|
||||
|
||||
let state = Object.entries(animations).reduce(
|
||||
(state, [name, animation]) => ({
|
||||
...state,
|
||||
[name]: getAnimationStates(animation),
|
||||
}),
|
||||
{} as AnimationStates
|
||||
);
|
||||
|
||||
const animationsDuration = getMaxAnimationsDuration(animations);
|
||||
|
||||
const animate = (name: string, animation: Animation[]) => {
|
||||
animation.forEach((a, index) => {
|
||||
window.requestAnimationFrame(() => {
|
||||
window.setTimeout(() => {
|
||||
state = {
|
||||
...state,
|
||||
[name]: [
|
||||
...state[name]?.slice(0, index),
|
||||
a.stateB,
|
||||
...state[name]?.slice(index + 1, state[name].length),
|
||||
],
|
||||
};
|
||||
subscriber();
|
||||
}, a.delay);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
getState() {
|
||||
return state;
|
||||
},
|
||||
run() {
|
||||
Object.entries(animations).forEach(([name, animation]) => {
|
||||
animate(name, animation);
|
||||
});
|
||||
},
|
||||
awaitAnimationComplete(callback) {
|
||||
window.setTimeout(callback, animationsDuration);
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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} />;
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
.edit-button {
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
.fadable {
|
||||
opacity: 1;
|
||||
transition: opacity 0.2s cubic-bezier(0.54, 0, 0.28, 1);
|
||||
|
||||
&.is-faded {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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: "",
|
||||
};
|
||||
@@ -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;
|
||||
@@ -1,6 +0,0 @@
|
||||
.item-list {
|
||||
margin: 0 0 25px;
|
||||
&__header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
.item-revision {
|
||||
& + .item-revision {
|
||||
margin-top: 40px;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
.item-revisions {
|
||||
margin-top: 60px;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -1,8 +0,0 @@
|
||||
.link {
|
||||
color: var(--color-white);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { HomepageOption, Item, QuadrantConfig } from "./model";
|
||||
|
||||
export interface ConfigData {
|
||||
tags?: string[];
|
||||
quadrants: { [key: string]: string };
|
||||
rings: string[];
|
||||
showEmptyRings: boolean;
|
||||
quadrantsMap: { [quadrant: string]: QuadrantConfig };
|
||||
chartConfig: {
|
||||
size: number;
|
||||
scale: number[];
|
||||
blipSize: number;
|
||||
ringsAttributes: { radius: number; arcWidth: number }[];
|
||||
};
|
||||
homepageContent: HomepageOption;
|
||||
dateFormat?: string;
|
||||
editLink?: {
|
||||
radarLink: string;
|
||||
title?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const radarName =
|
||||
process.env.REACT_APP_RADAR_NAME || "AOE Technology Radar";
|
||||
export const radarNameShort = radarName;
|
||||
export const titleFormat =
|
||||
process.env.REACT_APP_RADAR_TITLE_FORMAT || "%TECHNOLOGY_NAME% | %APP_TITLE%";
|
||||
|
||||
export function setTitle(document: Document, title?: string) {
|
||||
document.title = title
|
||||
? titleFormat
|
||||
.replace("%TECHNOLOGY_NAME%", title)
|
||||
.replace("%APP_TITLE%", radarName)
|
||||
: radarName;
|
||||
}
|
||||
|
||||
export const getItemPageNames = (items: Item[]) =>
|
||||
items.map((item) => `${item.quadrant}/${item.name}`);
|
||||
|
||||
export function isMobileViewport() {
|
||||
// return false for server side rendering
|
||||
if (typeof window == "undefined") return false;
|
||||
|
||||
const width =
|
||||
window.innerWidth ||
|
||||
document.documentElement.clientWidth ||
|
||||
document.body.clientWidth;
|
||||
return width < 1200;
|
||||
}
|
||||
|
||||
export const publicUrl =
|
||||
(process.env.PUBLIC_URL || "").replace(/\/$/, "") + "/";
|
||||
|
||||
export function assetUrl(file: string) {
|
||||
return publicUrl + file;
|
||||
}
|
||||
|
||||
export function translate(config: ConfigData, key: string) {
|
||||
return config.quadrants[key] || "-";
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { FC, createContext, useContext } from "react";
|
||||
|
||||
import { Props as SocialLink } from "../../components/SocialLink/SocialLink";
|
||||
|
||||
interface Quadrant {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface Paragraph {
|
||||
headline: string;
|
||||
values: string[];
|
||||
}
|
||||
|
||||
interface PageHelp {
|
||||
paragraphs: Paragraph[];
|
||||
quadrantsPreDescription?: string;
|
||||
quadrants: Quadrant[];
|
||||
rings: { name: string; description: string }[];
|
||||
ringsPreDescription?: string;
|
||||
sourcecodeLink?: {
|
||||
href: string;
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
headlinePrefix?: string;
|
||||
}
|
||||
|
||||
interface PageOverview {
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface PageItem {
|
||||
quadrantOverview: string;
|
||||
}
|
||||
|
||||
interface PageIndex {
|
||||
publishedLabel: string;
|
||||
}
|
||||
|
||||
export interface Messages {
|
||||
footerFootnote?: string;
|
||||
socialLinksLabel?: string;
|
||||
socialLinks?: SocialLink[];
|
||||
legalInformationLabel?: string;
|
||||
legalInformationLink?: string;
|
||||
pageHelp?: PageHelp;
|
||||
pageOverview?: PageOverview;
|
||||
pageItem?: PageItem;
|
||||
pageIndex?: PageIndex;
|
||||
searchLabel?: string;
|
||||
searchPlaceholder?: string;
|
||||
revisionsText?: string;
|
||||
}
|
||||
|
||||
const MessagesContext = createContext<Messages | undefined>(undefined);
|
||||
|
||||
export const MessagesProvider: FC<
|
||||
React.PropsWithChildren<{ messages?: Messages }>
|
||||
> = ({ messages, children }) => (
|
||||
<MessagesContext.Provider value={messages}>
|
||||
{children}
|
||||
</MessagesContext.Provider>
|
||||
);
|
||||
|
||||
export const useMessages = () => useContext(MessagesContext) || {};
|
||||
@@ -1,14 +0,0 @@
|
||||
import moment from "moment";
|
||||
|
||||
import { formatRelease } from "./date";
|
||||
|
||||
describe("formatRelease", () => {
|
||||
it("should format a date object using default output format", () => {
|
||||
expect(formatRelease(moment("2022-01-05"))).toEqual("January 2022");
|
||||
});
|
||||
it("should format a date object using a custom output format", () => {
|
||||
expect(formatRelease(moment("2022-01-05"), "DD.MM.YYYY")).toEqual(
|
||||
"05.01.2022"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +0,0 @@
|
||||
import moment from "moment";
|
||||
|
||||
const isoDateToMoment = (isoDate: moment.MomentInput) =>
|
||||
moment(isoDate, "YYYY-MM-DD");
|
||||
|
||||
export const formatRelease = (
|
||||
isoDate: moment.MomentInput,
|
||||
format: string = "MMMM YYYY"
|
||||
) => isoDateToMoment(isoDate).format(format);
|
||||
@@ -1,67 +0,0 @@
|
||||
/* @Libs */
|
||||
import { type ParseOptions, type StringifyOptions } from "query-string";
|
||||
import queryString from "query-string";
|
||||
|
||||
/* @Hooks */
|
||||
import { type SetStateAction, useMemo, useRef } from "react";
|
||||
import { useLocation, useSearchParams } from "react-router-dom";
|
||||
|
||||
const initialParseOptions: ParseOptions = {
|
||||
arrayFormat: "separator",
|
||||
arrayFormatSeparator: "|",
|
||||
parseBooleans: true,
|
||||
parseNumbers: true,
|
||||
};
|
||||
|
||||
const initialStringifyOptions: StringifyOptions = {
|
||||
arrayFormat: "separator",
|
||||
arrayFormatSeparator: "|",
|
||||
skipEmptyString: true,
|
||||
skipNull: true,
|
||||
};
|
||||
|
||||
type Options = {
|
||||
replace?: boolean;
|
||||
parseOptions?: ParseOptions;
|
||||
stringifyOptions?: StringifyOptions;
|
||||
};
|
||||
|
||||
type SearchParamState = Record<string, any>;
|
||||
|
||||
export function useSearchParamState<
|
||||
T extends SearchParamState = SearchParamState
|
||||
>(initialState?: T | (() => T), options?: Options) {
|
||||
type State = Partial<T>;
|
||||
|
||||
const { replace = true, parseOptions, stringifyOptions } = options || {};
|
||||
const location = useLocation();
|
||||
const [, setSearchParams] = useSearchParams();
|
||||
const initialStateRef = useRef<State>(
|
||||
typeof initialState === "function"
|
||||
? (initialState as () => T)()
|
||||
: initialState || {}
|
||||
);
|
||||
const state = useMemo(
|
||||
() => ({
|
||||
...initialStateRef.current,
|
||||
...(queryString.parse(location.search, {
|
||||
...initialStringifyOptions,
|
||||
...parseOptions,
|
||||
}) as State),
|
||||
}),
|
||||
[location.search, parseOptions]
|
||||
);
|
||||
|
||||
function setSearchParamsState(s: SetStateAction<State>) {
|
||||
const newState = typeof s === "function" ? s(state) : s;
|
||||
|
||||
const stringifyState = queryString.stringify(
|
||||
{ ...state, ...newState },
|
||||
{ ...initialParseOptions, ...stringifyOptions }
|
||||
);
|
||||
|
||||
setSearchParams(stringifyState, { replace });
|
||||
}
|
||||
|
||||
return [state, setSearchParamsState] as const;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 30 21" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.41421;"><path id="Combined-Shape" d="M4.012,11.427l24.704,0c0.626,0 1.134,-0.508 1.134,-1.134c0,-0.626 -0.508,-1.133 -1.134,-1.133l-24.987,0l7.224,-7.225c0.443,-0.442 0.443,-1.16 0,-1.603c-0.443,-0.443 -1.16,-0.443 -1.603,0l-9.018,9.018c-0.404,0.404 -0.439,1.037 -0.106,1.481c0.099,0.182 0.245,0.335 0.423,0.439l8.701,8.701c0.443,0.443 1.16,0.443 1.603,0c0.443,-0.443 0.443,-1.16 0,-1.603l-6.941,-6.941Z" style="fill:#fff;"/></svg>
|
||||
|
Before Width: | Height: | Size: 833 B |
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect transform="rotate(-45 8 8)" x="2" y="2" width="12" height="12" rx="3" fill="#a6b1bb"></rect>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 319 B |
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="8" cy="8" r="6" fill="#a6b1bb"></circle>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 273 B |
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg viewbox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#a6b1bb" 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"></path>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 345 B |
@@ -1 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 22 22" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.41421;"><path d="M18.703,1.405l-7.675,7.732l-7.674,-7.732c-0.54,-0.54 -1.405,-0.54 -1.892,0c-0.54,0.541 -0.54,1.406 0,1.892l7.675,7.731l-7.732,7.675c-0.54,0.54 -0.54,1.405 0,1.892c0.541,0.54 1.406,0.54 1.892,0l7.731,-7.675l7.675,7.675c0.54,0.54 1.405,0.54 1.892,0c0.487,-0.541 0.54,-1.405 0,-1.892l-7.675,-7.675l7.675,-7.674c0.54,-0.54 0.54,-1.405 0,-1.892c-0.484,-0.54 -1.352,-0.54 -1.892,-0.057Z" style="fill:#7e8991;"/></svg>
|
||||
|
Before Width: | Height: | Size: 830 B |
@@ -1,7 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Svg Vector Icons : http://www.onlinewebfonts.com/icon -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 1000 1000" enable-background="new 0 0 1000 1000" xml:space="preserve">
|
||||
<metadata> Svg Vector Icons : http://www.onlinewebfonts.com/icon </metadata>
|
||||
<g><path d="M981.4,50.7c-15.5-29.2-39.5-43-72.2-43c-135.8,0-273.4,0-409.2,0c-51.6,0-103.2,0-154.7,0c-86,0-171.9,0-257.9,0c-43,0-72.2,25.8-77.4,67.1c0,3.4,1.7,10.3,3.4,12C121.8,210.6,230.1,331,340.1,453c5.2,5.2,6.9,10.3,6.9,18.9c0,113.5,0,225.2,0,338.7c0,49.9,24.1,87.7,68.8,110c30.9,15.5,63.6,25.8,94.6,39.5c25.8,10.3,49.9,20.6,75.6,29.2c25.8,8.6,49.9-3.4,58.5-25.8c3.4-8.6,5.2-20.6,5.2-30.9c0-153,0-306,0-459.1c0-6.9,1.7-12,6.9-17.2c39.5-44.7,80.8-89.4,120.4-134.1c68.8-77.4,139.3-154.7,208-232.1c1.7-1.7,5.2-6.9,5.2-10.3C986.6,69.6,986.6,59.3,981.4,50.7z M878.2,95.4C792.3,190,708,284.6,622.1,379.1c-1.7,1.7-22.4,22.4-48.1,46.4c0,144.4,0,340.4,0,476.2c-3.4-1.7-6.9-1.7-8.6-3.4c-36.1-13.8-72.2-29.2-108.3-43c-22.4-8.6-32.7-25.8-32.7-49.9c0-110,0-220.1,0-331.8c0-1.7,0-25.8,0-48.1c-24.1-24.1-44.7-43-46.4-44.7c-89.4-96.3-175.4-194.3-263.1-290.6c-1.7-1.7-3.4-3.4-5.2-6.9c259.6,0,517.5,0,778.8,0C883.4,88.6,881.7,92,878.2,95.4z" fill="#7E8991"/></g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -1,10 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg width="16px" height="21px" viewBox="0 0 16 21" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 39.1 (31720) - http://www.bohemiancoding.com/sketch -->
|
||||
<title>Slice 1</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<defs></defs>
|
||||
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<path d="M2.66666667,5.21153877e-05 C1.21540741,5.21153877e-05 0,1.21545952 0,2.66671878 L0,17.7778299 C0,18.9976817 1.00237037,20.0000521 2.22222222,20.0000521 L15.3333333,20.0000521 C15.682963,20.0000521 16,19.6830151 16,19.3333854 L16,4.66671878 C15.9976296,4.27560767 15.7140741,3.99649656 15.3333333,4.00005212 C14.6921481,4.00005212 14,3.30790397 14,2.66671878 C14,2.0255336 14.6921481,1.33338545 15.3333333,1.33338545 C15.6853333,1.33812619 16,1.01871878 16,0.666718782 C16,0.314718782 15.6853333,-0.00468862535 15.3333333,5.21153877e-05 L2.66666667,5.21153877e-05 Z M2.66666667,1.33338545 L13.0346667,1.33338545 C12.8020741,1.72805212 12.6666667,2.18405212 12.6666667,2.66671878 C12.6666667,3.14938545 12.8020741,3.60538545 13.0346667,4.00005212 L2.66666667,4.00005212 C2.02548148,4.00005212 1.33333333,3.30790397 1.33333333,2.66671878 C1.33333333,2.0255336 2.02548148,1.33338545 2.66666667,1.33338545 Z M1.33333333,4.96538545 C1.72681481,5.19797804 2.18518519,5.33338545 2.66666667,5.33338545 L14.6666667,5.33338545 L14.6666667,18.6667188 L2.22222222,18.6667188 C1.71881481,18.6667188 1.33333333,18.2812373 1.33333333,17.7778299 L1.33333333,4.96538545 Z M3.77777778,7.77782989 C3.40977778,7.77782989 3.11111111,8.07649656 3.11111111,8.44449656 C3.11111111,8.81249656 3.40977778,9.11116323 3.77777778,9.11116323 L12.2222222,9.11116323 C12.5902222,9.11116323 12.8888889,8.81249656 12.8888889,8.44449656 C12.8888889,8.07649656 12.5902222,7.77782989 12.2222222,7.77782989 L3.77777778,7.77782989 Z M3.77777778,11.3333854 C3.40977778,11.3333854 3.11111111,11.6320521 3.11111111,12.0000521 C3.11111111,12.3680521 3.40977778,12.6667188 3.77777778,12.6667188 L12.2222222,12.6667188 C12.5902222,12.6667188 12.8888889,12.3680521 12.8888889,12.0000521 C12.8888889,11.6320521 12.5902222,11.3333854 12.2222222,11.3333854 L3.77777778,11.3333854 Z M3.77777778,14.888941 C3.40977778,14.888941 3.11111111,15.1876077 3.11111111,15.5556077 C3.11111111,15.9236077 3.40977778,16.2222743 3.77777778,16.2222743 L12.2222222,16.2222743 C12.5902222,16.2222743 12.8888889,15.9236077 12.8888889,15.5556077 C12.8888889,15.1876077 12.5902222,14.888941 12.2222222,14.888941 L3.77777778,14.888941 Z" fill="#7E8991"></path>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.6 KiB |
@@ -1,10 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg width="22px" height="22px" viewBox="0 0 22 22" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 39.1 (31720) - http://www.bohemiancoding.com/sketch -->
|
||||
<title>Slice 1</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<defs></defs>
|
||||
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<path d="M9.33152835,0 L8.68756287,0 L8.68756287,2.60709564 C3.84312353,2.94056136 0,6.97751758 0,11.9064716 C0,17.0522244 4.18577564,21.238 9.33152835,21.238 C14.2586451,21.238 18.2974386,17.3948765 18.6290671,12.5504371 L21.2361627,12.5504371 L21.2361627,11.9064716 C21.2361627,5.34142268 15.8965773,0 9.33152835,0 Z M9.33152835,19.9496097 C4.89450513,19.9496097 1.28655301,16.3416576 1.28655301,11.9046344 C1.28655301,7.68624707 4.55001574,4.21471275 8.68756287,3.88629954 L8.68756287,12.5485999 L17.3498632,12.5485999 C17.0196127,16.6843097 13.5517529,19.9496097 9.33152835,19.9496097 Z M18.6616787,11.2620468 L9.97595316,11.2620468 L9.97595316,1.30584441 C15.3187538,1.62736784 19.6087949,5.91878689 19.929859,11.2620468 L18.6616787,11.2620468 Z" id="Page-1-Copy-2" fill="#A1A6AA"></path>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1,13 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg width="23px" height="23px" viewBox="0 0 23 23" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 39.1 (31720) - http://www.bohemiancoding.com/sketch -->
|
||||
<title>Group</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<defs></defs>
|
||||
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="Group" transform="translate(1.000000, 1.000000)">
|
||||
<path d="M10.7625,0.525 C5.09744667,0.525 0.525,5.09744667 0.525,10.7625 C0.525,16.4275533 5.09744667,21 10.7625,21 C16.4275533,21 21,16.4275533 21,10.7625 C21,5.09744667 16.4275533,0.525 10.7625,0.525 Z" id="Path" stroke="#7E8991" stroke-width="1.5"></path>
|
||||
<path d="M10.7625,16.45 C10.08,16.45 9.53369667,15.9036967 9.53369667,15.2211967 C9.53369667,14.5386967 10.08,13.9923933 10.7625,13.9923933 L10.7625,13.9923933 C11.445,13.9923933 11.9913033,14.5386967 11.9913033,15.2211967 C11.9913033,15.9036967 11.445,16.45 10.7625,16.45 Z M13.5373933,9.53369667 C13.4011967,9.71630333 13.24134,9.89739333 13.0823933,10.0575533 C12.9686433,10.1713033 12.8324467,10.2850533 12.67259,10.4673567 C12.4899833,10.6263033 12.3537867,10.7637133 12.2400367,10.89991 C12.1262867,11.01366 12.0125367,11.12741 11.9439833,11.2187133 C11.73893,11.4911067 11.6476267,11.8560167 11.6712867,12.1972667 L11.6712867,12.9025167 L9.89648333,12.9025167 L9.89648333,11.94732 C9.87373333,11.6285167 9.91893,11.3100167 10.0551267,10.9924267 C10.2377333,10.69607 10.44127,10.4236767 10.71518,10.17373 L11.85268,9.03623 C12.1026267,8.76353333 12.2163767,8.42228333 12.2163767,8.05737333 C12.2163767,7.69367667 12.08018,7.35242667 11.8302333,7.10248 C11.5799833,6.85223 11.2162867,6.71603333 10.8513767,6.71603333 C10.48768,6.69358667 10.1239833,6.82978333 9.85007333,7.07973 C9.57768,7.32998 9.41752,7.69367667 9.39507333,8.05858667 L7.48377,8.05858667 C7.55262667,7.21714 7.93877,6.42119333 8.57637667,5.87489 C9.23643,5.32858667 10.0551267,5.05619333 10.9202333,5.07864 C11.73893,5.03344333 12.5576267,5.30614 13.1952333,5.82969333 C13.78643,6.32989 14.0824833,7.05849667 14.0824833,7.96849667 C14.06125,8.53239333 13.8786433,9.10114333 13.5373933,9.53369667 Z" id="Page-1" fill="#7E8991"></path>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.3 KiB |
@@ -1,10 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg width="20px" height="20px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 39.1 (31720) - http://www.bohemiancoding.com/sketch -->
|
||||
<title>Page 1</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<defs></defs>
|
||||
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<path d="M19.6015482,17.6785496 L15.5875479,13.6657346 C18.0332228,10.2976722 17.7392915,5.55921406 14.7021982,2.52330599 C13.0203893,0.841497065 10.8167929,0 8.61290024,0 C6.40811867,0 4.204226,0.841497065 2.52271338,2.52330599 C-0.840904461,5.88662753 -0.840904461,11.3403582 2.52271338,14.703976 C4.204226,16.3857849 6.40930388,17.227282 8.61290024,17.227282 C10.3883405,17.227282 12.1628919,16.6809015 13.6651419,15.5893257 L17.677957,19.6021408 C17.9431471,19.8673309 18.2913017,20.0003704 18.6397526,20.0003704 C18.9882035,20.0003704 19.3366544,19.8673309 19.6015482,19.6021408 C20.1328173,19.0708717 20.1328173,18.2098187 19.6015482,17.6785496 Z M3.86881239,13.3566918 C1.25276394,10.7409396 1.25276394,6.48397193 3.86881239,3.86821978 C5.13609511,2.60064075 6.82116335,1.90285005 8.61290024,1.90285005 C10.4046371,1.90285005 12.0900017,2.60064075 13.3572844,3.86821978 C15.9730365,6.48397193 15.9730365,10.7409396 13.3572844,13.3566918 C12.0900017,14.6239745 10.4046371,15.3220615 8.61290024,15.3220615 C6.82027445,15.3220615 5.13609511,14.6239745 3.86881239,13.3566918 Z" fill="#7E8991"></path>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,47 +0,0 @@
|
||||
:root {
|
||||
--color-gray-dark: #475157;
|
||||
--color-gray-dark-alt: #4f585e;
|
||||
--color-gray-dark-alt2: #434d53;
|
||||
--color-gray-normal: #7f858a;
|
||||
--color-gray-light: #7d878d;
|
||||
--color-gray-light-alt: #adadad;
|
||||
|
||||
--color-white: #fff;
|
||||
--color-green: #5cb449;
|
||||
--color-orange: #faa03d;
|
||||
--color-blue: #40a7d1;
|
||||
--color-marine: #688190;
|
||||
--color-red: #f1235a;
|
||||
--color-brand: #f59134;
|
||||
--until-sm: 30em;
|
||||
--until-md: 48em;
|
||||
--until-lg: 61.875em;
|
||||
--until-xl: 75em;
|
||||
}
|
||||
|
||||
// @custom-media --until-sm (max-width: 30em);
|
||||
// @custom-media --until-md (max-width: 48em);
|
||||
// @custom-media --until-lg (max-width: 61.875em);
|
||||
// @custom-media --until-xl (max-width: 75em);
|
||||
|
||||
body {
|
||||
background: var(--color-gray-dark);
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: "DIN";
|
||||
font-weight: normal;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
|
||||
& h1 {
|
||||
color: blue;
|
||||
}
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@import "./styles/main.scss";
|
||||
@@ -1,13 +0,0 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import App from "./components/App";
|
||||
import "./index.scss";
|
||||
|
||||
const container = document.getElementById("root")!;
|
||||
const root = createRoot(container);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
143
src/model.ts
@@ -1,143 +0,0 @@
|
||||
export enum HomepageOption {
|
||||
chart = "chart",
|
||||
columns = "columns",
|
||||
both = "both",
|
||||
}
|
||||
|
||||
export type ItemAttributes = {
|
||||
name: string;
|
||||
ring: string;
|
||||
quadrant: string;
|
||||
title: string;
|
||||
featured?: boolean;
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
export enum FlagType {
|
||||
new = "new",
|
||||
changed = "changed",
|
||||
default = "default",
|
||||
}
|
||||
|
||||
export type Item = ItemAttributes & {
|
||||
featured: boolean;
|
||||
body: string;
|
||||
info: string;
|
||||
flag: FlagType;
|
||||
revisions: Revision[];
|
||||
angleFraction?: number;
|
||||
radiusFraction?: number;
|
||||
};
|
||||
|
||||
export type Blip = Item & {
|
||||
quadrantPosition: number;
|
||||
ringPosition: number;
|
||||
colour: string;
|
||||
txtColour: string;
|
||||
coordinates: Point;
|
||||
};
|
||||
|
||||
export type Revision = ItemAttributes & {
|
||||
body: string;
|
||||
fileName: string;
|
||||
release: string;
|
||||
};
|
||||
|
||||
export type Quadrant = {
|
||||
[name: string]: Item[];
|
||||
};
|
||||
|
||||
export type QuadrantConfig = {
|
||||
colour: string;
|
||||
txtColour: string;
|
||||
position: number;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type Radar = {
|
||||
items: Item[];
|
||||
releases: string[];
|
||||
};
|
||||
|
||||
export type Group = {
|
||||
[quadrant: string]: Quadrant;
|
||||
};
|
||||
|
||||
export const featuredOnly = (items: Item[]) =>
|
||||
items.filter((item) => item.featured);
|
||||
export const nonFeaturedOnly = (items: Item[]) =>
|
||||
items.filter((item) => !item.featured);
|
||||
|
||||
export type Point = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
export const groupByQuadrants = (items: Item[]): Group =>
|
||||
items.reduce(
|
||||
(quadrants, item: Item) => ({
|
||||
...quadrants,
|
||||
[item.quadrant]: addItemToQuadrant(quadrants[item.quadrant], item),
|
||||
}),
|
||||
{} as { [k: string]: Quadrant }
|
||||
);
|
||||
|
||||
export const groupByFirstLetter = (items: Item[]) => {
|
||||
const index = items.reduce(
|
||||
(letterIndex, item) => ({
|
||||
...letterIndex,
|
||||
[getFirstLetter(item)]: addItemToList(
|
||||
letterIndex[getFirstLetter(item)],
|
||||
item
|
||||
),
|
||||
}),
|
||||
{} as { [k: string]: Item[] }
|
||||
);
|
||||
|
||||
return Object.keys(index)
|
||||
.sort()
|
||||
.map((letter) => ({
|
||||
letter,
|
||||
items: index[letter],
|
||||
}));
|
||||
};
|
||||
|
||||
const addItemToQuadrant = (quadrant: Quadrant = {}, item: Item): Quadrant => ({
|
||||
...quadrant,
|
||||
[item.ring]: addItemToRing(quadrant[item.ring], item),
|
||||
});
|
||||
|
||||
const addItemToList = (list: Item[] = [], item: Item) => [...list, item];
|
||||
|
||||
const addItemToRing = (ring: Item[] = [], item: Item) => [...ring, item];
|
||||
|
||||
export const getFirstLetter = (item: Item) =>
|
||||
item.title.substr(0, 1).toUpperCase();
|
||||
|
||||
export type Tag = string;
|
||||
|
||||
export const filteredOnly = (items: Item[], tags: Tag[] | string) => {
|
||||
return items.filter((item: Item) => {
|
||||
const { tags: itemTags } = item;
|
||||
|
||||
if (typeof itemTags === "undefined") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Array.isArray(tags)) {
|
||||
return tags.every((tag) => itemTags.includes(tag));
|
||||
}
|
||||
|
||||
return itemTags.includes(tags);
|
||||
});
|
||||
};
|
||||
|
||||
export const getTags = (items: Item[]): Tag[] => {
|
||||
const tags: Tag[] = items
|
||||
.reduce((acc: Tag[], item: Item) => {
|
||||
return !item.tags ? acc : acc.concat(item.tags);
|
||||
}, [])
|
||||
.sort();
|
||||
|
||||
return Array.from(new Set(tags));
|
||||
};
|
||||
1
src/react-app-env.d.ts
vendored
@@ -1 +0,0 @@
|
||||
/// <reference types="react-scripts" />
|
||||
@@ -1,23 +0,0 @@
|
||||
import { sanitize } from "./sanitize";
|
||||
|
||||
describe("sanitize", () => {
|
||||
it("should sanitize the string input to HTML output", () => {
|
||||
let res = sanitize("foo");
|
||||
expect(res.__html).toEqual("foo");
|
||||
res = sanitize('<a href="https://example.org">Example.org</a>');
|
||||
expect(res.__html).toEqual('<a href="https://example.org">Example.org</a>');
|
||||
});
|
||||
it("should not sanitize not allowed tags", () => {
|
||||
let res = sanitize(
|
||||
'Before <iframe src="https://example.org"></iframe> After'
|
||||
);
|
||||
expect(res.__html).toEqual("Before After");
|
||||
});
|
||||
it("should accept options for rendering", () => {
|
||||
let res = sanitize(
|
||||
'<a href="https://example.org" target="_blank">Example.org</a>',
|
||||
{ allowedAttributes: { a: ["href"] } }
|
||||
);
|
||||
expect(res.__html).toEqual('<a href="https://example.org">Example.org</a>');
|
||||
});
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
import sanitizeHtml from "sanitize-html";
|
||||
|
||||
const defaultSanitizeOptions = {
|
||||
allowedTags: ["b", "i", "em", "strong", "a", "ul", "ol", "li"],
|
||||
allowedAttributes: {
|
||||
a: ["href", "target"],
|
||||
},
|
||||
};
|
||||
|
||||
export const sanitize = (
|
||||
dirty: string,
|
||||
options: sanitizeHtml.IOptions = defaultSanitizeOptions
|
||||
) => ({
|
||||
__html: sanitizeHtml(dirty, options),
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||
// allows you to do things like:
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import "@testing-library/jest-dom";
|
||||
@@ -1,32 +0,0 @@
|
||||
.button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
position: relative;
|
||||
padding: 10px 10px 10px 35px;
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
line-height: 16px;
|
||||
color: var(--color-gray-normal);
|
||||
|
||||
&__icon {
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
top: 50%;
|
||||
margin-top: -11px;
|
||||
}
|
||||
}
|
||||
|
||||
.link-button {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
display: inline;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.link-button:hover,
|
||||
.link-button:focus {
|
||||
text-decoration: none;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
.fullpage-content {
|
||||
font-size: 16px;
|
||||
color: var(--color-white);
|
||||
|
||||
& p,
|
||||
& ul {
|
||||
font-weight: lighter;
|
||||
}
|
||||
|
||||
& a {
|
||||
color: var(--color-white);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
@import "../../styles/sccs-vars.scss";
|
||||
|
||||
.filter {
|
||||
margin-bottom: 40px;
|
||||
|
||||
/* @todo: re-enable filter for mobile */
|
||||
@media (max-width: $until-sm) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
.footnote {
|
||||
font-size: 12px;
|
||||
color: var(--color-gray-normal);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
h4.headline {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.headline {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: var(--color-white);
|
||||
font-size: 20px;
|
||||
font-weight: normal;
|
||||
|
||||
&--dark {
|
||||
color: var(--color-gray-light);
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
/* copied from here: http://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.10.0/styles/darcula.min.css*/
|
||||
.hljs {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
padding: 0.5em;
|
||||
background: #2b2b2b;
|
||||
}
|
||||
.hljs {
|
||||
color: #bababa;
|
||||
}
|
||||
.hljs-strong,
|
||||
.hljs-emphasis {
|
||||
color: #a8a8a2;
|
||||
}
|
||||
.hljs-bullet,
|
||||
.hljs-quote,
|
||||
.hljs-link,
|
||||
.hljs-number,
|
||||
.hljs-regexp,
|
||||
.hljs-literal {
|
||||
color: #6896ba;
|
||||
}
|
||||
.hljs-code,
|
||||
.hljs-selector-class {
|
||||
color: #a6e22e;
|
||||
}
|
||||
.hljs-emphasis {
|
||||
font-style: italic;
|
||||
}
|
||||
.hljs-keyword,
|
||||
.hljs-selector-tag,
|
||||
.hljs-section,
|
||||
.hljs-attribute,
|
||||
.hljs-name,
|
||||
.hljs-variable {
|
||||
color: #cb7832;
|
||||
}
|
||||
.hljs-params {
|
||||
color: #b9b9b9;
|
||||
}
|
||||
.hljs-string {
|
||||
color: #6a8759;
|
||||
}
|
||||
.hljs-subst,
|
||||
.hljs-type,
|
||||
.hljs-built_in,
|
||||
.hljs-builtin-name,
|
||||
.hljs-symbol,
|
||||
.hljs-selector-id,
|
||||
.hljs-selector-attr,
|
||||
.hljs-selector-pseudo,
|
||||
.hljs-template-tag,
|
||||
.hljs-template-variable,
|
||||
.hljs-addition {
|
||||
color: #e0c46c;
|
||||
}
|
||||
.hljs-comment,
|
||||
.hljs-deletion,
|
||||
.hljs-meta {
|
||||
color: #7f7f7f;
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
.icon-link {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
height: 25px;
|
||||
font-size: 14px;
|
||||
line-height: 25px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--color-gray-normal);
|
||||
cursor: pointer;
|
||||
|
||||
&--primary {
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
&--big {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
margin-right: 6px;
|
||||
position: relative;
|
||||
top: -1px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: -8px;
|
||||
width: 100%;
|
||||
border-bottom: 2px solid var(--color-gray-normal);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
.icon {
|
||||
display: inline-block;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
background-size: 22px 22px;
|
||||
background-repeat: no-repeat;
|
||||
background-position: 0 0;
|
||||
vertical-align: middle;
|
||||
|
||||
&--pie {
|
||||
background-image: url("../../icons/pie.svg");
|
||||
}
|
||||
|
||||
&--question {
|
||||
background-image: url("../../icons/question.svg");
|
||||
}
|
||||
|
||||
&--overview {
|
||||
background-image: url("../../icons/overview.svg");
|
||||
}
|
||||
|
||||
&--search {
|
||||
background-image: url("../../icons/search.svg");
|
||||
}
|
||||
|
||||
&--back {
|
||||
background-image: url("../../icons/back.svg");
|
||||
}
|
||||
|
||||
&--close {
|
||||
background-image: url("../../icons/close.svg");
|
||||
}
|
||||
|
||||
&--blip_new {
|
||||
background-image: url('../../icons/blip_new.svg');
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background-size: 18px;
|
||||
}
|
||||
|
||||
&--blip_changed {
|
||||
background-image: url('../../icons/blip_changed.svg');
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background-size: 18px;
|
||||
}
|
||||
|
||||
&--blip_default {
|
||||
background-image: url('../../icons/blip_default.svg');
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background-size: 18px;
|
||||
}
|
||||
|
||||
&--filter {
|
||||
background-image: url("../../icons/filter.svg");
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
@import "../../styles/sccs-vars.scss";
|
||||
|
||||
.letter-index {
|
||||
margin-bottom: 60px;
|
||||
|
||||
&__group {
|
||||
border-top: 1px solid var(--color-gray-normal);
|
||||
position: relative;
|
||||
padding: 0 0 0 200px;
|
||||
min-height: 80px;
|
||||
|
||||
@media (max-width: $until-md) {
|
||||
padding: 0 0 0 50px;
|
||||
}
|
||||
}
|
||||
|
||||
&__letter {
|
||||
font-size: 50px;
|
||||
line-height: 80px;
|
||||
color: var(--color-gray-normal);
|
||||
position: absolute;
|
||||
left: 50px;
|
||||
top: 0;
|
||||
|
||||
@media (max-width: $until-md) {
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
.markdown {
|
||||
color: var(--color-gray-normal);
|
||||
font-size: 16px;
|
||||
|
||||
p {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-gray-normal);
|
||||
text-decoration: underline;
|
||||
&:hover {
|
||||
color: var(--color-gray-dark);
|
||||
}
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
color: var(--color-gray-normal);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2em;
|
||||
margin: 1em 0;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.5em;
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.2em;
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
margin: 10 0 10 0;
|
||||
}
|
||||
|
||||
pre {
|
||||
overflow-x: auto;
|
||||
padding: 10px;
|
||||
background: var(--color-gray-dark);
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
@import "../../styles/sccs-vars.scss";
|
||||
|
||||
.nav {
|
||||
white-space: nowrap;
|
||||
|
||||
&__item {
|
||||
display: inline-flex;
|
||||
position: relative;
|
||||
|
||||
& + .nav__item {
|
||||
margin-left: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
&__search {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
visibility: hidden;
|
||||
overflow: hidden;
|
||||
width: 0;
|
||||
margin-top: -25px;
|
||||
opacity: 0.8;
|
||||
transition: width 400ms cubic-bezier(0.24, 1.12, 0.71, 0.98) 100ms,
|
||||
visibility 0s linear 500ms, opacity 200ms linear;
|
||||
|
||||
&.is-open {
|
||||
opacity: 1;
|
||||
width: 600px;
|
||||
visibility: visible;
|
||||
transition-delay: 0s;
|
||||
}
|
||||
}
|
||||
|
||||
&--relations {
|
||||
@media (max-width: $until-md) {
|
||||
.nav__item {
|
||||
display: block;
|
||||
margin: 5px 0 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
@import "../../styles/sccs-vars.scss";
|
||||
|
||||
.page {
|
||||
max-width: 1200px;
|
||||
min-height: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 0 15px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
|
||||
&__header {
|
||||
flex: 0 0 auto;
|
||||
margin-bottom: 20px;
|
||||
position: relative;
|
||||
z-index: 100;
|
||||
|
||||
@media (max-width: $until-sm) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: $until-lg) {
|
||||
.nav {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
margin-top: 5px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
@import "../../styles/sccs-vars.scss";
|
||||
|
||||
.publish-date {
|
||||
color: var(--color-gray-normal);
|
||||
text-align: right;
|
||||
|
||||
@media (max-width: $until-sm) {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
.ring-list {
|
||||
&__header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
&__item {
|
||||
font-size: 13px;
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
.social-icon-a {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
margin: 0 5px;
|
||||
background-color: var(--color-white);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.social-icon {
|
||||
font-size: 16px; /* Preferred icon size */
|
||||
color: var(--color-gray-dark);
|
||||
}
|
||||