refactor: projet stand-alone sans dépendance aoe_technology_radar

- Intégration du code source du framework dans radar-app/ (vendoring)
- Suppression de la dépendance npm aoe_technology_radar
- Création de scripts build-radar.js et serve-radar.js pour remplacer le CLI techradar
- Adaptation de tous les scripts et Docker pour utiliser radar-app/ au lieu de .techradar
- Refactorisation complète de Dockerfile.business
- Mise à jour de la documentation (architecture, déploiement, développement)
- Mise à jour de .gitignore pour ignorer les artefacts de build de radar-app/
- Ajout de postcss dans les dépendances Docker pour le build Next.js

Le projet est maintenant complètement indépendant du package externe.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
syoul
2026-02-25 18:11:40 +01:00
parent cc8df1a4af
commit 9d8ae3d32a
125 changed files with 15583 additions and 123 deletions

View File

@@ -0,0 +1,50 @@
.badge {
position: relative;
display: inline-block;
vertical-align: middle;
padding: 6px 15px;
text-transform: uppercase;
border: 1px solid transparent;
border-radius: 13px;
font-size: 12px;
line-height: 1;
overflow: hidden;
text-decoration: none;
}
.size-small {
padding: 4px 8px;
font-size: 9px;
}
.size-large {
padding: 7px 20px;
font-size: 14px;
border-radius: 15px;
}
.colored {
color: var(--foreground);
background-color: var(--badge);
}
.selectable {
cursor: pointer;
&:not(.selected) {
color: var(--foreground);
border: 1px solid var(--foreground);
background: transparent;
}
&:not(.colored) {
color: var(--foreground);
border: 1px solid var(--foreground);
background: transparent;
&.selected {
color: var(--background);
background: var(--foreground);
}
}
}

View File

@@ -0,0 +1,105 @@
import {
CSSProperties,
ComponentPropsWithoutRef,
ReactNode,
useMemo,
} from "react";
import styles from "./Badge.module.css";
import { getFlag, getRing } from "@/lib/data";
import { formatRelease } from "@/lib/format";
import { Flag } from "@/lib/types";
import { cn } from "@/lib/utils";
interface BadgeProps extends ComponentPropsWithoutRef<"span"> {
children?: ReactNode;
color?: string;
selectable?: boolean;
selected?: boolean;
size?: "small" | "medium" | "large";
}
export function Badge({
children,
color,
size = "medium",
selectable,
selected,
...props
}: BadgeProps) {
const style = useMemo(
() => (color ? ({ "--badge": color } as CSSProperties) : undefined),
[color],
);
const Component = props.onClick ? "button" : "span";
return (
<Component
{...props}
style={style}
className={cn(
props.className,
styles.badge,
styles[`size-${size}`],
color && styles.colored,
selectable && styles.selectable,
selected && styles.selected,
)}
>
{children}
</Component>
);
}
interface RingBadgeProps extends Omit<BadgeProps, "color" | "children"> {
ring: string;
release?: string;
}
export function RingBadge({
ring: ringName,
release,
...props
}: RingBadgeProps) {
const ring = getRing(ringName);
if (!ring) return null;
const label = release
? `${ring.title} | ${formatRelease(release)}`
: ring.title;
return (
<Badge color={ring.color} {...props}>
{label}
</Badge>
);
}
// Type guard to check if flag has the required attributes
function hasRequiredFlagAttributes(flag: any): flag is {
color: string;
title: string;
titleShort: string;
} {
return "color" in flag && "title" in flag && "titleShort" in flag;
}
interface FlagBadgeProps
extends Omit<BadgeProps, "color" | "children" | "size"> {
flag: Flag;
short?: boolean;
}
export function FlagBadge({ flag: flagName, short, ...props }: FlagBadgeProps) {
if (flagName === Flag.Default) return null;
const flag = getFlag(flagName);
if (!flag || !hasRequiredFlagAttributes(flag)) return null;
return (
<Badge color={flag.color} size="small" {...props}>
{short ? flag.titleShort : flag.title}
</Badge>
);
}

View File

@@ -0,0 +1,8 @@
.filter {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
margin-bottom: 20px;
gap: 20px;
}

View File

@@ -0,0 +1,25 @@
import styles from "./Filter.module.css";
import { QueryFilter } from "@/components/Filter/QueryFilter";
import { RingFilter } from "@/components/Filter/RingFilter";
interface FilterProps {
query?: string;
onQueryChange: (query: string) => void;
ring?: string;
onRingChange: (ring: string) => void;
}
export function Filter({
query,
onQueryChange,
ring,
onRingChange,
}: FilterProps) {
return (
<div className={styles.filter}>
<QueryFilter value={query} onChange={onQueryChange} />
<RingFilter value={ring} onChange={onRingChange} />
</div>
);
}

View File

@@ -0,0 +1,29 @@
.filter {
flex: 1 1 100%;
position: relative;
}
.input {
padding-right: 50px;
}
.button {
position: absolute;
top: 50%;
right: 16px;
width: 20px;
height: 20px;
margin: -10px 0 0;
background: transparent;
border: none;
}
.icon {
fill: var(--highlight);
}
@media (min-width: 768px) {
.filter {
flex: 1 1 auto;
}
}

View File

@@ -0,0 +1,39 @@
import { ChangeEvent, useEffect, useState } from "react";
import Search from "../Icons/Search";
import styles from "./QueryFilter.module.css";
import { getLabel } from "@/lib/data";
interface QueryFilterProps {
value?: string;
onChange: (value: string) => void;
}
export function QueryFilter({ value, onChange }: QueryFilterProps) {
const [val, setVal] = useState(value);
const _onChange = (e: ChangeEvent<HTMLInputElement>) => {
setVal(e.target.value);
onChange(e.target.value);
};
useEffect(() => {
setVal(value);
}, [value]);
return (
<div className={styles.filter}>
<input
className={styles.input}
id="search"
type="search"
placeholder={getLabel("searchPlaceholder")}
value={val}
onChange={_onChange}
/>
<button className={styles.button} type="submit">
<Search className={styles.icon} />
</button>
</div>
);
}

View File

@@ -0,0 +1,8 @@
.filter {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 20px;
}

View File

@@ -0,0 +1,43 @@
import styles from "./RingFilter.module.css";
import { Badge, RingBadge } from "@/components/Badge/Badge";
import { getRings } from "@/lib/data";
import { cn } from "@/lib/utils";
interface RingFilterProps {
value?: string;
onChange: (value: string) => void;
className?: string;
}
export function RingFilter({ value, onChange, className }: RingFilterProps) {
const rings = getRings();
return (
<ul className={cn(styles.filter, className)}>
<li>
<Badge
size="large"
selectable
selected={!value}
onClick={() => {
onChange("");
}}
>
All
</Badge>
</li>
{rings.map((ring) => (
<li key={ring.id}>
<RingBadge
ring={ring.id}
size="large"
selectable
selected={value === ring.id}
onClick={() => onChange(ring.id)}
/>
</li>
))}
</ul>
);
}

View File

@@ -0,0 +1,54 @@
.branding {
padding: 20px 0;
border-top: 1px solid var(--border);
}
.logo {
display: block;
margin: 0 auto 20px;
width: 150px;
}
.description {
font-size: 12px;
margin: 0 0 30px;
}
.imprint {
opacity: 0.7;
display: block;
font-size: 12px;
text-decoration: underline;
text-align: center;
&:hover {
opacity: 1;
}
}
@media (min-width: 768px) {
.branding {
display: flex;
justify-content: center;
align-items: center;
}
.logo {
margin: 0;
}
.description {
margin: 0 50px 0;
}
.imprint {
text-align: right;
}
}
@media (min-width: 768px) and (max-width: 1023px) {
.socialLinks {
flex-wrap: wrap;
min-width: 150px;
}
}

View File

@@ -0,0 +1,20 @@
import styles from "./Footer.module.css";
import { SocialLinks } from "@/components/SocialLinks/SocialLinks";
import { getAppName, getImprintUrl, getLabel, getLogoUrl } from "@/lib/data";
export function Footer() {
const logoUrl = getLogoUrl();
return (
<div className={styles.footer}>
<div className={styles.branding}>
<img src={logoUrl} className={styles.logo} alt={getAppName()} />
<p className={styles.description}>{getLabel("footer")}</p>
<SocialLinks className={styles.socialLinks} />
</div>
<a href={getImprintUrl()} className={styles.imprint} target="_blank">
{getLabel("imprint")}
</a>
</div>
);
}

View File

@@ -0,0 +1,119 @@
.header {
display: flex;
flex-wrap: wrap;
align-items: center;
margin: 0 0 20px;
}
.title {
margin: 0 30px 0 0;
}
.editLink {
position: absolute;
top: 10px;
right: 10px;
display: block;
width: 20px;
height: 20px;
opacity: 0;
transition: opacity 0.2s;
}
.revision {
padding: 30px 0 15px 35px;
margin-left: 20px;
border-left: 1px solid var(--border);
&:hover {
.editLink {
opacity: 1;
}
}
}
.release {
display: block;
text-align: center;
text-transform: uppercase;
font-size: 12px;
line-height: 1.2;
width: 50px;
height: 50px;
padding: 10px 0;
border-radius: 50%;
border: 1px solid var(--border);
background: var(--background);
float: left;
margin: -15px 0 0 -60px;
}
.notMaintainedIcon {
fill: currentColor;
width: 24px;
height: 24px;
margin: 8px auto;
}
.ring {
float: left;
margin: -45px 0 0 0;
}
.content {
position: relative;
background: var(--content);
color: var(--text);
border-radius: 6px;
padding: 30px 15px;
}
.content a {
color: var(--link);
text-decoration: underline;
&:hover {
text-decoration: none;
}
}
@media (min-width: 768px) {
.revision {
padding: 30px 0 15px 50px;
margin-left: 38px;
}
.release {
font-size: 18px;
width: 75px;
height: 75px;
padding: 15px 0;
margin: -15px 0 0 -90px;
}
.ring {
margin-left: -15px;
}
.content {
padding: 30px;
}
}
/* special styles for revisions without content */
.revision.noContent {
.content {
background: none;
}
.ring {
margin-top: -20px;
}
}
.revision.hint {
.content {
font-size: 14px;
background: var(--border);
color: var(--foreground);
}
}

View File

@@ -0,0 +1,81 @@
import styles from "./ItemDetail.module.css";
import { RingBadge } from "@/components/Badge/Badge";
import { Attention, Edit } from "@/components/Icons";
import { Tag } from "@/components/Tags/Tags";
import { getEditUrl, getLabel, getReleases } from "@/lib/data";
import { Item } from "@/lib/types";
import { cn } from "@/lib/utils";
const latestReleases = getReleases().slice(-3);
function isNotMaintained(release: string) {
return !latestReleases.includes(release);
}
interface ItemProps {
item: Item;
}
export function ItemDetail({ item }: ItemProps) {
const notMaintainedText = getLabel("notUpdated");
return (
<>
<div className={styles.header}>
<h1 className={styles.title}>{item.title}</h1>
{item.tags?.map((tag) => <Tag key={tag} tag={tag} />)}
</div>
<div className={styles.revisions}>
{notMaintainedText && isNotMaintained(item.release) && (
<div className={cn(styles.revision, styles.hint)}>
<span className={styles.release}>
<Attention className={styles.notMaintainedIcon} />
</span>
<div className={styles.content}>{notMaintainedText}</div>
</div>
)}
<Revision
id={item.id}
release={item.release}
ring={item.ring}
body={item.body}
/>
{item.revisions?.map((revision, index) => (
<Revision key={index} id={item.id} {...revision} />
))}
</div>
</>
);
}
interface RevisionProps {
id: string;
release: string;
ring: string;
body?: string;
}
function Revision({ id, release, ring, body }: RevisionProps) {
const date = new Date(release);
const editLink = getEditUrl({ id, release });
const formattedDate = date.toLocaleDateString("en-US", {
month: "short",
year: "numeric",
});
return (
<div className={cn(styles.revision, !body && styles.noContent)}>
<time dateTime={release} className={styles.release}>
{formattedDate}
</time>
<div className={styles.content}>
<RingBadge className={styles.ring} ring={ring} size="large" />
{body ? <div dangerouslySetInnerHTML={{ __html: body }} /> : null}
{editLink && (
<a href={editLink} target="_blank" className={styles.editLink}>
<Edit />
</a>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,76 @@
.list {
list-style: none;
margin: 0;
padding: 0;
}
.item {
+ .item {
border-top: 1px solid var(--border);
}
}
.flag {
display: inline;
flex: 0 0 auto;
align-self: baseline;
margin-left: 8px;
}
.ring {
flex: 0 0 auto;
margin-left: 16px;
align-self: baseline;
}
.quadrant {
font-size: 14px;
opacity: 0.7;
}
.info {
flex-basis: 100%;
}
.link {
display: block;
padding: 10px;
border-radius: 6px;
&.isFadedOut {
opacity: 0.65;
}
&:hover,
&.isActive {
background: var(--foreground);
color: var(--background);
opacity: 1;
}
}
.isSmall {
font-size: 14px;
.link {
padding: 8px;
}
}
.isLarge {
.link {
display: flex;
flex-wrap: wrap;
}
.quadrant {
margin-left: auto;
}
@media (min-width: 768px) {
.info {
flex-basis: auto;
margin-left: auto;
}
}
}

View File

@@ -0,0 +1,67 @@
import Link from "next/link";
import styles from "./ItemList.module.css";
import { FlagBadge, RingBadge } from "@/components/Badge/Badge";
import { getQuadrant } from "@/lib/data";
import { Item } from "@/lib/types";
import { cn } from "@/lib/utils";
export interface ItemListProps {
items: Item[];
activeId?: string;
size?: "small" | "default" | "large";
hideRing?: boolean;
className?: string;
}
export function ItemList({
items,
activeId,
size = "default",
hideRing = false,
className,
}: ItemListProps) {
return (
<ul
className={cn(styles.list, className, {
[styles.isSmall]: size === "small",
[styles.isLarge]: size === "large",
})}
>
{items.map((item) => (
<li className={styles.item} key={item.id}>
<Link
className={cn(styles.link, {
[styles.isFadedOut]: !item.featured,
[styles.isActive]: item.id === activeId,
})}
href={`/${item.quadrant}/${item.id}`}
>
<span className={styles.title}>{item.title}</span>
<FlagBadge
className={styles.flag}
flag={item.flag}
short={size == "small"}
/>
{size === "large" && (
<div className={styles.info}>
<span className={styles.quadrant}>
{getQuadrant(item.quadrant)?.title}
</span>
{!hideRing && (
<RingBadge
className={styles.ring}
ring={item.ring}
size="small"
/>
)}
</div>
)}
</Link>
</li>
))}
</ul>
);
}

View File

@@ -0,0 +1,24 @@
.layout {
min-height: 100vh;
}
.container {
max-width: var(--max-width);
margin: 0 auto;
padding: 20px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
}
.layout.default {
.content {
max-width: var(--max-width);
min-height: calc(100vh - 250px);
margin: 0 auto;
padding: 20px;
}
}

View File

@@ -0,0 +1,39 @@
import { Roboto } from "next/font/google";
import { FC, ReactNode } from "react";
import styles from "./Layout.module.css";
import { Footer } from "@/components/Footer/Footer";
import { Logo } from "@/components/Logo/Logo";
import { Navigation } from "@/components/Navigation/Navigation";
import { cn } from "@/lib/utils";
const font = Roboto({ weight: ["400", "700"], subsets: ["latin"] });
export type LayoutClass = "default" | "full";
interface LayoutProps {
children: ReactNode;
layoutClass?: LayoutClass;
}
export const Layout: FC<LayoutProps> = ({
children,
layoutClass = "default",
}) => {
return (
<div
id="layout"
className={cn(styles.layout, font.className, styles[layoutClass])}
>
<header className={cn(styles.container, styles.header)}>
<Logo />
<Navigation />
</header>
<main className={cn(styles.content)}>{children}</main>
<footer className={cn(styles.container, styles.footer)}>
<Footer />
</footer>
</div>
);
};

View File

@@ -0,0 +1,54 @@
.logo {
position: relative;
display: flex;
justify-content: flex-start;
align-items: center;
min-height: 60px;
gap: 16px;
transition: padding-left 200ms ease-in-out;
&:before {
content: "";
display: block;
position: absolute;
left: 0;
width: 22px;
height: 22px;
background: url("../../icons/back.svg") no-repeat 50% 50%;
opacity: 0;
transition: opacity 200ms ease-in-out;
}
}
.src {
width: 150px;
transition: width 200ms ease-in-out;
}
.subline {
position: relative;
top: -2px;
font-size: 18px;
opacity: 0;
transition: opacity 200ms ease-in-out;
@media (max-width: 1024px) {
display: none;
}
}
.logo.small {
.subline {
opacity: 0.8;
}
.src {
width: 75px;
}
&:hover {
padding-left: 30px;
&:before {
opacity: 1;
}
}
}

View File

@@ -0,0 +1,22 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import styles from "./Logo.module.css";
import { getAppName, getLogoUrl } from "@/lib/data";
import { cn } from "@/lib/utils";
export function Logo() {
const pathname = usePathname();
const appName = getAppName();
const logoUrl = getLogoUrl();
return (
<Link href="/" className={cn(styles.logo, pathname != "/" && styles.small)}>
<img src={logoUrl} className={cn(styles.src)} alt={appName} />
<span className={styles.subline}>{appName}</span>
</Link>
);
}

View File

@@ -0,0 +1,20 @@
.list {
list-style: none;
display: flex;
gap: 16px;
font-size: 14px;
}
.icon {
display: inline-block;
vertical-align: middle;
width: 22px;
margin: 0 6px 0 0;
fill: var(--highlight);
}
@media (max-width: 900px) {
.label {
display: none;
}
}

View File

@@ -0,0 +1,37 @@
import Link from "next/link";
import styles from "./Navigation.module.css";
import IconOverview from "@/components/Icons/Overview";
import IconQuestion from "@/components/Icons/Question";
import IconSearch from "@/components/Icons/Search";
import { getLabel, getToggle } from "@/lib/data";
export function Navigation() {
return (
<nav className={styles.nav}>
<ul className={styles.list}>
<li className={styles.item}>
<Link href="/help-and-about-tech-radar">
<IconQuestion className={styles.icon} />
<span className={styles.label}>{getLabel("pageAbout")}</span>
</Link>
</li>
<li className={styles.item}>
<Link href="/overview">
<IconOverview className={styles.icon} />
<span className={styles.label}>{getLabel("pageOverview")}</span>
</Link>
</li>
{getToggle("showSearch") && (
<li className={styles.item}>
<Link href="/overview">
<IconSearch className={styles.icon} />
<span className={styles.label}>{getLabel("pageSearch")}</span>
</Link>
</li>
)}
</ul>
</nav>
);
}

View File

@@ -0,0 +1,20 @@
.link {
text-transform: uppercase;
font-size: 12px;
white-space: nowrap;
}
.icon {
fill: var(--highlight);
display: inline-block;
vertical-align: middle;
margin: -2px 6px 0 0;
width: 16px;
height: 16px;
}
.link:hover {
.label {
text-decoration: underline;
}
}

View File

@@ -0,0 +1,26 @@
import Link from "next/link";
import styles from "./QuadrantLink.module.css";
import Pie from "@/components/Icons/Pie";
import { getLabel } from "@/lib/data";
import { Quadrant } from "@/lib/types";
import { cn } from "@/lib/utils";
interface QuadrantLinkProps {
quadrant: Quadrant;
label?: string;
className?: string;
}
export function QuadrantLink({
quadrant,
label = getLabel("zoomIn"),
className,
}: QuadrantLinkProps) {
return (
<Link className={cn(styles.link, className)} href={`/${quadrant.id}`}>
<Pie className={styles.icon} />
<span className={styles.label}>{label}</span>
</Link>
);
}

View File

@@ -0,0 +1,38 @@
.quadrants {
--cols: 1;
--gap: 60px;
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: var(--gap);
}
.quadrant {
margin-bottom: 20px;
flex: 1 0
calc(100% / var(--cols) - var(--gap) / var(--cols) * (var(--cols) - 1));
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
margin-bottom: 20px;
border-bottom: 1px solid var(--border);
}
.title {
margin: 0;
}
.link {
}
@media (min-width: 1220px) {
.quadrants {
--cols: 2;
}
}

View File

@@ -0,0 +1,35 @@
import Link from "next/link";
import styles from "./QuadrantList.module.css";
import { QuadrantLink } from "@/components/QuadrantLink/QuadrantLink";
import { RingList } from "@/components/RingList/RingList";
import { getQuadrant, groupItemsByQuadrant } from "@/lib/data";
import { Item } from "@/lib/types";
interface RingListProps {
items: Item[];
}
export function QuadrantList({ items }: RingListProps) {
const quadrants = groupItemsByQuadrant(items);
return (
<ul className={styles.quadrants}>
{Object.entries(quadrants).map(([quadrantId, items]) => {
const quadrant = getQuadrant(quadrantId);
if (!quadrant) return null;
return (
<li key={quadrantId} className={styles.quadrant}>
<div className={styles.header}>
<h3 className={styles.title}>
<Link href={`/${quadrant.id}`}>{quadrant.title}</Link>
</h3>
<QuadrantLink quadrant={quadrant} />
</div>
<RingList items={items} size="small" />
</li>
);
})}
</ul>
);
}

View File

@@ -0,0 +1,58 @@
import React from "react";
import { getChartConfig } from "@/lib/data";
import { Flag } from "@/lib/types";
const { blipSize } = getChartConfig();
const halfBlipSize = blipSize / 2;
interface BlipProps {
color: string;
x: number;
y: number;
}
export function Blip({ flag, color, x, y }: BlipProps & { flag: Flag }) {
switch (flag) {
case Flag.New:
return <BlipNew x={x} y={y} color={color} />;
case Flag.Changed:
return <BlipChanged x={x} y={y} color={color} />;
default:
return <BlipDefault x={x} y={y} color={color} />;
}
}
function BlipNew({ x, y, color }: BlipProps) {
x = Math.round(x - halfBlipSize);
y = Math.round(y - halfBlipSize);
return (
<path
stroke="none"
fill={color}
d="M5.7679491924311 2.1387840678323a2 2 0 0 1 3.4641016151378 0l5.0358983848622 8.7224318643355a2 2 0 0 1 -1.7320508075689 3l-10.071796769724 0a2 2 0 0 1 -1.7320508075689 -3"
transform={`translate(${x},${y})`}
/>
);
}
function BlipChanged({ x, y, color }: BlipProps) {
x = Math.round(x - halfBlipSize);
y = Math.round(y - halfBlipSize);
return (
<rect
transform={`rotate(-45 ${x} ${y})`}
x={x}
y={y}
width={blipSize}
height={blipSize}
rx="3"
stroke="none"
fill={color}
/>
);
}
function BlipDefault({ x, y, color }: BlipProps) {
return <circle cx={x} cy={y} r={halfBlipSize} stroke="none" fill={color} />;
}

View File

@@ -0,0 +1,9 @@
.ringLabels {
text-transform: uppercase;
}
@media (max-width: 767px) {
.ringLabels {
display: none;
}
}

View File

@@ -0,0 +1,163 @@
import Link from "next/link";
import React, { FC, Fragment, memo } from "react";
import styles from "./Chart.module.css";
import { Blip } from "@/components/Radar/Blip";
import { Item, Quadrant, Ring } from "@/lib/types";
export interface ChartProps {
size?: number;
quadrants: Quadrant[];
rings: Ring[];
items: Item[];
className?: string;
}
const _Chart: FC<ChartProps> = ({
size = 800,
quadrants = [],
rings = [],
items = [],
className,
}) => {
const viewBoxSize = size;
const center = size / 2;
const startAngles = [270, 0, 180, 90]; // Corresponding to positions 1, 2, 3, and 4 respectively
// Helper function to convert polar coordinates to cartesian
const polarToCartesian = (
radius: number,
angleInDegrees: number,
): { x: number; y: number } => {
const angleInRadians = ((angleInDegrees - 90) * Math.PI) / 180.0;
return {
x: Math.round(center + radius * Math.cos(angleInRadians)),
y: Math.round(center + radius * Math.sin(angleInRadians)),
};
};
// Function to generate the path for a ring segment
const describeArc = (radiusPercentage: number, position: number): string => {
// Define the start and end angles based on the quadrant position
const startAngle = startAngles[position - 1];
const endAngle = startAngle + 90;
const radius = radiusPercentage * center; // Convert percentage to actual radius
const start = polarToCartesian(radius, endAngle);
const end = polarToCartesian(radius, startAngle);
// prettier-ignore
return [
"M", start.x, start.y,
"A", radius, radius, 0, 0, 0, end.x, end.y,
].join(" ");
};
const renderGlow = (position: number, color: string) => {
const gradientId = `glow-${position}`;
const cx = position === 1 || position === 3 ? 1 : 0;
const cy = position === 1 || position === 2 ? 1 : 0;
const x = position === 1 || position === 3 ? 0 : center;
const y = position === 1 || position === 2 ? 0 : center;
return (
<>
<defs>
<radialGradient id={gradientId} x={0} y={0} r={1} cx={cx} cy={cy}>
<stop offset="0%" stopColor={color} stopOpacity={0.5}></stop>
<stop offset="100%" stopColor={color} stopOpacity={0}></stop>
</radialGradient>
</defs>
<rect
width={center}
height={center}
x={x}
y={y}
fill={`url(#${gradientId})`}
/>
</>
);
};
// Function to place items inside their rings and quadrants
const renderItem = (item: Item) => {
const ring = rings.find((r) => r.id === item.ring);
const quadrant = quadrants.find((q) => q.id === item.quadrant);
if (!ring || !quadrant) return null; // If no ring or quadrant, don't render item
const [x, y] = item.position;
return (
<Link
key={item.id}
href={`/${item.quadrant}/${item.id}`}
data-tooltip={item.title}
data-tooltip-color={quadrant.color}
tabIndex={-1}
>
<Blip flag={item.flag} color={quadrant.color} x={x} y={y} />
</Link>
);
};
const renderRingLabels = () => {
return rings.map((ring, index) => {
const outerRadius = ring.radius || 1;
const innerRadius = rings[index - 1]?.radius || 0;
const position = ((outerRadius + innerRadius) / 2) * center;
return (
<Fragment key={ring.id}>
<text
x={center + position}
y={center}
textAnchor="middle"
dominantBaseline="middle"
fontSize="12"
>
{ring.title}
</text>
<text
x={center - position}
y={center}
textAnchor="middle"
dominantBaseline="middle"
fontSize="12"
>
{ring.title}
</text>
</Fragment>
);
});
};
return (
<svg
className={className}
width={viewBoxSize}
height={viewBoxSize}
viewBox={`0 0 ${viewBoxSize} ${viewBoxSize}`}
>
{quadrants.map((quadrant) => (
<g key={quadrant.id} data-quadrant={quadrant.id}>
{renderGlow(quadrant.position, quadrant.color)}
{rings.map((ring) => (
<path
key={`${ring.id}-${quadrant.id}`}
data-key={`${ring.id}-${quadrant.id}`}
d={describeArc(ring.radius || 0.5, quadrant.position)}
fill="none"
stroke={quadrant.color}
strokeWidth={ring.strokeWidth || 2}
/>
))}
</g>
))}
<g className={styles.items}>{items.map((item) => renderItem(item))}</g>
<g className={styles.ringLabels}>{renderRingLabels()}</g>
</svg>
);
};
export const Chart = memo(_Chart);

View File

@@ -0,0 +1,37 @@
.label {
width: 240px;
min-height: 210px;
position: absolute;
top: 0;
left: 0;
}
.header {
display: flex;
justify-content: space-between;
padding: 10px 0;
margin: 0 0 15px;
border-bottom: 2px solid var(--quadrant-color);
text-transform: uppercase;
font-size: 12px;
}
.title {
margin: 0 0 10px;
}
.description {
font-size: 14px;
}
.position-2,
.position-4 {
left: auto;
right: 0;
}
.position-3,
.position-4 {
top: auto;
bottom: 0;
}

View File

@@ -0,0 +1,36 @@
import Link from "next/link";
import { CSSProperties, useMemo } from "react";
import styles from "./Label.module.css";
import { QuadrantLink } from "@/components/QuadrantLink/QuadrantLink";
import { getLabel } from "@/lib/data";
import { Quadrant } from "@/lib/types";
import { cn } from "@/lib/utils";
interface LabelProps {
quadrant: Quadrant;
}
export function Label({ quadrant }: LabelProps) {
const style = useMemo(
() => ({ "--quadrant-color": quadrant.color }) as CSSProperties,
[quadrant.color],
);
return (
<div
className={cn(styles.label, styles[`position-${quadrant.position}`])}
style={style}
>
<div className={styles.header}>
<span>
{getLabel("quadrant")} {quadrant.position}
</span>
<QuadrantLink quadrant={quadrant} />
</div>
<h3 className={styles.title}>{quadrant.title}</h3>
<p className={styles.description}>{quadrant.description}</p>
</div>
);
}

View File

@@ -0,0 +1,35 @@
.legend {
list-style: none;
padding: 0;
margin: 0;
font-size: 14px;
display: none;
}
.icon {
display: inline-block;
vertical-align: middle;
width: 16px;
height: 16px;
margin: -2px 8px 0 0;
}
@media (min-width: 768px) {
.legend {
display: block;
position: absolute;
left: 50%;
bottom: 50px;
transform: translateX(-50%);
}
}
@media (min-width: 1200px) {
.legend {
bottom: auto;
left: auto;
right: 0;
top: 50%;
transform: translateY(-50%);
}
}

View File

@@ -0,0 +1,36 @@
import { ComponentPropsWithoutRef } from "react";
import styles from "./Legend.module.css";
import BlipChanged from "@/components/Icons/BlipChanged";
import BlipDefault from "@/components/Icons/BlipDefault";
import BlipNew from "@/components/Icons/BlipNew";
import { getFlags } from "@/lib/data";
import { Flag } from "@/lib/types";
function Icon({
flag,
...props
}: { flag: Flag } & ComponentPropsWithoutRef<"svg">) {
switch (flag) {
case Flag.New:
return <BlipNew {...props} />;
case Flag.Changed:
return <BlipChanged {...props} />;
case Flag.Default:
return <BlipDefault {...props} />;
}
}
export function Legend() {
return (
<ul className={styles.legend}>
{Object.entries(getFlags()).map(([key, flag]) => (
<li key={key}>
<Icon flag={key as Flag} className={styles.icon} />
<span className={styles.label}>{flag.description}</span>
</li>
))}
</ul>
);
}

View File

@@ -0,0 +1,74 @@
.radar {
padding: 0 15px 30px;
position: relative;
transition: padding 200ms ease-in-out;
}
.chart {
display: block;
max-width: 100%;
height: auto;
margin: 0 auto;
fill: currentColor;
}
.tooltip {
background-color: var(--tooltip, var(--background));
color: var(--foreground);
font-size: 14px;
padding: 4px 8px;
height: fit-content;
width: fit-content;
border-radius: 6px;
position: absolute;
text-align: center;
opacity: 0;
transform: translate(-50%, -90%) scale(0.7);
transform-origin: 50% 100%;
transition:
all 100ms ease-in-out,
left 0ms,
top 0ms;
box-shadow:
0 4px 14px 0 rgba(0, 0, 0, 0.2),
0 0 0 1px rgba(0, 0, 0, 0.05);
pointer-events: none;
z-index: 1;
&:before {
content: "";
display: block;
position: absolute;
z-index: 2;
bottom: -1px;
left: 50%;
margin-left: -8px;
width: 0;
height: 0;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-top: 8px solid var(--tooltip, var(--background));
transition: bottom 100ms ease-in-out;
}
&.isShown {
opacity: 1;
transform: translate(-50%, -130%) scale(1);
&:before {
bottom: -7px;
}
}
}
@media (max-width: 767px) {
.labels {
display: none;
}
}
@media (min-width: 768px) and (max-width: 1200px) {
.radar {
padding: 150px 15px;
}
}

View File

@@ -0,0 +1,111 @@
import React, {
CSSProperties,
FC,
MouseEvent,
useMemo,
useRef,
useState,
} from "react";
import styles from "./Radar.module.css";
import { Chart } from "@/components/Radar/Chart";
import { Label } from "@/components/Radar/Label";
import { Legend } from "@/components/Radar/Legend";
import { Item, Quadrant, Ring } from "@/lib/types";
import { cn } from "@/lib/utils";
export interface RadarProps {
size?: number;
quadrants: Quadrant[];
rings: Ring[];
items: Item[];
}
export const Radar: FC<RadarProps> = ({
size = 800,
quadrants = [],
rings = [],
items = [],
}) => {
const radarRef = useRef<HTMLDivElement>(null);
const [tooltip, setTooltip] = useState({
show: false,
text: "",
color: "",
x: 0,
y: 0,
});
const tooltipStyle = useMemo(
() =>
({
left: tooltip.x,
top: tooltip.y,
...(tooltip.color ? { "--tooltip": tooltip.color } : undefined),
}) as CSSProperties,
[tooltip],
);
const handleMouseMove = (e: MouseEvent<HTMLDivElement>) => {
const link =
e.target instanceof Element && e.target.closest("a[data-tooltip]");
if (link) {
const text = link.getAttribute("data-tooltip") || "";
const color = link.getAttribute("data-tooltip-color") || "";
const linkRect = link.getBoundingClientRect();
const radarRect = radarRef.current!.getBoundingClientRect();
// Adjusting tooltip position to be relative to the radar container
const x = linkRect.left - radarRect.left + linkRect.width / 2;
const y = linkRect.top - radarRect.top;
setTooltip({
text,
color,
show: !!text,
x,
y,
});
} else {
if (tooltip.show) {
setTooltip({ ...tooltip, show: false });
}
}
};
const handleMouseLeave = () => {
setTooltip({ ...tooltip, show: false });
};
return (
<div
ref={radarRef}
className={styles.radar}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
>
<Chart
className={styles.chart}
size={size}
quadrants={quadrants}
rings={rings}
items={items}
/>
<div className={styles.labels}>
{quadrants.map((quadrant) => (
<Label key={quadrant.id} quadrant={quadrant} />
))}
</div>
<Legend />
<span
className={cn(styles.tooltip, tooltip.show && styles.isShown)}
style={tooltipStyle}
>
{tooltip.text}
</span>
</div>
);
};
export default Radar;

View File

@@ -0,0 +1,38 @@
.rings {
--cols: 1;
--gap: 30px;
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: var(--gap);
}
.ring {
margin-bottom: 20px;
flex: 1 0
calc(100% / var(--cols) - var(--gap) / var(--cols) * (var(--cols) - 1));
}
.badge {
margin-bottom: 20px;
}
@media (min-width: 480px) {
.rings {
--cols: 2;
}
}
@media (min-width: 768px) {
.rings.isSmall {
--cols: 4;
}
}
@media (min-width: 1024px) {
.rings {
--cols: 4;
}
}

View File

@@ -0,0 +1,27 @@
import styles from "./RingList.module.css";
import { RingBadge } from "@/components/Badge/Badge";
import { ItemList, ItemListProps } from "@/components/ItemList/ItemList";
import { groupItemsByRing } from "@/lib/data";
import { Item } from "@/lib/types";
import { cn } from "@/lib/utils";
interface RingListProps {
items: Item[];
size?: ItemListProps["size"];
}
export function RingList({ items, size }: RingListProps) {
const rings = groupItemsByRing(items);
return (
<ul className={cn(styles.rings, { [styles.isSmall]: size == "small" })}>
{Object.entries(rings).map(([ring, items]) => {
return (
<li key={ring} className={styles.ring}>
<RingBadge className={styles.badge} ring={ring} />
<ItemList className={styles.list} items={items} size={size} />
</li>
);
})}
</ul>
);
}

View File

@@ -0,0 +1,29 @@
.links {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
list-style: none;
padding: 0;
}
.icon {
fill: var(--background);
width: 16px;
height: 16px;
}
.link {
display: block;
border: 1px solid var(--border);
background: var(--foreground);
padding: 6px;
border-radius: 50%;
&:hover {
background: var(--background);
.icon {
fill: var(--foreground);
}
}
}

View File

@@ -0,0 +1,66 @@
import styles from "./SocialLinks.module.css";
import {
SocialFacebook,
SocialGithub,
SocialGitlab,
SocialInstagram,
SocialLinkedin,
SocialX,
SocialXing,
SocialYoutube,
} from "@/components/Icons";
import { getSocialLinks } from "@/lib/data";
import { cn } from "@/lib/utils";
interface SocialLinksProps {
className?: string;
}
function getIcon(name: string) {
switch (name.toLowerCase()) {
case "facebook":
return SocialFacebook;
case "github":
return SocialGithub;
case "gitlab":
return SocialGitlab;
case "instagram":
return SocialInstagram;
case "linkedin":
return SocialLinkedin;
case "x":
return SocialX;
case "xing":
return SocialXing;
case "youtube":
return SocialYoutube;
default:
return null;
}
}
export function SocialLinks({ className }: SocialLinksProps) {
const links = getSocialLinks();
return (
<ul className={cn(styles.links, className)}>
{links.map((link, i) => {
const Icon = getIcon(link.icon);
return (
Icon && (
<li key={i}>
<a
href={link.href}
className={styles.link}
target="_blank"
rel="noopener noreferrer"
>
<Icon className={styles.icon} />
</a>
</li>
)
);
})}
</ul>
);
}

View File

@@ -0,0 +1,43 @@
.icon {
width: 16px;
height: 16px;
display: inline-block;
vertical-align: middle;
margin: -2px 6px 0 -5px;
}
.tag {
position: relative;
display: inline-block;
vertical-align: middle;
padding: 6px 15px 5px;
margin: 6px;
text-transform: uppercase;
border: 1px solid var(--tag);
border-radius: 13px;
background: var(--tag);
font-size: 14px;
line-height: 1;
overflow: hidden;
text-decoration: none;
transition: all 150ms ease-in-out;
&:hover,
&:focus,
&.active {
background: var(--foreground);
color: var(--background);
}
&.active {
.icon {
transform: scale(0.8);
}
}
}
.tags {
text-align: center;
margin: 0 auto 60px;
max-width: 600px;
}

View File

@@ -0,0 +1,47 @@
import Link, { LinkProps } from "next/link";
import { ComponentPropsWithoutRef } from "react";
import styles from "./Tags.module.css";
import IconRemove from "@/components/Icons/Close";
import IconTag from "@/components/Icons/Tag";
import { getLabel } from "@/lib/data";
import { cn } from "@/lib/utils";
type TagProps = {
tag: string;
isActive?: boolean;
} & Omit<LinkProps, "href"> &
ComponentPropsWithoutRef<"a">;
export function Tag({ tag, isActive, className, ...props }: TagProps) {
const Icon = isActive ? IconRemove : IconTag;
return (
<Link
{...props}
className={cn(styles.tag, className, isActive && styles.active)}
href={isActive ? "/" : `/?tag=${encodeURIComponent(tag)}`}
>
<Icon className={cn(styles.icon)} />
<span className={styles.label}>{tag}</span>
</Link>
);
}
interface TagsProps {
tags: string[];
activeTag?: string;
className?: string;
}
export function Tags({ tags, activeTag, className }: TagsProps) {
const label = getLabel("filterByTag");
return (
<div className={cn(styles.tags, className)}>
{!!label && <h3>{label}</h3>}
{tags.map((tag) => (
<Tag key={tag} tag={tag} isActive={activeTag == tag} scroll={false} />
))}
</div>
);
}