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>
40
radar-app/src/app/sitemap.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { MetadataRoute } from "next";
|
||||
|
||||
import { getAbsoluteUrl, getItems, getQuadrants } from "@/lib/data";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
export const revalidate = 60;
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
const quadrants = getQuadrants().map((quadrant) => ({
|
||||
url: getAbsoluteUrl(`/${quadrant.id}/`),
|
||||
lastModified: new Date(),
|
||||
priority: 0.8,
|
||||
}));
|
||||
|
||||
const items = getItems().map((item) => ({
|
||||
url: getAbsoluteUrl(`/${item.quadrant}/${item.id}/`),
|
||||
lastModified: new Date(),
|
||||
priority: 0.5,
|
||||
}));
|
||||
|
||||
return [
|
||||
{
|
||||
url: getAbsoluteUrl(),
|
||||
lastModified: new Date(),
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
url: getAbsoluteUrl("/overview/"),
|
||||
lastModified: new Date(),
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: getAbsoluteUrl("/help-and-about-tech-radar/"),
|
||||
lastModified: new Date(),
|
||||
priority: 0.9,
|
||||
},
|
||||
...quadrants,
|
||||
...items,
|
||||
];
|
||||
}
|
||||
50
radar-app/src/components/Badge/Badge.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
105
radar-app/src/components/Badge/Badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
radar-app/src/components/Filter/Filter.module.css
Normal file
@@ -0,0 +1,8 @@
|
||||
.filter {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 20px;
|
||||
gap: 20px;
|
||||
}
|
||||
25
radar-app/src/components/Filter/Filter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
radar-app/src/components/Filter/QueryFilter.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
39
radar-app/src/components/Filter/QueryFilter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
radar-app/src/components/Filter/RingFilter.module.css
Normal file
@@ -0,0 +1,8 @@
|
||||
.filter {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
}
|
||||
43
radar-app/src/components/Filter/RingFilter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
radar-app/src/components/Footer/Footer.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
20
radar-app/src/components/Footer/Footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
119
radar-app/src/components/ItemDetail/ItemDetail.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
81
radar-app/src/components/ItemDetail/ItemDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
radar-app/src/components/ItemList/ItemList.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
67
radar-app/src/components/ItemList/ItemList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
radar-app/src/components/Layout/Layout.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
39
radar-app/src/components/Layout/Layout.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
54
radar-app/src/components/Logo/Logo.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
22
radar-app/src/components/Logo/Logo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
radar-app/src/components/Navigation/Navigation.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
37
radar-app/src/components/Navigation/Navigation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
26
radar-app/src/components/QuadrantLink/QuadrantLink.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
35
radar-app/src/components/QuadrantList/QuadrantList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
radar-app/src/components/Radar/Blip.tsx
Normal 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} />;
|
||||
}
|
||||
9
radar-app/src/components/Radar/Chart.module.css
Normal file
@@ -0,0 +1,9 @@
|
||||
.ringLabels {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.ringLabels {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
163
radar-app/src/components/Radar/Chart.tsx
Normal 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);
|
||||
37
radar-app/src/components/Radar/Label.module.css
Normal 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;
|
||||
}
|
||||
36
radar-app/src/components/Radar/Label.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
radar-app/src/components/Radar/Legend.module.css
Normal 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%);
|
||||
}
|
||||
}
|
||||
36
radar-app/src/components/Radar/Legend.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
radar-app/src/components/Radar/Radar.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
111
radar-app/src/components/Radar/Radar.tsx
Normal 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;
|
||||
38
radar-app/src/components/RingList/RingList.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
27
radar-app/src/components/RingList/RingList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
radar-app/src/components/SocialLinks/SocialLinks.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
66
radar-app/src/components/SocialLinks/SocialLinks.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
radar-app/src/components/Tags/Tags.module.css
Normal 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;
|
||||
}
|
||||
47
radar-app/src/components/Tags/Tags.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
radar-app/src/icons/attention.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" ?><svg fill="none" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path clip-rule="evenodd" d="M12.8455 5.21137C12.4531 4.59003 11.5469 4.59003 11.1545 5.21137L2.78316 18.466C2.36261 19.1319 2.84109 20 3.62865 20H20.3713C21.1589 20 21.6374 19.1319 21.2168 18.466L12.8455 5.21137ZM9.46353 4.14338C10.6408 2.27935 13.3592 2.27936 14.5365 4.14339L22.9078 17.398C24.1695 19.3956 22.734 22 20.3713 22H3.62865C1.26598 22 -0.169465 19.3956 1.09218 17.398L9.46353 4.14338ZM13 17C13 17.5523 12.5523 18 12 18C11.4477 18 11 17.5523 11 17C11 16.4477 11.4477 16 12 16C12.5523 16 13 16.4477 13 17ZM10.6941 10.1644L11.4178 14.5068C11.4652 14.7914 11.7115 15 12 15C12.2885 15 12.5348 14.7914 12.5822 14.5068L13.3059 10.1644C13.4075 9.55487 12.9375 9 12.3195 9H11.6805C11.0625 9 10.5925 9.55487 10.6941 10.1644Z" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 874 B |
1
radar-app/src/icons/back.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.41421" viewBox="0 0 30 21"><path d="M4.012 11.427h24.704a1.134 1.134 0 0 0 0-2.267H3.729l7.224-7.225A1.133 1.133 0 1 0 9.35.332L.332 9.35a1.134 1.134 0 0 0-.106 1.481c.099.182.245.335.423.439l8.701 8.701a1.133 1.133 0 1 0 1.603-1.603l-6.941-6.941Z" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 420 B |
1
radar-app/src/icons/blip_changed.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect width="12" height="12" x="2" y="2" rx="3" transform="rotate(-45 8 8)"/></svg>
|
||||
|
After Width: | Height: | Size: 143 B |
1
radar-app/src/icons/blip_default.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><circle cx="8" cy="8" r="6"/></svg>
|
||||
|
After Width: | Height: | Size: 95 B |
1
radar-app/src/icons/blip_new.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg"><path d="m.247 10.212 5.02-8.697a2 2 0 0 1 3.465 0l5.021 8.697a2 2 0 0 1-1.732 3H1.98a2 2 0 0 1-1.732-3z"/></svg>
|
||||
|
After Width: | Height: | Size: 153 B |
1
radar-app/src/icons/close.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><svg id="a" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22"><path d="m18.7,1.4l-7.68,7.73L3.35,1.41c-.54-.54-1.4-.54-1.89,0-.54.54-.54,1.41,0,1.89l7.67,7.73-7.73,7.67c-.54.54-.54,1.41,0,1.89.54.54,1.41.54,1.89,0l7.73-7.68,7.68,7.68c.54.54,1.41.54,1.89,0,.49-.54.54-1.41,0-1.89l-7.68-7.68,7.68-7.67c.54-.54.54-1.4,0-1.89-.48-.54-1.35-.54-1.89-.06Z" fill-rule="evenodd" stroke-width="0"/></svg>
|
||||
|
After Width: | Height: | Size: 437 B |
6
radar-app/src/icons/edit.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" ?>
|
||||
<svg height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m18.988 2.012 3 3L19.701 7.3l-3-3zM8 16h3l7.287-7.287-3-3L8 13z"/>
|
||||
<path
|
||||
d="M19 19H8.158c-.026 0-.053.01-.079.01-.033 0-.066-.009-.1-.01H5V5h6.847l2-2H5c-1.103 0-2 .896-2 2v14c0 1.104.897 2 2 2h14a2 2 0 0 0 2-2v-8.668l-2 2V19z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 360 B |
1
radar-app/src/icons/filter.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><svg id="a" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22"><path d="m20.31,2.31c-.3-.56-.76-.83-1.4-.83H3.01c-.83,0-1.4.5-1.5,1.3,0,.07.03.2.07.23,2.1,2.4,4.19,4.72,6.32,7.09.1.1.13.2.13.37v6.55c0,.97.47,1.7,1.33,2.13.6.3,1.23.5,1.83.76.5.2.97.4,1.46.56.5.17.97-.07,1.13-.5.07-.17.1-.4.1-.6v-8.88c0-.13.03-.23.13-.33.76-.86,1.56-1.73,2.33-2.59,1.33-1.5,2.7-2.99,4.02-4.49.03-.03.1-.13.1-.2-.07-.2-.07-.4-.17-.56h0Zm-2,.86c-1.66,1.83-3.29,3.66-4.96,5.49-.03.03-.43.43-.93.9v9.21c-.07-.03-.13-.03-.17-.07-.7-.27-1.4-.56-2.1-.83-.43-.17-.63-.5-.63-.97v-7.35c-.47-.47-.86-.83-.9-.86-1.73-1.86-3.39-3.76-5.09-5.62-.03-.03-.07-.07-.1-.13h15.07c-.1.1-.13.17-.2.23h0Z" stroke-width="0"/></svg>
|
||||
|
After Width: | Height: | Size: 731 B |
1
radar-app/src/icons/overview.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><svg id="a" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22"><path d="m5.67,1c-1.45,0-2.67,1.22-2.67,2.67v15.11c0,1.23,1,2.22,2.22,2.22h13.11c.35,0,.67-.32.67-.67V5.67c0-.39-.29-.67-.67-.67-.64,0-1.33-.69-1.33-1.33s.69-1.33,1.33-1.33c.35,0,.67-.31.67-.67s-.31-.67-.67-.67c0,0-12.67,0-12.67,0Zm0,1.33h10.37c-.23.39-.37.85-.37,1.33s.14.94.37,1.33H5.67c-.64,0-1.33-.69-1.33-1.33s.69-1.33,1.33-1.33Zm-1.33,3.63c.39.23.85.37,1.33.37h12v13.33H5.22c-.48,0-.88-.37-.89-.86,0-.01,0-.02,0-.03V5.97h0Zm2.44,2.81c-.37.01-.65.32-.64.69.01.35.29.63.64.64h8.44c.37-.01.65-.32.64-.69-.01-.35-.29-.63-.64-.64H6.78Zm0,3.56c-.37,0-.67.3-.67.67,0,.37.3.67.67.67h8.44c.37,0,.67-.3.67-.67,0-.37-.3-.67-.67-.67H6.78Zm0,3.56c-.37.01-.65.32-.64.69.01.35.29.63.64.64h8.44c.37-.01.65-.32.64-.69-.01-.35-.29-.63-.64-.64H6.78Z" fill-rule="evenodd" stroke-width="0"/></svg>
|
||||
|
After Width: | Height: | Size: 887 B |
1
radar-app/src/icons/pie.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><svg id="a" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22"><path d="m9.71.38h-.64v2.61C4.22,3.32.38,7.36.38,12.29s4.19,9.33,9.33,9.33,8.97-3.84,9.3-8.69h2.61v-.64C21.62,5.72,16.28.38,9.71.38Zm0,19.95c-4.44,0-8.05-3.61-8.05-8.05s3.26-7.69,7.4-8.02v8.66h8.66c-.33,4.14-3.8,7.4-8.02,7.4h0Zm9.33-8.69h-8.69V1.69c5.34.32,9.63,4.61,9.95,9.96,0,0-1.27,0-1.27,0Z" fill-rule="evenodd" stroke-width="0"/></svg>
|
||||
|
After Width: | Height: | Size: 446 B |
1
radar-app/src/icons/question.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><svg id="a" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22"><path d="m11,22C4.9,22,0,17,0,11S4.9,0,11,0h0c6,0,11,4.9,11,11s-5,11-11,11Zm0-20.5h0C5.8,1.5,1.5,5.7,1.5,11s4.2,9.5,9.5,9.5,9.5-4.2,9.5-9.5S16.2,1.5,11,1.5" stroke-width="0"/><path d="m11,16.65c-.7,0-1.2-.5-1.2-1.2h0c0-.7.6-1.2,1.2-1.2s1.2.6,1.2,1.2-.5,1.2-1.2,1.2h0Zm2.8-6.9c-.1.2-.3.4-.4.5l-.4.4c-.2.1-.3.3-.4.4l-.3.3c-.2.3-.3.6-.3,1v.7h-1.8v-1c0-.3,0-.6.2-.9.2-.3.4-.6.7-.8l1.1-1.1c.2-.3.4-.6.4-1s-.1-.7-.4-.9c-.3-.2-.6-.4-1-.4s-.7.1-1,.4-.4.6-.5,1h-2c.1-.8.5-1.6,1.1-2.2.7-.5,1.5-.8,2.3-.8s1.6.2,2.3.8c.6.5.9,1.2.9,2.1,0,.5-.2,1-.5,1.5h0Z" fill-rule="evenodd" stroke-width="0"/></svg>
|
||||
|
After Width: | Height: | Size: 693 B |
1
radar-app/src/icons/search.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><svg id="a" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22"><path d="m20.6,18.31l-4.01-4.01c2.44-3.37,2.15-8.11-.89-11.14-1.61-1.62-3.81-2.53-6.09-2.52-2.28,0-4.48.91-6.09,2.52-3.36,3.36-3.36,8.82,0,12.18,0,0,0,0,0,0,1.61,1.62,3.81,2.53,6.09,2.52,1.78,0,3.55-.55,5.05-1.64l4.01,4.01c.26.26.61.4.96.4.75,0,1.36-.61,1.36-1.36,0-.36-.14-.71-.4-.96h0Zm-15.73-4.32c-2.62-2.62-2.62-6.87,0-9.49,1.26-1.26,2.96-1.97,4.74-1.97,1.79,0,3.48.7,4.74,1.97,2.62,2.62,2.62,6.87,0,9.49-1.26,1.26-2.96,1.97-4.74,1.97-1.78,0-3.49-.7-4.74-1.97Z" fill-rule="evenodd" stroke-width="0"/></svg>
|
||||
|
After Width: | Height: | Size: 615 B |
4
radar-app/src/icons/social-facebook.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg viewBox="0 0 320 512" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M279.14 288l14.22-92.66h-88.91v-60.13c0-25.35 12.42-50.06 52.24-50.06h40.42V6.26S260.43 0 225.36 0c-73.22 0-121.08 44.38-121.08 124.72v70.62H22.89V288h81.39v224h100.17V288z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 261 B |
4
radar-app/src/icons/social-github.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg viewBox="0 0 496 512" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
7
radar-app/src/icons/social-gitlab.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="a" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512">
|
||||
<g id="b">
|
||||
<path
|
||||
d="M487.75,200.06l-.7-1.78L419.55,22.09c-1.37-3.45-3.81-6.38-6.95-8.37-6.44-4-14.69-3.55-20.66,1.11-2.88,2.34-4.98,5.52-5.99,9.09l-45.58,139.46h-184.58L110.2,23.93c-.99-3.59-3.09-6.78-5.99-9.12-5.97-4.66-14.22-5.11-20.66-1.11-3.13,1.99-5.56,4.92-6.95,8.37L8.96,198.18l-.67,1.78c-19.96,52.17-3.01,111.25,41.58,144.89l.23.18.62.44,102.84,77.01,50.88,38.51,30.99,23.4c7.45,5.66,17.76,5.66,25.21,0l30.99-23.4,50.88-38.51,103.46-77.48.26-.21c44.49-33.64,61.41-92.62,41.53-144.73Z" stroke-width="0"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 646 B |
4
radar-app/src/icons/social-instagram.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg viewBox="0 0 448 512" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M224.1 141c-63.6 0-114.9 51.3-114.9 114.9s51.3 114.9 114.9 114.9S339 319.5 339 255.9 287.7 141 224.1 141zm0 189.6c-41.1 0-74.7-33.5-74.7-74.7s33.5-74.7 74.7-74.7 74.7 33.5 74.7 74.7-33.6 74.7-74.7 74.7zm146.4-194.3c0 14.9-12 26.8-26.8 26.8-14.9 0-26.8-12-26.8-26.8s12-26.8 26.8-26.8 26.8 12 26.8 26.8zm76.1 27.2c-1.7-35.9-9.9-67.7-36.2-93.9-26.2-26.2-58-34.4-93.9-36.2-37-2.1-147.9-2.1-184.9 0-35.8 1.7-67.6 9.9-93.9 36.1s-34.4 58-36.2 93.9c-2.1 37-2.1 147.9 0 184.9 1.7 35.9 9.9 67.7 36.2 93.9s58 34.4 93.9 36.2c37 2.1 147.9 2.1 184.9 0 35.9-1.7 67.7-9.9 93.9-36.2 26.2-26.2 34.4-58 36.2-93.9 2.1-37 2.1-147.8 0-184.8zM398.8 388c-7.8 19.6-22.9 34.7-42.6 42.6-29.5 11.7-99.5 9-132.1 9s-102.7 2.6-132.1-9c-19.6-7.8-34.7-22.9-42.6-42.6-11.7-29.5-9-99.5-9-132.1s-2.6-102.7 9-132.1c7.8-19.6 22.9-34.7 42.6-42.6 29.5-11.7 99.5-9 132.1-9s102.7-2.6 132.1 9c19.6 7.8 34.7 22.9 42.6 42.6 11.7 29.5 9 99.5 9 132.1s2.7 102.7-9 132.1z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1010 B |
4
radar-app/src/icons/social-linkedin.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg viewBox="0 0 448 512" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M100.28 448H7.4V148.9h92.88zM53.79 108.1C24.09 108.1 0 83.5 0 53.8a53.79 53.79 0 0 1 107.58 0c0 29.7-24.1 54.3-53.79 54.3zM447.9 448h-92.68V302.4c0-34.7-.7-79.2-48.29-79.2-48.29 0-55.69 37.7-55.69 76.7V448h-92.78V148.9h89.08v40.8h1.3c12.4-23.5 42.69-48.3 87.88-48.3 94 0 111.28 61.9 111.28 142.3V448z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 389 B |
4
radar-app/src/icons/social-x.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M12.6.75h2.454l-5.36 6.142L16 15.25h-4.937l-3.867-5.07-4.425 5.07H.316l5.733-6.57L0 .75h5.063l3.495 4.633L12.601.75Zm-.86 13.028h1.36L4.323 2.145H2.865z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 238 B |
4
radar-app/src/icons/social-xing.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg viewBox="0 0 384 512" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M162.7 210c-1.8 3.3-25.2 44.4-70.1 123.5-4.9 8.3-10.8 12.5-17.7 12.5H9.8c-7.7 0-12.1-7.5-8.5-14.4l69-121.3c.2 0 .2-.1 0-.3l-43.9-75.6c-4.3-7.8.3-14.1 8.5-14.1H100c7.3 0 13.3 4.1 18 12.2l44.7 77.5zM382.6 46.1l-144 253v.3L330.2 466c3.9 7.1.2 14.1-8.5 14.1h-65.2c-7.6 0-13.6-4-18-12.2l-92.4-168.5c3.3-5.8 51.5-90.8 144.8-255.2 4.6-8.1 10.4-12.2 17.5-12.2h65.7c8 0 12.3 6.7 8.5 14.1z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 468 B |
4
radar-app/src/icons/social-youtube.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg viewBox="0 0 576 512" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M549.655 124.083c-6.281-23.65-24.787-42.276-48.284-48.597C458.781 64 288 64 288 64S117.22 64 74.629 75.486c-23.497 6.322-42.003 24.947-48.284 48.597-11.412 42.867-11.412 132.305-11.412 132.305s0 89.438 11.412 132.305c6.281 23.65 24.787 41.5 48.284 47.821C117.22 448 288 448 288 448s170.78 0 213.371-11.486c23.497-6.321 42.003-24.171 48.284-47.821 11.412-42.867 11.412-132.305 11.412-132.305s0-89.438-11.412-132.305zm-317.51 213.508V175.185l142.739 81.205-142.739 81.201z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 558 B |
1
radar-app/src/icons/tag.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" ?><svg version="1.1" viewBox="0 0 30 30" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M23,3h-6c-0.512,0-1.024,0.195-1.414,0.586l-12,12c-0.781,0.781-0.781,2.047,0,2.828l8,8c0.781,0.781,2.047,0.781,2.828,0 c0.391-0.391,11.609-11.609,12-12C26.805,14.024,27,13.512,27,13V7C27,4.791,25.209,3,23,3z M23,9c-1.105,0-2-0.895-2-2 c0-1.105,0.895-2,2-2s2,0.895,2,2C25,8.105,24.105,9,23,9z"/></svg>
|
||||
|
After Width: | Height: | Size: 470 B |
31
radar-app/src/lib/config.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import defaultConfig from "../../data/config.default.json";
|
||||
import _userConfig from "../../data/config.json";
|
||||
|
||||
type DeepPartial<T> = T extends object
|
||||
? {
|
||||
[P in keyof T]?: DeepPartial<T[P]>;
|
||||
}
|
||||
: T;
|
||||
|
||||
type Config = typeof defaultConfig;
|
||||
type UserConfig = DeepPartial<typeof defaultConfig>;
|
||||
|
||||
const userConfig = _userConfig as UserConfig;
|
||||
const config = { ...defaultConfig, ...userConfig } as Config;
|
||||
|
||||
if (userConfig.colors)
|
||||
config.colors = { ...defaultConfig.colors, ...userConfig.colors };
|
||||
|
||||
if (userConfig.labels)
|
||||
config.labels = { ...defaultConfig.labels, ...userConfig.labels };
|
||||
|
||||
if (userConfig.toggles)
|
||||
config.toggles = { ...defaultConfig.toggles, ...userConfig.toggles };
|
||||
|
||||
if (userConfig.fuzzySearch)
|
||||
config.fuzzySearch = {
|
||||
...defaultConfig.fuzzySearch,
|
||||
...userConfig.fuzzySearch,
|
||||
};
|
||||
|
||||
export default config;
|
||||
131
radar-app/src/lib/data.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import data from "../../data/data.json";
|
||||
import config from "./config";
|
||||
|
||||
import { format } from "@/lib/format";
|
||||
import { Flag, Item, Quadrant, Ring } from "@/lib/types";
|
||||
import { assetUrl } from "@/lib/utils";
|
||||
|
||||
export function getLabel(key: keyof typeof config.labels) {
|
||||
return config.labels[key] || "";
|
||||
}
|
||||
|
||||
export function getToggle(key: keyof typeof config.toggles) {
|
||||
return config.toggles[key] || false;
|
||||
}
|
||||
|
||||
export function getSections() {
|
||||
return config.sections;
|
||||
}
|
||||
|
||||
export function getAppName() {
|
||||
return getLabel("title");
|
||||
}
|
||||
|
||||
export function getLogoUrl() {
|
||||
return assetUrl(config.logoFile);
|
||||
}
|
||||
|
||||
export function getJsUrl(): string {
|
||||
if (!config.jsFile) return "";
|
||||
return assetUrl(config.jsFile);
|
||||
}
|
||||
|
||||
export function getChartConfig() {
|
||||
return config.chart;
|
||||
}
|
||||
|
||||
export function getColors() {
|
||||
return config.colors;
|
||||
}
|
||||
|
||||
export function getFlags() {
|
||||
return config.flags;
|
||||
}
|
||||
|
||||
export function getFlag(flag: Flag) {
|
||||
return config.flags[flag];
|
||||
}
|
||||
|
||||
export const getFuzzySearchConfig = () => {
|
||||
return config.fuzzySearch;
|
||||
};
|
||||
|
||||
export function getRings(): Ring[] {
|
||||
return config.rings;
|
||||
}
|
||||
|
||||
export function getRing(id: string): Ring | undefined {
|
||||
return getRings().find((r) => r.id === id);
|
||||
}
|
||||
|
||||
export function getReleases(): string[] {
|
||||
return data.releases;
|
||||
}
|
||||
|
||||
export function getSocialLinks() {
|
||||
return config.social;
|
||||
}
|
||||
|
||||
export function getTags(): string[] {
|
||||
return data.tags;
|
||||
}
|
||||
|
||||
export function getEditUrl(props: { id: string; release: string }) {
|
||||
if (!config.editUrl) return "";
|
||||
return format(config.editUrl, props);
|
||||
}
|
||||
|
||||
export function getQuadrants(): Quadrant[] {
|
||||
return config.quadrants.map((q, i) => ({ ...q, position: i + 1 }));
|
||||
}
|
||||
|
||||
export function getQuadrant(id: string): Quadrant | undefined {
|
||||
return getQuadrants().find((q) => q.id === id);
|
||||
}
|
||||
|
||||
export function getItems(quadrant?: string, featured?: boolean): Item[] {
|
||||
return data.items.filter((item) => {
|
||||
if (quadrant && item.quadrant !== quadrant) return false;
|
||||
return !(featured && !item.featured);
|
||||
}) as Item[];
|
||||
}
|
||||
|
||||
export function getImprintUrl() {
|
||||
return config.imprint;
|
||||
}
|
||||
|
||||
export function getAbsoluteUrl(path: string = "/") {
|
||||
return `${config.baseUrl}${path}`;
|
||||
}
|
||||
|
||||
export function getItem(id: string): Item | undefined {
|
||||
return data.items.find((item) => item.id === id) as Item;
|
||||
}
|
||||
|
||||
export const sortByFeaturedAndTitle = (a: Item, b: Item) =>
|
||||
Number(b.featured) - Number(a.featured) || a.title.localeCompare(b.title);
|
||||
|
||||
export const groupItemsByRing = (items: Item[]) => {
|
||||
const showEmptyRings = getToggle("showEmptyRings");
|
||||
return getRings().reduce(
|
||||
(acc, ring) => {
|
||||
const ringItems = items.filter((item) => item.ring === ring.id);
|
||||
if (ringItems.length || showEmptyRings) acc[ring.id] = ringItems;
|
||||
return acc;
|
||||
},
|
||||
{} as { [ringId: string]: Item[] },
|
||||
);
|
||||
};
|
||||
|
||||
export const groupItemsByQuadrant = (items: Item[]) => {
|
||||
return getQuadrants().reduce(
|
||||
(acc, quadrant) => {
|
||||
const quadrantItems = items.filter(
|
||||
(item) => item.quadrant === quadrant.id,
|
||||
);
|
||||
if (quadrantItems.length) acc[quadrant.id] = quadrantItems;
|
||||
return acc;
|
||||
},
|
||||
{} as { [quadrantId: string]: Item[] },
|
||||
);
|
||||
};
|
||||
23
radar-app/src/lib/format.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { getAppName } from "@/lib/data";
|
||||
|
||||
// Replaces placeholders in a string with values from a context object
|
||||
// e.g. format("Hello {name}.", {name: "World"}) => "Hello World."
|
||||
export function format(text: string, context: Record<string, any>): string {
|
||||
return text.replace(/{(\w+)}/g, (match, key) => {
|
||||
return context[key] || match;
|
||||
});
|
||||
}
|
||||
|
||||
// Format the title of the page
|
||||
export function formatTitle(...title: string[]): string {
|
||||
return [...title, getAppName()].join(" | ");
|
||||
}
|
||||
|
||||
// Formats a release (2024-02-14) to a date (February 2024)
|
||||
export function formatRelease(release: string): string {
|
||||
const date = new Date(release);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
45
radar-app/src/lib/types.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
export enum Flag {
|
||||
New = "new",
|
||||
Changed = "changed",
|
||||
Default = "default",
|
||||
}
|
||||
|
||||
export type Release = string;
|
||||
|
||||
export interface Revision {
|
||||
release: Release;
|
||||
ring: string;
|
||||
body?: string;
|
||||
}
|
||||
|
||||
export interface Item {
|
||||
id: string;
|
||||
title: string;
|
||||
info?: string;
|
||||
body: string;
|
||||
featured: boolean;
|
||||
ring: string;
|
||||
quadrant: string;
|
||||
flag: Flag;
|
||||
tags?: string[];
|
||||
release: Release;
|
||||
revisions?: Revision[];
|
||||
position: [x: number, y: number];
|
||||
}
|
||||
|
||||
export interface Ring {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
color: string;
|
||||
radius?: number;
|
||||
strokeWidth?: number;
|
||||
}
|
||||
|
||||
export interface Quadrant {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
color: string;
|
||||
position: number;
|
||||
}
|
||||
14
radar-app/src/lib/utils.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
|
||||
import config from "../../next.config.js";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return clsx(inputs);
|
||||
}
|
||||
|
||||
export function assetUrl(path: string) {
|
||||
if (/^https?:/.test(path)) return path;
|
||||
if (!config.basePath) return path;
|
||||
if (!path.startsWith("/")) path = "/" + path;
|
||||
return `${config.basePath}${path}`;
|
||||
}
|
||||
22
radar-app/src/pages/404.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import Head from "next/head";
|
||||
import Link from "next/link";
|
||||
|
||||
import Search from "@/components/Icons/Search";
|
||||
import { formatTitle } from "@/lib/format";
|
||||
|
||||
export default function Custom404() {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{formatTitle("404 - Page Not Found")}</title>
|
||||
</Head>
|
||||
<div style={{ textAlign: "center", margin: "0 auto" }}>
|
||||
<Search width={150} style={{ display: "inline-block" }} />
|
||||
<h1>404 - Page Not Found</h1>
|
||||
<p>
|
||||
<Link href="/">Return to homepage</Link>
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
24
radar-app/src/pages/[quadrant]/[id].module.css
Normal file
@@ -0,0 +1,24 @@
|
||||
.content {
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
.ringAndQuadrant {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.layout {
|
||||
display: flex;
|
||||
}
|
||||
.sidebar {
|
||||
width: 360px;
|
||||
padding: 110px 0 0 60px;
|
||||
}
|
||||
.content {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
75
radar-app/src/pages/[quadrant]/[id].tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import Head from "next/head";
|
||||
import { useRouter } from "next/router";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import styles from "./[id].module.css";
|
||||
|
||||
import { RingBadge } from "@/components/Badge/Badge";
|
||||
import { ItemDetail } from "@/components/ItemDetail/ItemDetail";
|
||||
import { ItemList } from "@/components/ItemList/ItemList";
|
||||
import { QuadrantLink } from "@/components/QuadrantLink/QuadrantLink";
|
||||
import {
|
||||
getItem,
|
||||
getItems,
|
||||
getLabel,
|
||||
getQuadrant,
|
||||
sortByFeaturedAndTitle,
|
||||
} from "@/lib/data";
|
||||
import { formatTitle } from "@/lib/format";
|
||||
import { CustomPage } from "@/pages/_app";
|
||||
|
||||
const ItemPage: CustomPage = () => {
|
||||
const { query } = useRouter();
|
||||
const quadrant = getQuadrant(query.quadrant as string);
|
||||
const item = getItem(query.id as string);
|
||||
|
||||
const relatedItems = useMemo(() => {
|
||||
return getItems()
|
||||
.filter((i) => i.quadrant === quadrant?.id && i.ring == item?.ring)
|
||||
.sort(sortByFeaturedAndTitle);
|
||||
}, [quadrant?.id, item?.ring]);
|
||||
|
||||
if (!quadrant || !item) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{formatTitle(item.title, quadrant.title)}</title>
|
||||
<meta name="description" content={quadrant.description} />
|
||||
</Head>
|
||||
|
||||
<div className={styles.layout}>
|
||||
<section className={styles.content}>
|
||||
<ItemDetail item={item} />
|
||||
</section>
|
||||
<aside className={styles.sidebar}>
|
||||
<h3>{quadrant.title}</h3>
|
||||
<div className={styles.ringAndQuadrant}>
|
||||
<RingBadge ring={item.ring} />
|
||||
<QuadrantLink
|
||||
quadrant={quadrant}
|
||||
label={getLabel("quadrantOverview")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ItemList items={relatedItems} activeId={item.id} />
|
||||
</aside>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ItemPage;
|
||||
|
||||
export const getStaticPaths = async () => {
|
||||
const items = getItems();
|
||||
const paths = items.map((item) => ({
|
||||
params: { quadrant: item.quadrant, id: item.id },
|
||||
}));
|
||||
|
||||
return { paths, fallback: false };
|
||||
};
|
||||
|
||||
export const getStaticProps = async () => {
|
||||
return { props: {} };
|
||||
};
|
||||
52
radar-app/src/pages/[quadrant]/index.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import Head from "next/head";
|
||||
import { useRouter } from "next/router";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { RingList } from "@/components/RingList/RingList";
|
||||
import {
|
||||
getItems,
|
||||
getQuadrant,
|
||||
getQuadrants,
|
||||
sortByFeaturedAndTitle,
|
||||
} from "@/lib/data";
|
||||
import { formatTitle } from "@/lib/format";
|
||||
import { CustomPage } from "@/pages/_app";
|
||||
|
||||
const QuadrantPage: CustomPage = () => {
|
||||
const { query } = useRouter();
|
||||
const quadrant = getQuadrant(query.quadrant as string);
|
||||
const items = useMemo(
|
||||
() => quadrant?.id && getItems(quadrant.id).sort(sortByFeaturedAndTitle),
|
||||
[quadrant?.id],
|
||||
);
|
||||
if (!quadrant || !items) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{formatTitle(quadrant.title)}</title>
|
||||
<meta name="description" content={quadrant.description} />
|
||||
</Head>
|
||||
|
||||
<h1>{quadrant.title}</h1>
|
||||
<h2>{quadrant.description}</h2>
|
||||
|
||||
<RingList items={items} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuadrantPage;
|
||||
|
||||
export const getStaticPaths = async () => {
|
||||
const quadrants = getQuadrants();
|
||||
const paths = quadrants.map((quadrant) => ({
|
||||
params: { quadrant: quadrant.id },
|
||||
}));
|
||||
|
||||
return { paths, fallback: false };
|
||||
};
|
||||
|
||||
export const getStaticProps = async () => {
|
||||
return { props: {} };
|
||||
};
|
||||
37
radar-app/src/pages/_app.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { NextPage } from "next";
|
||||
import type { AppProps } from "next/app";
|
||||
import Head from "next/head";
|
||||
import Script from "next/script";
|
||||
|
||||
import { Layout, type LayoutClass } from "@/components/Layout/Layout";
|
||||
import { getJsUrl } from "@/lib/data";
|
||||
import { formatTitle } from "@/lib/format";
|
||||
import { assetUrl } from "@/lib/utils";
|
||||
import "@/styles/_globals.css";
|
||||
import "@/styles/_hljs.css";
|
||||
import "@/styles/custom.css";
|
||||
|
||||
export type CustomPage<P = {}, IP = P> = NextPage<P, IP> & {
|
||||
layoutClass?: LayoutClass;
|
||||
};
|
||||
|
||||
type CustomAppProps = AppProps & {
|
||||
Component: CustomPage;
|
||||
};
|
||||
|
||||
export default function App({ Component, pageProps, router }: CustomAppProps) {
|
||||
const jsUrl = getJsUrl();
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{formatTitle()}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href={assetUrl("/favicon.ico")} />
|
||||
</Head>
|
||||
<Layout layoutClass={Component.layoutClass}>
|
||||
<Component {...pageProps} />
|
||||
{jsUrl && <Script src={jsUrl} />}
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
27
radar-app/src/pages/_document.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Head, Html, Main, NextScript } from "next/document";
|
||||
import { CSSProperties, useMemo } from "react";
|
||||
|
||||
import { getColors } from "@/lib/data";
|
||||
|
||||
export default function Document() {
|
||||
const style = useMemo(() => {
|
||||
const cssVariables: Record<string, any> = {};
|
||||
const colors = getColors();
|
||||
|
||||
Object.entries(colors).forEach(([key, value]) => {
|
||||
cssVariables[`--${key}`] = value;
|
||||
});
|
||||
|
||||
return cssVariables as CSSProperties;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Html lang="en" style={style}>
|
||||
<Head />
|
||||
<body>
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
20
radar-app/src/pages/help-and-about-tech-radar.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import Head from "next/head";
|
||||
|
||||
import about from "../../data/about.json";
|
||||
|
||||
import { formatTitle } from "@/lib/format";
|
||||
import { CustomPage } from "@/pages/_app";
|
||||
|
||||
const HelpAndAbout: CustomPage = () => {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{formatTitle("Help and About")}</title>
|
||||
</Head>
|
||||
|
||||
<div dangerouslySetInnerHTML={{ __html: about.body }} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default HelpAndAbout;
|
||||
85
radar-app/src/pages/index.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import Head from "next/head";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { QuadrantList } from "@/components/QuadrantList/QuadrantList";
|
||||
import { Radar } from "@/components/Radar/Radar";
|
||||
import { Tags } from "@/components/Tags/Tags";
|
||||
import {
|
||||
getAppName,
|
||||
getChartConfig,
|
||||
getItems,
|
||||
getLabel,
|
||||
getQuadrants,
|
||||
getReleases,
|
||||
getRings,
|
||||
getSections,
|
||||
getTags,
|
||||
getToggle,
|
||||
} from "@/lib/data";
|
||||
import { CustomPage } from "@/pages/_app";
|
||||
|
||||
const Home: CustomPage = () => {
|
||||
const router = useRouter();
|
||||
const tag = router.query.tag as string | undefined;
|
||||
const appName = getAppName();
|
||||
const metaDescription = getLabel("metaDescription");
|
||||
const chartConfig = getChartConfig();
|
||||
const sections = getSections();
|
||||
const version = getReleases().length;
|
||||
const rings = getRings();
|
||||
const quadrants = getQuadrants();
|
||||
const tags = getTags();
|
||||
const items = getItems(undefined, true).filter(
|
||||
(item) => !tag || item.tags?.includes(tag),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
{metaDescription && (
|
||||
<meta name="description" content={metaDescription} />
|
||||
)}
|
||||
</Head>
|
||||
|
||||
<h1>
|
||||
{appName}{" "}
|
||||
<span style={{ color: "var(--highlight)", whiteSpace: "nowrap" }}>
|
||||
Version #{version}
|
||||
</span>
|
||||
</h1>
|
||||
{sections.map((section) => {
|
||||
switch (section) {
|
||||
case "radar":
|
||||
return (
|
||||
getToggle("showChart") && (
|
||||
<Radar
|
||||
key={section}
|
||||
size={chartConfig.size}
|
||||
quadrants={quadrants}
|
||||
rings={rings}
|
||||
items={items}
|
||||
/>
|
||||
)
|
||||
);
|
||||
case "tags":
|
||||
return (
|
||||
getToggle("showTagFilter") &&
|
||||
tags.length > 0 && (
|
||||
<Tags key={section} tags={tags} activeTag={tag} />
|
||||
)
|
||||
);
|
||||
case "list":
|
||||
return (
|
||||
getToggle("showQuadrantList") && (
|
||||
<QuadrantList key={section} items={items} />
|
||||
)
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
84
radar-app/src/pages/overview.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import Fuse from "fuse.js";
|
||||
import Head from "next/head";
|
||||
import { useRouter } from "next/router";
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
import { Filter } from "@/components/Filter/Filter";
|
||||
import { ItemList } from "@/components/ItemList/ItemList";
|
||||
import { getItems, getLabel } from "@/lib/data";
|
||||
import { getFuzzySearchConfig } from "@/lib/data";
|
||||
import { formatTitle } from "@/lib/format";
|
||||
import { CustomPage } from "@/pages/_app";
|
||||
|
||||
const Overview: CustomPage = () => {
|
||||
const title = getLabel("pageOverview");
|
||||
const router = useRouter();
|
||||
const ring = router.query.ring as string | undefined;
|
||||
const query = (router.query.query as string) || "";
|
||||
|
||||
const onRingChange = useCallback(
|
||||
(ring: string) => {
|
||||
router.push({ query: { ...router.query, ring, query } });
|
||||
},
|
||||
[router, query],
|
||||
);
|
||||
|
||||
const onQueryChange = useCallback(
|
||||
(query: string) => {
|
||||
router.replace({ query: { ...router.query, ring, query } });
|
||||
},
|
||||
[router, ring],
|
||||
);
|
||||
|
||||
const { items, index } = useMemo(() => {
|
||||
const items = getItems().filter((item) => !ring || item.ring === ring);
|
||||
const index = new Fuse(items, {
|
||||
...getFuzzySearchConfig(),
|
||||
keys: [
|
||||
{
|
||||
name: "title",
|
||||
weight: 1.5,
|
||||
},
|
||||
{
|
||||
name: "tags",
|
||||
weight: 1,
|
||||
},
|
||||
{
|
||||
name: "body",
|
||||
weight: 0.9,
|
||||
},
|
||||
{
|
||||
name: "revision.body",
|
||||
weight: 0.7,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return { items, index };
|
||||
}, [ring]);
|
||||
|
||||
const results = useMemo(() => {
|
||||
if (!query) return items;
|
||||
return index.search(query).map((result) => result.item);
|
||||
}, [query, index, items]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{formatTitle(title)}</title>
|
||||
</Head>
|
||||
|
||||
<h1>{title}</h1>
|
||||
<Filter
|
||||
query={query}
|
||||
ring={ring}
|
||||
onRingChange={onRingChange}
|
||||
onQueryChange={onQueryChange}
|
||||
/>
|
||||
|
||||
<ItemList items={results} size="large" hideRing={!!ring} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Overview;
|
||||
126
radar-app/src/styles/_globals.css
Normal file
@@ -0,0 +1,126 @@
|
||||
:root {
|
||||
--max-width: 1200px;
|
||||
--border-radius: 12px;
|
||||
--font-mono:
|
||||
ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono",
|
||||
"Roboto Mono", "Oxygen Mono", "Ubuntu Monospace", "Source Code Pro",
|
||||
"Fira Mono", "Droid Sans Mono", "Courier New", monospace;
|
||||
|
||||
--foreground: #fff;
|
||||
--background: #173d7a;
|
||||
--content: #fff;
|
||||
--text: #575757;
|
||||
--link: #029df7;
|
||||
--highlight: #029df7;
|
||||
--border: rgba(255, 255, 255, 0.1);
|
||||
--tag: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
outline-color: var(--highlight);
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
scroll-behavior: smooth;
|
||||
max-width: 100vw;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--foreground);
|
||||
background: var(--background);
|
||||
font-weight: 400;
|
||||
line-height: 1.3em;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
line-height: 1.2em;
|
||||
margin-bottom: 1em;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 37px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-weight: 400;
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
padding-left: 16px;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
ol {
|
||||
padding-left: 26px;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin-bottom: 1em;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
input {
|
||||
background: var(--foreground);
|
||||
color: var(--background);
|
||||
border: 1px solid transparent;
|
||||
padding: 10px 12px;
|
||||
border-radius: 3px;
|
||||
width: 100%;
|
||||
font-size: 16px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--highlight);
|
||||
}
|
||||
}
|
||||
|
||||
input::-webkit-search-cancel-button {
|
||||
display: none;
|
||||
}
|
||||
96
radar-app/src/styles/_hljs.css
Normal file
@@ -0,0 +1,96 @@
|
||||
.hljs-subst {
|
||||
/* var(--highlight-color) */
|
||||
color: #2f3337;
|
||||
}
|
||||
|
||||
.hljs-comment {
|
||||
/* var(--highlight-comment) */
|
||||
color: #656e77;
|
||||
}
|
||||
|
||||
.hljs-keyword,
|
||||
.hljs-selector-tag,
|
||||
.hljs-meta .hljs-keyword,
|
||||
.hljs-doctag,
|
||||
.hljs-section {
|
||||
/* var(--highlight-keyword) */
|
||||
color: #015692;
|
||||
}
|
||||
|
||||
.hljs-attr {
|
||||
/* var(--highlight-attribute); */
|
||||
color: #015692;
|
||||
}
|
||||
|
||||
.hljs-attribute {
|
||||
/* var(--highlight-symbol) */
|
||||
color: #803378;
|
||||
}
|
||||
|
||||
.hljs-name,
|
||||
.hljs-type,
|
||||
.hljs-number,
|
||||
.hljs-selector-id,
|
||||
.hljs-quote,
|
||||
.hljs-template-tag {
|
||||
/* var(--highlight-namespace) */
|
||||
color: #b75501;
|
||||
}
|
||||
|
||||
.hljs-selector-class {
|
||||
/* var(--highlight-keyword) */
|
||||
color: #015692;
|
||||
}
|
||||
|
||||
.hljs-string,
|
||||
.hljs-regexp,
|
||||
.hljs-symbol,
|
||||
.hljs-variable,
|
||||
.hljs-template-variable,
|
||||
.hljs-link,
|
||||
.hljs-selector-attr {
|
||||
/* var(--highlight-variable) */
|
||||
color: #54790d;
|
||||
}
|
||||
|
||||
.hljs-meta,
|
||||
.hljs-selector-pseudo {
|
||||
/* var(--highlight-keyword) */
|
||||
color: #015692;
|
||||
}
|
||||
|
||||
.hljs-built_in,
|
||||
.hljs-title,
|
||||
.hljs-literal {
|
||||
/* var(--highlight-literal) */
|
||||
color: #b75501;
|
||||
}
|
||||
|
||||
.hljs-bullet,
|
||||
.hljs-code {
|
||||
/* var(--highlight-punctuation) */
|
||||
color: #535a60;
|
||||
}
|
||||
|
||||
.hljs-meta .hljs-string {
|
||||
/* var(--highlight-variable) */
|
||||
color: #54790d;
|
||||
}
|
||||
|
||||
.hljs-deletion {
|
||||
/* var(--highlight-deletion) */
|
||||
color: #c02d2e;
|
||||
}
|
||||
|
||||
.hljs-addition {
|
||||
/* var(--highlight-addition) */
|
||||
color: #2f6f44;
|
||||
}
|
||||
|
||||
.hljs-emphasis {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.hljs-strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
2
radar-app/src/styles/custom.css
Normal file
@@ -0,0 +1,2 @@
|
||||
/* Use this file to optionally override global css styles and use with caution. */
|
||||
/* See README.md for hints and examples: https://github.com/AOEpeople/aoe_technology_radar/ */
|
||||