committed by
Bastian
parent
8d28e4c3a3
commit
70ea8d5bcd
16
README.md
16
README.md
@@ -110,6 +110,22 @@ To change the logo, create a public folder in your application and put your `log
|
|||||||
|
|
||||||
For reference have a look at [public/logo.svg](./public/logo.svg).
|
For reference have a look at [public/logo.svg](./public/logo.svg).
|
||||||
|
|
||||||
|
### Change the rings and quadrants config
|
||||||
|
To change the default rings and quadrants of the radar, you can place a custom `config.json` file within the `public` folder.
|
||||||
|
The content should look as follows:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"quadrants": {
|
||||||
|
"languages-and-frameworks": "Languages & Frameworks",
|
||||||
|
"methods-and-patterns": "Methods & Patterns",
|
||||||
|
"platforms-and-aoe-services": "Platforms & Operations",
|
||||||
|
"tools": "Tools"
|
||||||
|
},
|
||||||
|
"rings":["all", "adopt", "trial", "assess", "hold"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Change the index.html
|
### Change the index.html
|
||||||
To change the index.html, create a public folder in your application and put your `index.html` in it.
|
To change the index.html, create a public folder in your application and put your `index.html` in it.
|
||||||
|
|
||||||
|
|||||||
9
config_example.json
Normal file
9
config_example.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"quadrants": {
|
||||||
|
"languages-and-frameworks": "Languages & Frameworks",
|
||||||
|
"methods-and-patterns": "Methods & Patterns",
|
||||||
|
"platforms-and-aoe-services": "Platforms & Operations",
|
||||||
|
"tools": "Tools"
|
||||||
|
},
|
||||||
|
"rings":["all", "adopt", "trial", "assess", "hold"]
|
||||||
|
}
|
||||||
@@ -39,7 +39,6 @@ var __generator = (this && this.__generator) || function (thisArg, body) {
|
|||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
var fs_1 = require("fs");
|
var fs_1 = require("fs");
|
||||||
var radar_1 = require("./generateJson/radar");
|
var radar_1 = require("./generateJson/radar");
|
||||||
var config_1 = require("../src/config");
|
|
||||||
// Do this as the first thing so that any code reading it knows the right env.
|
// Do this as the first thing so that any code reading it knows the right env.
|
||||||
process.env.BABEL_ENV = "production";
|
process.env.BABEL_ENV = "production";
|
||||||
process.env.NODE_ENV = "production";
|
process.env.NODE_ENV = "production";
|
||||||
@@ -50,7 +49,7 @@ process.on("unhandledRejection", function (err) {
|
|||||||
throw err;
|
throw err;
|
||||||
});
|
});
|
||||||
(function () { return __awaiter(void 0, void 0, void 0, function () {
|
(function () { return __awaiter(void 0, void 0, void 0, function () {
|
||||||
var radar, e_1;
|
var radar, rawConf, config, e_1;
|
||||||
return __generator(this, function (_a) {
|
return __generator(this, function (_a) {
|
||||||
switch (_a.label) {
|
switch (_a.label) {
|
||||||
case 0:
|
case 0:
|
||||||
@@ -61,7 +60,9 @@ process.on("unhandledRejection", function (err) {
|
|||||||
radar = _a.sent();
|
radar = _a.sent();
|
||||||
fs_1.copyFileSync("build/index.html", "build/overview.html");
|
fs_1.copyFileSync("build/index.html", "build/overview.html");
|
||||||
fs_1.copyFileSync("build/index.html", "build/help-and-about-tech-radar.html");
|
fs_1.copyFileSync("build/index.html", "build/help-and-about-tech-radar.html");
|
||||||
config_1.quadrants.forEach(function (quadrant) {
|
rawConf = fs_1.readFileSync("build/config.json", "utf-8");
|
||||||
|
config = JSON.parse(rawConf);
|
||||||
|
Object.keys(config.quadrants).forEach(function (quadrant) {
|
||||||
var destFolder = "build/" + quadrant;
|
var destFolder = "build/" + quadrant;
|
||||||
fs_1.copyFileSync("build/index.html", destFolder + ".html");
|
fs_1.copyFileSync("build/index.html", destFolder + ".html");
|
||||||
if (!fs_1.existsSync(destFolder)) {
|
if (!fs_1.existsSync(destFolder)) {
|
||||||
|
|||||||
@@ -76,13 +76,14 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
exports.createRadar = void 0;
|
exports.createRadar = void 0;
|
||||||
var fs_extra_1 = require("fs-extra");
|
var fs_extra_1 = require("fs-extra");
|
||||||
|
var fs_1 = require("fs");
|
||||||
var path = __importStar(require("path"));
|
var path = __importStar(require("path"));
|
||||||
var front_matter_1 = __importDefault(require("front-matter"));
|
var front_matter_1 = __importDefault(require("front-matter"));
|
||||||
// @ts-ignore esModuleInterop is activated in tsconfig.scripts.json, but IDE typescript uses default typescript config
|
// @ts-ignore esModuleInterop is activated in tsconfig.scripts.json, but IDE typescript uses default typescript config
|
||||||
var marked_1 = __importDefault(require("marked"));
|
var marked_1 = __importDefault(require("marked"));
|
||||||
var highlight_js_1 = __importDefault(require("highlight.js"));
|
var highlight_js_1 = __importDefault(require("highlight.js"));
|
||||||
var file_1 = require("./file");
|
var file_1 = require("./file");
|
||||||
var config_1 = require("../../src/config");
|
var paths_1 = require("../paths");
|
||||||
marked_1.default.setOptions({
|
marked_1.default.setOptions({
|
||||||
highlight: function (code) { return highlight_js_1.default.highlightAuto(code).value; },
|
highlight: function (code) { return highlight_js_1.default.highlightAuto(code).value; },
|
||||||
});
|
});
|
||||||
@@ -108,11 +109,14 @@ var createRadar = function () { return __awaiter(void 0, void 0, void 0, functio
|
|||||||
}); };
|
}); };
|
||||||
exports.createRadar = createRadar;
|
exports.createRadar = createRadar;
|
||||||
var checkAttributes = function (fileName, attributes) {
|
var checkAttributes = function (fileName, attributes) {
|
||||||
if (attributes.ring && !config_1.rings.includes(attributes.ring)) {
|
var rawConf = fs_1.readFileSync(path.resolve(paths_1.appBuild, 'config.json'), 'utf-8');
|
||||||
throw new Error("Error: " + fileName + " has an illegal value for 'ring' - must be one of " + config_1.rings);
|
var config = JSON.parse(rawConf);
|
||||||
|
if (attributes.ring && !config.rings.includes(attributes.ring)) {
|
||||||
|
throw new Error("Error: " + fileName + " has an illegal value for 'ring' - must be one of " + config.rings);
|
||||||
}
|
}
|
||||||
if (attributes.quadrant && !config_1.quadrants.includes(attributes.quadrant)) {
|
var quadrants = Object.keys(config.quadrants);
|
||||||
throw new Error("Error: " + fileName + " has an illegal value for 'quadrant' - must be one of " + config_1.quadrants);
|
if (attributes.quadrant && !quadrants.includes(attributes.quadrant)) {
|
||||||
|
throw new Error("Error: " + fileName + " has an illegal value for 'quadrant' - must be one of " + quadrants);
|
||||||
}
|
}
|
||||||
return attributes;
|
return attributes;
|
||||||
};
|
};
|
||||||
@@ -120,19 +124,21 @@ var createRevisionsFromFiles = function (fileNames) {
|
|||||||
var publicUrl = process.env.PUBLIC_URL;
|
var publicUrl = process.env.PUBLIC_URL;
|
||||||
return Promise.all(fileNames.map(function (fileName) {
|
return Promise.all(fileNames.map(function (fileName) {
|
||||||
return new Promise(function (resolve, reject) {
|
return new Promise(function (resolve, reject) {
|
||||||
fs_extra_1.readFile(fileName, "utf8", function (err, data) {
|
fs_extra_1.readFile(fileName, "utf8", function (err, data) { return __awaiter(void 0, void 0, void 0, function () {
|
||||||
if (err) {
|
var fm, html;
|
||||||
reject(err);
|
return __generator(this, function (_a) {
|
||||||
}
|
if (err) {
|
||||||
else {
|
reject(err);
|
||||||
var fm = front_matter_1.default(data);
|
}
|
||||||
// add target attribute to external links
|
else {
|
||||||
// todo: check path
|
fm = front_matter_1.default(data);
|
||||||
var html = marked_1.default(fm.body.replace(/\]\(\//g, "](" + publicUrl + "/"));
|
html = marked_1.default(fm.body.replace(/\]\(\//g, "](" + publicUrl + "/"));
|
||||||
html = html.replace(/a href="http/g, 'a target="_blank" rel="noopener noreferrer" href="http');
|
html = html.replace(/a href="http/g, 'a target="_blank" rel="noopener noreferrer" href="http');
|
||||||
resolve(__assign(__assign(__assign({}, itemInfoFromFilename(fileName)), checkAttributes(fileName, fm.attributes)), { fileName: fileName, body: html }));
|
resolve(__assign(__assign(__assign({}, itemInfoFromFilename(fileName)), checkAttributes(fileName, fm.attributes)), { fileName: fileName, body: html }));
|
||||||
}
|
}
|
||||||
});
|
return [2 /*return*/];
|
||||||
|
});
|
||||||
|
}); });
|
||||||
});
|
});
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|||||||
9
public/config.json
Normal file
9
public/config.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"quadrants": {
|
||||||
|
"languages-and-frameworks": "Languages & Frameworks",
|
||||||
|
"methods-and-patterns": "Methods & Patterns",
|
||||||
|
"platforms-and-aoe-services": "Platforms & Operations",
|
||||||
|
"tools": "Tools"
|
||||||
|
},
|
||||||
|
"rings":["all", "adopt", "trial", "assess", "hold"]
|
||||||
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
import { copyFileSync, mkdirSync, existsSync } from "fs";
|
import { copyFileSync, mkdirSync, existsSync, readFileSync } from "fs";
|
||||||
import { createRadar } from "./generateJson/radar";
|
import { createRadar } from "./generateJson/radar";
|
||||||
import { quadrants } from "../src/config";
|
|
||||||
|
|
||||||
// Do this as the first thing so that any code reading it knows the right env.
|
// Do this as the first thing so that any code reading it knows the right env.
|
||||||
process.env.BABEL_ENV = "production";
|
process.env.BABEL_ENV = "production";
|
||||||
@@ -22,8 +21,9 @@ process.on("unhandledRejection", (err) => {
|
|||||||
|
|
||||||
copyFileSync("build/index.html", "build/overview.html");
|
copyFileSync("build/index.html", "build/overview.html");
|
||||||
copyFileSync("build/index.html", "build/help-and-about-tech-radar.html");
|
copyFileSync("build/index.html", "build/help-and-about-tech-radar.html");
|
||||||
|
const rawConf = readFileSync("build/config.json", "utf-8");
|
||||||
quadrants.forEach((quadrant) => {
|
const config = JSON.parse(rawConf);
|
||||||
|
Object.keys(config.quadrants).forEach((quadrant) => {
|
||||||
const destFolder = `build/${quadrant}`;
|
const destFolder = `build/${quadrant}`;
|
||||||
copyFileSync("build/index.html", `${destFolder}.html`);
|
copyFileSync("build/index.html", `${destFolder}.html`);
|
||||||
if (!existsSync(destFolder)) {
|
if (!existsSync(destFolder)) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { readFile } from "fs-extra";
|
import { readFile } from "fs-extra";
|
||||||
|
import { readFileSync } from "fs";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import frontMatter from "front-matter";
|
import frontMatter from "front-matter";
|
||||||
// @ts-ignore esModuleInterop is activated in tsconfig.scripts.json, but IDE typescript uses default typescript config
|
// @ts-ignore esModuleInterop is activated in tsconfig.scripts.json, but IDE typescript uses default typescript config
|
||||||
@@ -6,8 +7,8 @@ import marked from "marked";
|
|||||||
import highlight from "highlight.js";
|
import highlight from "highlight.js";
|
||||||
|
|
||||||
import { radarPath, getAllMarkdownFiles } from "./file";
|
import { radarPath, getAllMarkdownFiles } from "./file";
|
||||||
import { quadrants, rings } from "../../src/config";
|
|
||||||
import { Item, Revision, ItemAttributes, Radar } from "../../src/model";
|
import { Item, Revision, ItemAttributes, Radar } from "../../src/model";
|
||||||
|
import { appBuild } from "../paths";
|
||||||
|
|
||||||
type FMAttributes = ItemAttributes;
|
type FMAttributes = ItemAttributes;
|
||||||
|
|
||||||
@@ -29,12 +30,16 @@ export const createRadar = async (): Promise<Radar> => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const checkAttributes = (fileName: string, attributes: FMAttributes) => {
|
const checkAttributes = (fileName: string, attributes: FMAttributes) => {
|
||||||
if (attributes.ring && !rings.includes(attributes.ring)) {
|
const rawConf = readFileSync(path.resolve(appBuild, 'config.json'), 'utf-8');
|
||||||
|
const config = JSON.parse(rawConf);
|
||||||
|
|
||||||
|
if (attributes.ring && !config.rings.includes(attributes.ring)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Error: ${fileName} has an illegal value for 'ring' - must be one of ${rings}`
|
`Error: ${fileName} has an illegal value for 'ring' - must be one of ${config.rings}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const quadrants = Object.keys(config.quadrants);
|
||||||
if (attributes.quadrant && !quadrants.includes(attributes.quadrant)) {
|
if (attributes.quadrant && !quadrants.includes(attributes.quadrant)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Error: ${fileName} has an illegal value for 'quadrant' - must be one of ${quadrants}`
|
`Error: ${fileName} has an illegal value for 'quadrant' - must be one of ${quadrants}`
|
||||||
@@ -50,7 +55,7 @@ const createRevisionsFromFiles = (fileNames: string[]) => {
|
|||||||
fileNames.map(
|
fileNames.map(
|
||||||
(fileName) =>
|
(fileName) =>
|
||||||
new Promise<Revision>((resolve, reject) => {
|
new Promise<Revision>((resolve, reject) => {
|
||||||
readFile(fileName, "utf8", (err, data) => {
|
readFile(fileName, "utf8", async (err, data) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
reject(err);
|
reject(err);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from "react-router-dom";
|
} from "react-router-dom";
|
||||||
import { Item } from "../model";
|
import { Item } from "../model";
|
||||||
import { Messages, MessagesProvider } from "../context/MessagesContext";
|
import { Messages, MessagesProvider } from "../context/MessagesContext";
|
||||||
|
import { ConfigData } from "../config";
|
||||||
|
|
||||||
interface Params {
|
interface Params {
|
||||||
page: string;
|
page: string;
|
||||||
@@ -40,9 +41,11 @@ const useQuery = () => new URLSearchParams(useLocation().search);
|
|||||||
const RouterWithPageParam = ({
|
const RouterWithPageParam = ({
|
||||||
items,
|
items,
|
||||||
releases,
|
releases,
|
||||||
|
config,
|
||||||
}: {
|
}: {
|
||||||
items: Item[];
|
items: Item[];
|
||||||
releases: string[];
|
releases: string[];
|
||||||
|
config: ConfigData;
|
||||||
}) => {
|
}) => {
|
||||||
const { page } = useParams<Params>();
|
const { page } = useParams<Params>();
|
||||||
const query = useQuery();
|
const query = useQuery();
|
||||||
@@ -53,6 +56,7 @@ const RouterWithPageParam = ({
|
|||||||
search={query.get("search") || ""}
|
search={query.get("search") || ""}
|
||||||
items={items}
|
items={items}
|
||||||
releases={releases}
|
releases={releases}
|
||||||
|
config={config}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -79,8 +83,11 @@ export default function App() {
|
|||||||
const messages = useFetch<Messages>(
|
const messages = useFetch<Messages>(
|
||||||
`${process.env.PUBLIC_URL}/messages.json`
|
`${process.env.PUBLIC_URL}/messages.json`
|
||||||
);
|
);
|
||||||
|
const config = useFetch<ConfigData>(
|
||||||
|
`${process.env.PUBLIC_URL}/config.json`
|
||||||
|
);
|
||||||
|
|
||||||
if (data) {
|
if (data && config) {
|
||||||
const { items, releases } = data;
|
const { items, releases } = data;
|
||||||
return (
|
return (
|
||||||
<MessagesProvider messages={messages}>
|
<MessagesProvider messages={messages}>
|
||||||
@@ -93,7 +100,7 @@ export default function App() {
|
|||||||
<HeaderWithPageParam />
|
<HeaderWithPageParam />
|
||||||
</div>
|
</div>
|
||||||
<div className={classNames("page__content")}>
|
<div className={classNames("page__content")}>
|
||||||
<RouterWithPageParam items={items} releases={releases} />
|
<RouterWithPageParam config={config} items={items} releases={releases} />
|
||||||
</div>
|
</div>
|
||||||
<div className="page__footer">
|
<div className="page__footer">
|
||||||
<FooterWithPageParam items={items} />
|
<FooterWithPageParam items={items} />
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import React, { MouseEventHandler } from "react";
|
import React, { MouseEventHandler } from "react";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import "./badge.scss";
|
import "./badge.scss";
|
||||||
import { Ring } from "../../config";
|
|
||||||
type BadgeProps = {
|
type BadgeProps = {
|
||||||
onClick?: MouseEventHandler;
|
onClick?: MouseEventHandler;
|
||||||
big?: boolean;
|
big?: boolean;
|
||||||
type: "big" | "all" | "empty" | Ring;
|
type: "big" | "all" | "empty" | string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Badge({
|
export default function Badge({
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import React from "react";
|
|
||||||
import { formatRelease } from "../../date";
|
import { formatRelease } from "../../date";
|
||||||
import { featuredOnly, Item } from "../../model";
|
import { featuredOnly, Item } from "../../model";
|
||||||
import HeroHeadline from "../HeroHeadline/HeroHeadline";
|
import HeroHeadline from "../HeroHeadline/HeroHeadline";
|
||||||
import QuadrantGrid from "../QuadrantGrid/QuadrantGrid";
|
import QuadrantGrid from "../QuadrantGrid/QuadrantGrid";
|
||||||
import Fadeable from "../Fadeable/Fadeable";
|
import Fadeable from "../Fadeable/Fadeable";
|
||||||
import SetTitle from "../SetTitle";
|
import SetTitle from "../SetTitle";
|
||||||
import { radarName, radarNameShort } from "../../config";
|
import { ConfigData, radarName, radarNameShort } from "../../config";
|
||||||
import { MomentInput } from "moment";
|
import { MomentInput } from "moment";
|
||||||
import { useMessages } from "../../context/MessagesContext";
|
import { useMessages } from "../../context/MessagesContext";
|
||||||
|
|
||||||
@@ -13,6 +12,7 @@ type PageIndexProps = {
|
|||||||
leaving: boolean;
|
leaving: boolean;
|
||||||
onLeave: () => void;
|
onLeave: () => void;
|
||||||
items: Item[];
|
items: Item[];
|
||||||
|
config: ConfigData;
|
||||||
releases: MomentInput[];
|
releases: MomentInput[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -20,6 +20,7 @@ export default function PageIndex({
|
|||||||
leaving,
|
leaving,
|
||||||
onLeave,
|
onLeave,
|
||||||
items,
|
items,
|
||||||
|
config,
|
||||||
releases,
|
releases,
|
||||||
}: PageIndexProps) {
|
}: PageIndexProps) {
|
||||||
const { pageIndex } = useMessages();
|
const { pageIndex } = useMessages();
|
||||||
@@ -35,7 +36,7 @@ export default function PageIndex({
|
|||||||
{radarName}
|
{radarName}
|
||||||
</HeroHeadline>
|
</HeroHeadline>
|
||||||
</div>
|
</div>
|
||||||
<QuadrantGrid items={featuredOnly(items)} />
|
<QuadrantGrid items={featuredOnly(items)} config={config} />
|
||||||
<div className="publish-date">
|
<div className="publish-date">
|
||||||
{publishedLabel} {formatRelease(newestRelease)}
|
{publishedLabel} {formatRelease(newestRelease)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import SetTitle from "../SetTitle";
|
|||||||
import ItemRevisions from "../ItemRevisions/ItemRevisions";
|
import ItemRevisions from "../ItemRevisions/ItemRevisions";
|
||||||
import { useAnimations } from "./useAnimations";
|
import { useAnimations } from "./useAnimations";
|
||||||
import "./item-page.scss";
|
import "./item-page.scss";
|
||||||
import { translate } from "../../config";
|
import { ConfigData, translate } from "../../config";
|
||||||
import {
|
import {
|
||||||
groupByQuadrants,
|
groupByQuadrants,
|
||||||
Item,
|
Item,
|
||||||
@@ -29,11 +29,12 @@ const getItemsInRing = (pageName: string, items: Item[]) => {
|
|||||||
type Props = {
|
type Props = {
|
||||||
pageName: string;
|
pageName: string;
|
||||||
items: Item[];
|
items: Item[];
|
||||||
|
config: ConfigData;
|
||||||
leaving: boolean;
|
leaving: boolean;
|
||||||
onLeave: () => void;
|
onLeave: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const PageItem: React.FC<Props> = ({ pageName, items, leaving, onLeave }) => {
|
const PageItem: React.FC<Props> = ({ pageName, items, config, leaving, onLeave }) => {
|
||||||
const { pageItem } = useMessages();
|
const { pageItem } = useMessages();
|
||||||
const quadrantOverview = pageItem?.quadrantOverview || 'Quadrant Overview';
|
const quadrantOverview = pageItem?.quadrantOverview || 'Quadrant Overview';
|
||||||
|
|
||||||
@@ -57,7 +58,7 @@ const PageItem: React.FC<Props> = ({ pageName, items, leaving, onLeave }) => {
|
|||||||
className="item-page__header"
|
className="item-page__header"
|
||||||
style={getAnimationState("navHeader")}
|
style={getAnimationState("navHeader")}
|
||||||
>
|
>
|
||||||
<h3 className="headline">{translate(item.quadrant)}</h3>
|
<h3 className="headline">{translate(config, item.quadrant)}</h3>
|
||||||
</div>
|
</div>
|
||||||
<ItemList
|
<ItemList
|
||||||
items={itemsInRing}
|
items={itemsInRing}
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ import Fadeable from "../Fadeable/Fadeable";
|
|||||||
import SetTitle from "../SetTitle";
|
import SetTitle from "../SetTitle";
|
||||||
import ItemRevisions from "../ItemRevisions/ItemRevisions";
|
import ItemRevisions from "../ItemRevisions/ItemRevisions";
|
||||||
|
|
||||||
import { translate } from "../../config";
|
import { ConfigData, translate } from "../../config";
|
||||||
import { groupByQuadrants, Item } from "../../model";
|
import { groupByQuadrants, Item } from "../../model";
|
||||||
|
|
||||||
type PageItemMobileProps = {
|
type PageItemMobileProps = {
|
||||||
pageName: string;
|
pageName: string;
|
||||||
items: Item[];
|
items: Item[];
|
||||||
|
config: ConfigData;
|
||||||
leaving: boolean;
|
leaving: boolean;
|
||||||
onLeave: () => void;
|
onLeave: () => void;
|
||||||
};
|
};
|
||||||
@@ -19,6 +20,7 @@ type PageItemMobileProps = {
|
|||||||
export default function PageItemMobile({
|
export default function PageItemMobile({
|
||||||
pageName,
|
pageName,
|
||||||
items,
|
items,
|
||||||
|
config,
|
||||||
leaving,
|
leaving,
|
||||||
onLeave,
|
onLeave,
|
||||||
}: PageItemMobileProps) {
|
}: PageItemMobileProps) {
|
||||||
@@ -47,7 +49,7 @@ export default function PageItemMobile({
|
|||||||
<div className="mobile-item-page__header">
|
<div className="mobile-item-page__header">
|
||||||
<div className="split">
|
<div className="split">
|
||||||
<div className="split__left">
|
<div className="split__left">
|
||||||
<h3 className="headline">{translate(item.quadrant)}</h3>
|
<h3 className="headline">{translate(config, item.quadrant)}</h3>
|
||||||
<h1 className="hero-headline hero-headline--inverse">
|
<h1 className="hero-headline hero-headline--inverse">
|
||||||
{item.title}
|
{item.title}
|
||||||
</h1>
|
</h1>
|
||||||
@@ -73,7 +75,7 @@ export default function PageItemMobile({
|
|||||||
<ItemList items={itemsInRing} activeItem={item}>
|
<ItemList items={itemsInRing} activeItem={item}>
|
||||||
<div className="split">
|
<div className="split">
|
||||||
<div className="split__left">
|
<div className="split__left">
|
||||||
<h3 className="headline">{translate(item.quadrant)}</h3>
|
<h3 className="headline">{translate(config, item.quadrant)}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="split__right">
|
<div className="split__right">
|
||||||
<Link className="icon-link" pageName={item.quadrant}>
|
<Link className="icon-link" pageName={item.quadrant}>
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import Fadeable from "../Fadeable/Fadeable";
|
|||||||
import SetTitle from "../SetTitle";
|
import SetTitle from "../SetTitle";
|
||||||
import Flag from "../Flag/Flag";
|
import Flag from "../Flag/Flag";
|
||||||
import { groupByFirstLetter, Item } from "../../model";
|
import { groupByFirstLetter, Item } from "../../model";
|
||||||
|
import { ConfigData, translate } from "../../config";
|
||||||
import { useMessages } from "../../context/MessagesContext";
|
import { useMessages } from "../../context/MessagesContext";
|
||||||
import { translate, Ring } from "../../config";
|
|
||||||
|
|
||||||
const containsSearchTerm = (text = "", term = "") => {
|
const containsSearchTerm = (text = "", term = "") => {
|
||||||
// TODO search refinement
|
// TODO search refinement
|
||||||
@@ -20,9 +20,10 @@ const containsSearchTerm = (text = "", term = "") => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type PageOverviewProps = {
|
type PageOverviewProps = {
|
||||||
rings: readonly ("all" | Ring)[];
|
rings: readonly ("all" | string)[];
|
||||||
search: string;
|
search: string;
|
||||||
items: Item[];
|
items: Item[];
|
||||||
|
config: ConfigData;
|
||||||
leaving: boolean;
|
leaving: boolean;
|
||||||
onLeave: () => void;
|
onLeave: () => void;
|
||||||
};
|
};
|
||||||
@@ -31,10 +32,11 @@ export default function PageOverview({
|
|||||||
rings,
|
rings,
|
||||||
search: searchProp,
|
search: searchProp,
|
||||||
items,
|
items,
|
||||||
|
config,
|
||||||
leaving,
|
leaving,
|
||||||
onLeave,
|
onLeave,
|
||||||
}: PageOverviewProps) {
|
}: PageOverviewProps) {
|
||||||
const [ring, setRing] = useState<Ring | "all">("all");
|
const [ring, setRing] = useState<string | "all">("all");
|
||||||
const [search, setSearch] = useState(searchProp);
|
const [search, setSearch] = useState(searchProp);
|
||||||
const { pageOverview } = useMessages();
|
const { pageOverview } = useMessages();
|
||||||
const title = pageOverview?.title || 'Technologies Overview';
|
const title = pageOverview?.title || 'Technologies Overview';
|
||||||
@@ -46,7 +48,7 @@ export default function PageOverview({
|
|||||||
setSearch(searchProp);
|
setSearch(searchProp);
|
||||||
}, [rings, searchProp]);
|
}, [rings, searchProp]);
|
||||||
|
|
||||||
const handleRingClick = (ring: Ring) => () => {
|
const handleRingClick = (ring: string) => () => {
|
||||||
setRing(ring);
|
setRing(ring);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -134,7 +136,7 @@ export default function PageOverview({
|
|||||||
<div className="split__right">
|
<div className="split__right">
|
||||||
<div className="nav nav--relations">
|
<div className="nav nav--relations">
|
||||||
<div className="nav__item">
|
<div className="nav__item">
|
||||||
{translate(item.quadrant)}
|
{translate(config, item.quadrant)}
|
||||||
</div>
|
</div>
|
||||||
<div className="nav__item">
|
<div className="nav__item">
|
||||||
<Badge type={item.ring}>{item.ring}</Badge>
|
<Badge type={item.ring}>{item.ring}</Badge>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import QuadrantSection from "../QuadrantSection/QuadrantSection";
|
|||||||
import Fadeable from "../Fadeable/Fadeable";
|
import Fadeable from "../Fadeable/Fadeable";
|
||||||
import SetTitle from "../SetTitle";
|
import SetTitle from "../SetTitle";
|
||||||
|
|
||||||
import { translate } from "../../config";
|
import { ConfigData, translate } from "../../config";
|
||||||
import { featuredOnly, groupByQuadrants, Item } from "../../model";
|
import { featuredOnly, groupByQuadrants, Item } from "../../model";
|
||||||
|
|
||||||
type PageQuadrantProps = {
|
type PageQuadrantProps = {
|
||||||
@@ -13,6 +13,7 @@ type PageQuadrantProps = {
|
|||||||
onLeave: () => void;
|
onLeave: () => void;
|
||||||
pageName: string;
|
pageName: string;
|
||||||
items: Item[];
|
items: Item[];
|
||||||
|
config: ConfigData;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PageQuadrant({
|
export default function PageQuadrant({
|
||||||
@@ -20,15 +21,16 @@ export default function PageQuadrant({
|
|||||||
onLeave,
|
onLeave,
|
||||||
pageName,
|
pageName,
|
||||||
items,
|
items,
|
||||||
|
config,
|
||||||
}: PageQuadrantProps) {
|
}: PageQuadrantProps) {
|
||||||
const groups = groupByQuadrants(featuredOnly(items));
|
const groups = groupByQuadrants(featuredOnly(items));
|
||||||
return (
|
return (
|
||||||
<Fadeable leaving={leaving} onLeave={onLeave}>
|
<Fadeable leaving={leaving} onLeave={onLeave}>
|
||||||
<SetTitle title={translate(pageName)} />
|
<SetTitle title={translate(config, pageName)} />
|
||||||
<HeadlineGroup>
|
<HeadlineGroup>
|
||||||
<HeroHeadline>{translate(pageName)}</HeroHeadline>
|
<HeroHeadline>{translate(config, pageName)}</HeroHeadline>
|
||||||
</HeadlineGroup>
|
</HeadlineGroup>
|
||||||
<QuadrantSection groups={groups} quadrantName={pageName} big />
|
<QuadrantSection groups={groups} quadrantName={pageName} config={config} big />
|
||||||
</Fadeable>
|
</Fadeable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { groupByQuadrants, Item, Group } from "../../model";
|
import { groupByQuadrants, Item, Group } from "../../model";
|
||||||
import { quadrants } from "../../config";
|
import { ConfigData } from "../../config";
|
||||||
import QuadrantSection from "../QuadrantSection/QuadrantSection";
|
import QuadrantSection from "../QuadrantSection/QuadrantSection";
|
||||||
import "./quadrant-grid.scss";
|
import "./quadrant-grid.scss";
|
||||||
const renderQuadrant = (quadrantName: string, groups: Group) => {
|
const renderQuadrant = (quadrantName: string, groups: Group, config: ConfigData) => {
|
||||||
return (
|
return (
|
||||||
<div key={quadrantName} className="quadrant-grid__quadrant">
|
<div key={quadrantName} className="quadrant-grid__quadrant">
|
||||||
<QuadrantSection quadrantName={quadrantName} groups={groups} />
|
<QuadrantSection quadrantName={quadrantName} groups={groups} config={config} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function QuadrantGrid({ items }: { items: Item[] }) {
|
export default function QuadrantGrid({ items, config }: { items: Item[], config: ConfigData }) {
|
||||||
const groups = groupByQuadrants(items);
|
const groups = groupByQuadrants(items);
|
||||||
return (
|
return (
|
||||||
<div className="quadrant-grid">
|
<div className="quadrant-grid">
|
||||||
{quadrants.map((quadrantName) => renderQuadrant(quadrantName, groups))}
|
{Object.keys(config.quadrants).map((quadrantName: string) => renderQuadrant(quadrantName, groups, config))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import React from "react";
|
import { translate, showEmptyRings, ConfigData } from "../../config";
|
||||||
import { translate, rings, Ring, showEmptyRings } from "../../config";
|
|
||||||
import Badge from "../Badge/Badge";
|
import Badge from "../Badge/Badge";
|
||||||
import Link from "../Link/Link";
|
import Link from "../Link/Link";
|
||||||
import ItemList from "../ItemList/ItemList";
|
import ItemList from "../ItemList/ItemList";
|
||||||
@@ -7,7 +6,7 @@ import Flag from "../Flag/Flag";
|
|||||||
import { Group } from "../../model";
|
import { Group } from "../../model";
|
||||||
import "./quadrant-section.scss";
|
import "./quadrant-section.scss";
|
||||||
const renderList = (
|
const renderList = (
|
||||||
ringName: Ring,
|
ringName: string,
|
||||||
quadrantName: string,
|
quadrantName: string,
|
||||||
groups: Group,
|
groups: Group,
|
||||||
big: boolean
|
big: boolean
|
||||||
@@ -42,7 +41,7 @@ const renderList = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderRing = (
|
const renderRing = (
|
||||||
ringName: Ring,
|
ringName: string,
|
||||||
quadrantName: string,
|
quadrantName: string,
|
||||||
groups: Group,
|
groups: Group,
|
||||||
big: boolean
|
big: boolean
|
||||||
@@ -65,10 +64,12 @@ const renderRing = (
|
|||||||
export default function QuadrantSection({
|
export default function QuadrantSection({
|
||||||
quadrantName,
|
quadrantName,
|
||||||
groups,
|
groups,
|
||||||
|
config,
|
||||||
big = false,
|
big = false,
|
||||||
}: {
|
}: {
|
||||||
quadrantName: string;
|
quadrantName: string;
|
||||||
groups: Group;
|
groups: Group;
|
||||||
|
config: ConfigData;
|
||||||
big?: boolean;
|
big?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -76,7 +77,7 @@ export default function QuadrantSection({
|
|||||||
<div className="quadrant-section__header">
|
<div className="quadrant-section__header">
|
||||||
<div className="split">
|
<div className="split">
|
||||||
<div className="split__left">
|
<div className="split__left">
|
||||||
<h4 className="headline">{translate(quadrantName)}</h4>
|
<h4 className="headline">{translate(config, quadrantName)}</h4>
|
||||||
</div>
|
</div>
|
||||||
{!big && (
|
{!big && (
|
||||||
<div className="split__right">
|
<div className="split__right">
|
||||||
@@ -89,7 +90,7 @@ export default function QuadrantSection({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="quadrant-section__rings">
|
<div className="quadrant-section__rings">
|
||||||
{rings.map((ringName) =>
|
{config.rings.map((ringName: string) =>
|
||||||
renderRing(ringName, quadrantName, groups, big)
|
renderRing(ringName, quadrantName, groups, big)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import PageIndex from "./PageIndex/PageIndex";
|
import PageIndex from "./PageIndex/PageIndex";
|
||||||
import PageOverview from "./PageOverview/PageOverview";
|
import PageOverview from "./PageOverview/PageOverview";
|
||||||
import PageHelp from "./PageHelp/PageHelp";
|
import PageHelp from "./PageHelp/PageHelp";
|
||||||
@@ -6,10 +6,9 @@ import PageQuadrant from "./PageQuadrant/PageQuadrant";
|
|||||||
import PageItem from "./PageItem/PageItem";
|
import PageItem from "./PageItem/PageItem";
|
||||||
import PageItemMobile from "./PageItemMobile/PageItemMobile";
|
import PageItemMobile from "./PageItemMobile/PageItemMobile";
|
||||||
import {
|
import {
|
||||||
quadrants,
|
ConfigData,
|
||||||
getItemPageNames,
|
getItemPageNames,
|
||||||
isMobileViewport,
|
isMobileViewport,
|
||||||
rings,
|
|
||||||
} from "../config";
|
} from "../config";
|
||||||
import { Item } from "../model";
|
import { Item } from "../model";
|
||||||
|
|
||||||
@@ -18,6 +17,7 @@ type RouterProps = {
|
|||||||
items: Item[];
|
items: Item[];
|
||||||
releases: string[];
|
releases: string[];
|
||||||
search: string;
|
search: string;
|
||||||
|
config: ConfigData;
|
||||||
};
|
};
|
||||||
|
|
||||||
enum page {
|
enum page {
|
||||||
@@ -30,7 +30,7 @@ enum page {
|
|||||||
notFound,
|
notFound,
|
||||||
}
|
}
|
||||||
|
|
||||||
const getPageByName = (items: Item[], pageName: string): page => {
|
const getPageByName = (items: Item[], pageName: string, config: ConfigData): page => {
|
||||||
if (pageName === "index") {
|
if (pageName === "index") {
|
||||||
return page.index;
|
return page.index;
|
||||||
}
|
}
|
||||||
@@ -40,7 +40,7 @@ const getPageByName = (items: Item[], pageName: string): page => {
|
|||||||
if (pageName === "help-and-about-tech-radar") {
|
if (pageName === "help-and-about-tech-radar") {
|
||||||
return page.help;
|
return page.help;
|
||||||
}
|
}
|
||||||
if (quadrants.includes(pageName)) {
|
if (Object.keys(config.quadrants).includes(pageName)) {
|
||||||
return page.quadrant;
|
return page.quadrant;
|
||||||
}
|
}
|
||||||
if (getItemPageNames(items).includes(pageName)) {
|
if (getItemPageNames(items).includes(pageName)) {
|
||||||
@@ -55,6 +55,7 @@ export default function Router({
|
|||||||
items,
|
items,
|
||||||
releases,
|
releases,
|
||||||
search,
|
search,
|
||||||
|
config
|
||||||
}: RouterProps) {
|
}: RouterProps) {
|
||||||
const [statePageName, setStatePageName] = useState(pageName);
|
const [statePageName, setStatePageName] = useState(pageName);
|
||||||
const [leaving, setLeaving] = useState(false);
|
const [leaving, setLeaving] = useState(false);
|
||||||
@@ -62,14 +63,14 @@ export default function Router({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const nowLeaving =
|
const nowLeaving =
|
||||||
getPageByName(items, pageName) !== getPageByName(items, statePageName);
|
getPageByName(items, pageName, config) !== getPageByName(items, statePageName, config);
|
||||||
if (nowLeaving) {
|
if (nowLeaving) {
|
||||||
setLeaving(true);
|
setLeaving(true);
|
||||||
setNextPageName(pageName);
|
setNextPageName(pageName);
|
||||||
} else {
|
} else {
|
||||||
setStatePageName(pageName);
|
setStatePageName(pageName);
|
||||||
}
|
}
|
||||||
}, [pageName, items, statePageName]);
|
}, [pageName, items, config, statePageName]);
|
||||||
|
|
||||||
const handlePageLeave = () => {
|
const handlePageLeave = () => {
|
||||||
setStatePageName(nextPageName);
|
setStatePageName(nextPageName);
|
||||||
@@ -82,12 +83,13 @@ export default function Router({
|
|||||||
}, 0);
|
}, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
switch (getPageByName(items, statePageName)) {
|
switch (getPageByName(items, statePageName, config)) {
|
||||||
case page.index:
|
case page.index:
|
||||||
return (
|
return (
|
||||||
<PageIndex
|
<PageIndex
|
||||||
leaving={leaving}
|
leaving={leaving}
|
||||||
items={items}
|
items={items}
|
||||||
|
config={config}
|
||||||
onLeave={handlePageLeave}
|
onLeave={handlePageLeave}
|
||||||
releases={releases}
|
releases={releases}
|
||||||
/>
|
/>
|
||||||
@@ -96,7 +98,8 @@ export default function Router({
|
|||||||
return (
|
return (
|
||||||
<PageOverview
|
<PageOverview
|
||||||
items={items}
|
items={items}
|
||||||
rings={rings}
|
config={config}
|
||||||
|
rings={config.rings}
|
||||||
search={search}
|
search={search}
|
||||||
leaving={leaving}
|
leaving={leaving}
|
||||||
onLeave={handlePageLeave}
|
onLeave={handlePageLeave}
|
||||||
@@ -110,6 +113,7 @@ export default function Router({
|
|||||||
leaving={leaving}
|
leaving={leaving}
|
||||||
onLeave={handlePageLeave}
|
onLeave={handlePageLeave}
|
||||||
items={items}
|
items={items}
|
||||||
|
config={config}
|
||||||
pageName={statePageName}
|
pageName={statePageName}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -117,6 +121,7 @@ export default function Router({
|
|||||||
return (
|
return (
|
||||||
<PageItemMobile
|
<PageItemMobile
|
||||||
items={items}
|
items={items}
|
||||||
|
config={config}
|
||||||
pageName={statePageName}
|
pageName={statePageName}
|
||||||
leaving={leaving}
|
leaving={leaving}
|
||||||
onLeave={handlePageLeave}
|
onLeave={handlePageLeave}
|
||||||
@@ -126,6 +131,7 @@ export default function Router({
|
|||||||
return (
|
return (
|
||||||
<PageItem
|
<PageItem
|
||||||
items={items}
|
items={items}
|
||||||
|
config={config}
|
||||||
pageName={statePageName}
|
pageName={statePageName}
|
||||||
leaving={leaving}
|
leaving={leaving}
|
||||||
onLeave={handlePageLeave}
|
onLeave={handlePageLeave}
|
||||||
|
|||||||
@@ -1,34 +1,19 @@
|
|||||||
import { Item } from "./model";
|
import { Item } from "./model";
|
||||||
|
|
||||||
|
export interface ConfigData {
|
||||||
|
quadrants: { [key: string]: string };
|
||||||
|
rings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export const radarName =
|
export const radarName =
|
||||||
process.env.REACT_APP_RADAR_NAME || "AOE Technology Radar";
|
process.env.REACT_APP_RADAR_NAME || "AOE Technology Radar";
|
||||||
export const radarNameShort = radarName;
|
export const radarNameShort = radarName;
|
||||||
|
|
||||||
export const quadrants = [
|
|
||||||
"languages-and-frameworks",
|
|
||||||
"methods-and-patterns",
|
|
||||||
"platforms-and-aoe-services",
|
|
||||||
"tools",
|
|
||||||
];
|
|
||||||
|
|
||||||
export const rings = ["all", "adopt", "trial", "assess", "hold"] as const;
|
|
||||||
|
|
||||||
export type Ring = typeof rings[number];
|
|
||||||
|
|
||||||
export const getItemPageNames = (items: Item[]) =>
|
export const getItemPageNames = (items: Item[]) =>
|
||||||
items.map((item) => `${item.quadrant}/${item.name}`);
|
items.map((item) => `${item.quadrant}/${item.name}`);
|
||||||
|
|
||||||
export const showEmptyRings = false;
|
export const showEmptyRings = false;
|
||||||
|
|
||||||
const messages: { [k: string]: string } = {
|
|
||||||
"languages-and-frameworks": "Languages & Frameworks",
|
|
||||||
"methods-and-patterns": "Methods & Patterns",
|
|
||||||
"platforms-and-aoe-services": "Platforms & Operations",
|
|
||||||
tools: "Tools",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const translate = (key: string) => messages[key] || "-";
|
|
||||||
|
|
||||||
export function isMobileViewport() {
|
export function isMobileViewport() {
|
||||||
// return false for server side rendering
|
// return false for server side rendering
|
||||||
if (typeof window == "undefined") return false;
|
if (typeof window == "undefined") return false;
|
||||||
@@ -43,3 +28,7 @@ export function isMobileViewport() {
|
|||||||
export function assetUrl(file: string) {
|
export function assetUrl(file: string) {
|
||||||
return process.env.PUBLIC_URL + "/" + file;
|
return process.env.PUBLIC_URL + "/" + file;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function translate(config: ConfigData, key: string) {
|
||||||
|
return config.quadrants[key] || "-";
|
||||||
|
}
|
||||||
@@ -6,11 +6,6 @@ interface Quadrant {
|
|||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Ring {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Paragraph {
|
interface Paragraph {
|
||||||
headline: string;
|
headline: string;
|
||||||
values: string[];
|
values: string[];
|
||||||
@@ -20,7 +15,7 @@ interface PageHelp {
|
|||||||
paragraphs: Paragraph[];
|
paragraphs: Paragraph[];
|
||||||
quadrantsPreDescription?: string;
|
quadrantsPreDescription?: string;
|
||||||
quadrants: Quadrant[];
|
quadrants: Quadrant[];
|
||||||
rings: Ring[];
|
rings: {name: string, description: string }[];
|
||||||
ringsPreDescription?: string;
|
ringsPreDescription?: string;
|
||||||
sourcecodeLink?: {
|
sourcecodeLink?: {
|
||||||
href: string;
|
href: string;
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { Ring } from "./config";
|
|
||||||
|
|
||||||
export type ItemAttributes = {
|
export type ItemAttributes = {
|
||||||
name: string;
|
name: string;
|
||||||
ring: Ring;
|
ring: string;
|
||||||
quadrant: string;
|
quadrant: string;
|
||||||
title: string;
|
title: string;
|
||||||
featured?: boolean;
|
featured?: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user