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:
50
radar-app/src/components/Badge/Badge.module.css
Normal file
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user