diff --git a/directory/web/.dockerignore b/directory/web/.dockerignore
new file mode 100644
index 000000000..77fa549de
--- /dev/null
+++ b/directory/web/.dockerignore
@@ -0,0 +1,5 @@
+**/Dockerfile
+**/.dockerignore
+**/.gitignore
+**/azure-pipelines.yaml
+**/node_modules
\ No newline at end of file
diff --git a/directory/web/.env b/directory/web/.env
new file mode 100644
index 000000000..2d2e9a0cd
--- /dev/null
+++ b/directory/web/.env
@@ -0,0 +1 @@
+INLINE_RUNTIME_CHUNK=false
\ No newline at end of file
diff --git a/directory/web/.gitignore b/directory/web/.gitignore
new file mode 100644
index 000000000..821ef96d4
--- /dev/null
+++ b/directory/web/.gitignore
@@ -0,0 +1,25 @@
+# https://raw.githubusercontent.com/facebook/create-react-app/master/packages/cra-template/template/gitignore
+
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.js
+
+# testing
+/coverage
+
+# production
+/build
+
+# misc
+.DS_Store
+.env.local
+.env.development.local
+.env.test.local
+.env.production.local
+
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
diff --git a/directory/web/Dockerfile b/directory/web/Dockerfile
new file mode 100644
index 000000000..6c9f46bbd
--- /dev/null
+++ b/directory/web/Dockerfile
@@ -0,0 +1,26 @@
+# init base
+FROM nginx:alpine as base
+COPY reverse-proxy/conf.d /etc/nginx/conf.d
+
+# init install
+FROM node:alpine as install
+
+# https://github.com/nodejs/docker-node/blob/master/docs/BestPractices.md#node-gyp-alpine
+RUN apk add --no-cache python make g++
+
+# install
+WORKDIR /usr/src/app/
+COPY package*.json ./
+RUN npm ci --only=production
+
+# init build
+FROM node:alpine as build
+COPY --from=install node_modules .
+
+# build
+COPY . .
+RUN npm run build
+
+# final
+FROM base as final
+COPY --from=build /usr/src/app/build /usr/share/nginx/html
\ No newline at end of file
diff --git a/directory/web/config-overrides.js b/directory/web/config-overrides.js
new file mode 100644
index 000000000..b2a41e367
--- /dev/null
+++ b/directory/web/config-overrides.js
@@ -0,0 +1,9 @@
+const { override, fixBabelImports } = require("customize-cra");
+
+module.exports = override(
+ fixBabelImports("import", {
+ libraryName: "antd",
+ libraryDirectory: "es",
+ style: "css",
+ })
+);
diff --git a/directory/web/package-lock.json b/directory/web/package-lock.json
new file mode 100644
index 000000000..f4b640e7d
Binary files /dev/null and b/directory/web/package-lock.json differ
diff --git a/directory/web/package.json b/directory/web/package.json
new file mode 100644
index 000000000..0f518f8a7
--- /dev/null
+++ b/directory/web/package.json
@@ -0,0 +1,68 @@
+{
+ "name": "filterlists.web",
+ "version": "0.1.0",
+ "private": true,
+ "dependencies": {
+ "@testing-library/jest-dom": "^5.11.4",
+ "@testing-library/react": "^10.4.9",
+ "@testing-library/user-event": "^12.1.2",
+ "@types/jest": "^26.0.10",
+ "@types/node": "^14.6.0",
+ "@types/react": "^16.9.46",
+ "@types/react-dom": "^16.9.8",
+ "@types/react-router-dom": "^5.1.5",
+ "antd": "^4.6.1",
+ "babel-plugin-import": "^1.13.0",
+ "customize-cra": "^1.0.0",
+ "react": "^16.13.1",
+ "react-app-rewired": "^2.1.6",
+ "react-dom": "^16.13.1",
+ "react-router-dom": "^5.2.0",
+ "react-scripts": "^3.4.3",
+ "slugify": "^1.4.5",
+ "typescript": "^4.0.2"
+ },
+ "scripts": {
+ "start": "react-app-rewired start",
+ "build": "react-app-rewired build",
+ "test": "react-app-rewired test",
+ "eject": "react-scripts eject"
+ },
+ "proxy": "https://filterlists.com",
+ "eslintConfig": {
+ "extends": [
+ "react-app",
+ "plugin:prettier/recommended"
+ ]
+ },
+ "husky": {
+ "hooks": {
+ "pre-commit": "lint-staged"
+ }
+ },
+ "lint-staged": {
+ "**/*.{json,js,jsx,ts,tsx,css,scss,yaml,md}": [
+ "prettier --write",
+ "git add"
+ ]
+ },
+ "browserslist": {
+ "production": [
+ ">0.2%",
+ "not dead",
+ "not op_mini all"
+ ],
+ "development": [
+ "last 1 chrome version",
+ "last 1 firefox version",
+ "last 1 safari version"
+ ]
+ },
+ "devDependencies": {
+ "eslint-config-prettier": "^6.11.0",
+ "eslint-plugin-prettier": "^3.1.4",
+ "husky": "^4.2.5",
+ "lint-staged": "^10.2.11",
+ "prettier": "^2.0.5"
+ }
+}
diff --git a/directory/web/public/icon_filterlists.png b/directory/web/public/icon_filterlists.png
new file mode 100644
index 000000000..99c15bdde
Binary files /dev/null and b/directory/web/public/icon_filterlists.png differ
diff --git a/directory/web/public/index.html b/directory/web/public/index.html
new file mode 100644
index 000000000..c59925ee6
--- /dev/null
+++ b/directory/web/public/index.html
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+ FilterLists | Subscriptions for uBlock Origin, Adblock Plus, AdGuard, ...
+
+
+
+
+
+
+
+
+
+
diff --git a/directory/web/public/logo_filterlists.png b/directory/web/public/logo_filterlists.png
new file mode 100644
index 000000000..7d8d18170
Binary files /dev/null and b/directory/web/public/logo_filterlists.png differ
diff --git a/directory/web/public/manifest.json b/directory/web/public/manifest.json
new file mode 100644
index 000000000..452af93c5
--- /dev/null
+++ b/directory/web/public/manifest.json
@@ -0,0 +1,15 @@
+{
+ "short_name": "FilterLists",
+ "name": "FilterLists | Subscriptions for uBlock Origin, Adblock Plus, AdGuard, ...",
+ "icons": [
+ {
+ "src": "icon_filterlists.png",
+ "sizes": "64x64 32x32 24x24 16x16",
+ "type": "image/png"
+ }
+ ],
+ "start_url": ".",
+ "display": "standalone",
+ "theme_color": "#000000",
+ "background_color": "#ffffff"
+}
diff --git a/directory/web/public/robots.txt b/directory/web/public/robots.txt
new file mode 100644
index 000000000..e9e57dc4d
--- /dev/null
+++ b/directory/web/public/robots.txt
@@ -0,0 +1,3 @@
+# https://www.robotstxt.org/robotstxt.html
+User-agent: *
+Disallow:
diff --git a/directory/web/reverse-proxy/conf.d/default.conf b/directory/web/reverse-proxy/conf.d/default.conf
new file mode 100644
index 000000000..173de90d8
--- /dev/null
+++ b/directory/web/reverse-proxy/conf.d/default.conf
@@ -0,0 +1,17 @@
+server {
+ listen 80;
+ server_name localhost;
+
+ location / {
+ root /usr/share/nginx/html;
+ index index.html index.htm;
+ try_files $uri /index.html;
+ }
+
+ # redirect server error pages to the static page /50x.html
+ #
+ error_page 500 502 503 504 /50x.html;
+ location = /50x.html {
+ root /usr/share/nginx/html;
+ }
+}
\ No newline at end of file
diff --git a/directory/web/src/App.test.tsx b/directory/web/src/App.test.tsx
new file mode 100644
index 000000000..5e49ca508
--- /dev/null
+++ b/directory/web/src/App.test.tsx
@@ -0,0 +1,10 @@
+import React from "react";
+import ReactDOM from "react-dom";
+
+import { App } from "./App";
+
+it("renders without crashing", () => {
+ const div = document.createElement("div");
+ ReactDOM.render(, div);
+ ReactDOM.unmountComponentAtNode(div);
+});
diff --git a/directory/web/src/App.tsx b/directory/web/src/App.tsx
new file mode 100644
index 000000000..c39af99a3
--- /dev/null
+++ b/directory/web/src/App.tsx
@@ -0,0 +1,158 @@
+import {
+ ApiOutlined,
+ DollarOutlined,
+ GithubOutlined,
+ TeamOutlined,
+ TwitterOutlined,
+} from "@ant-design/icons";
+import { Layout, Menu, Tag } from "antd";
+import React from "react";
+import {
+ BrowserRouter as Router,
+ Link,
+ Route,
+ RouteComponentProps,
+ Switch,
+} from "react-router-dom";
+
+import { ListsTable } from "./components";
+
+const { Header, Content, Footer } = Layout;
+
+export const App: React.FC = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
+
+const Logo = () => (
+
+
+
+);
+
+const NotFound = (props: RouteComponentProps) => (
+
+ 404 Not Found: {props.location.pathname}
+
+);
+
+const CopyrightAuthor = () => (
+
+ ©{new Date().getFullYear()}
+
+ Collin M. Barrett
+
+
+);
+
+const Twitter = () => (
+
+
+ Twitter
+
+
+);
+
+const Community = () => (
+
+
+ Community
+
+
+);
+
+const GitHub = () => (
+
+
+ GitHub
+
+
+);
+
+const Api = () => (
+
+
+ API
+
+
+);
+
+const Donate = () => (
+
+
+ Donate
+
+
+);
diff --git a/directory/web/src/components/Description.tsx b/directory/web/src/components/Description.tsx
new file mode 100644
index 000000000..1a0471cf6
--- /dev/null
+++ b/directory/web/src/components/Description.tsx
@@ -0,0 +1,15 @@
+import React from "react";
+
+interface Props {
+ description: string;
+ descriptionSourceUrl: string;
+}
+
+export const Description = (props: Props) =>
+ props.descriptionSourceUrl ? (
+
+ {props.description}
+
+ ) : (
+ {props.description}
+ );
diff --git a/directory/web/src/components/LicenseTag.tsx b/directory/web/src/components/LicenseTag.tsx
new file mode 100644
index 000000000..8beb0e554
--- /dev/null
+++ b/directory/web/src/components/LicenseTag.tsx
@@ -0,0 +1,32 @@
+import { Tag } from "antd";
+import * as React from "react";
+
+interface Props {
+ name: string;
+ descriptionUrl?: string;
+ showLabel?: boolean;
+}
+
+export const LicenseTag = (props: Props) =>
+ props.name ? (
+
+ {props.showLabel && License:
}
+
+
+
+
+ ) : null;
+
+const TagContents = (props: Props) =>
+ props.descriptionUrl ? (
+
+ {props.name}
+
+ ) : (
+ <>{props.name}>
+ );
diff --git a/directory/web/src/components/LinkButton.tsx b/directory/web/src/components/LinkButton.tsx
new file mode 100644
index 000000000..7a3fe5e82
--- /dev/null
+++ b/directory/web/src/components/LinkButton.tsx
@@ -0,0 +1,27 @@
+import { Button } from "antd";
+import { ButtonType } from "antd/lib/button";
+import React from "react";
+
+interface Props {
+ url: string;
+ text: string;
+ title?: string;
+ type?: ButtonType;
+ icon?: React.ReactNode;
+}
+
+export const LinkButton = (props: Props) =>
+ props.url && props.text ? (
+
+ ) : null;
diff --git a/directory/web/src/components/ListInfoButton.tsx b/directory/web/src/components/ListInfoButton.tsx
new file mode 100644
index 000000000..37013b331
--- /dev/null
+++ b/directory/web/src/components/ListInfoButton.tsx
@@ -0,0 +1,28 @@
+import { InfoCircleOutlined } from "@ant-design/icons";
+import { Button } from "antd";
+import React from "react";
+import { RouteComponentProps } from "react-router-dom";
+import slugify from "slugify";
+
+import { SlugifyOptions } from "../constants";
+
+interface Props {
+ listName: string;
+}
+
+export const ListInfoButton = (props: RouteComponentProps & Props) => {
+ const listPath = `/lists/${slugify(props.listName, SlugifyOptions)}`;
+ return (
+
+ );
+};
diff --git a/directory/web/src/components/PublishedDate.tsx b/directory/web/src/components/PublishedDate.tsx
new file mode 100644
index 000000000..c8b9d6c3c
--- /dev/null
+++ b/directory/web/src/components/PublishedDate.tsx
@@ -0,0 +1,13 @@
+import React from "react";
+
+interface Props {
+ publishedDate: string;
+}
+
+export const PublishedDate = (props: Props) =>
+ props.publishedDate ? (
+ <>
+ First Published Date:
+ {new Date(props.publishedDate).toDateString()}
+ >
+ ) : null;
diff --git a/directory/web/src/components/SubscribeButtons.tsx b/directory/web/src/components/SubscribeButtons.tsx
new file mode 100644
index 000000000..461951ab7
--- /dev/null
+++ b/directory/web/src/components/SubscribeButtons.tsx
@@ -0,0 +1,104 @@
+import { ImportOutlined } from "@ant-design/icons";
+import { Button } from "antd";
+import { ButtonType } from "antd/lib/button";
+import React from "react";
+
+interface Props {
+ name: string;
+ viewUrl: string;
+ viewUrlMirrors: string[];
+}
+
+export const SubscribeButtons = (props: Props) =>
+ props.viewUrl ? (
+ <>
+
+
+ >
+ ) : null;
+
+interface MirrorButtonsProps {
+ name: string;
+ viewUrlMirrors: string[];
+}
+
+const MirrorButtons = (props: MirrorButtonsProps) => (
+ <>
+ {props.viewUrlMirrors && props.viewUrlMirrors.length
+ ? props.viewUrlMirrors.map((viewUrlMirror: string, i: number) => (
+
+ ))
+ : null}
+ >
+);
+
+interface SubscribeButtonProps {
+ name: string;
+ viewUrl: string;
+ text: string;
+}
+
+const SubscribeButton = (props: SubscribeButtonProps) => {
+ const buttonProps = buildButtonProps(props.name, props.viewUrl);
+ return (
+ }
+ type={buttonProps.type}
+ href={buttonProps.href}
+ title={buttonProps.title}
+ >
+ {props.text}
+
+ );
+};
+
+const buildButtonProps = (name: string, viewUrl: string) => {
+ let type: ButtonType = "primary";
+ let disabled: boolean = false;
+
+ const hrefLocation = `${encodeURIComponent(viewUrl)}`;
+ const hrefTitle = `${encodeURIComponent(name)}`;
+ let href = `abp:subscribe?location=${hrefLocation}&title=${hrefTitle}`;
+
+ let prefixes: string[] = [];
+ let message = `Subscribe to ${name} with a browser extension supporting the "abp:" protocol (e.g. uBlock Origin, Adblock Plus).`;
+
+ // HTTP protocols
+ if (viewUrl.includes(".onion/")) {
+ type = "dashed";
+ prefixes.push("TOR");
+ }
+ if (viewUrl.includes("http://")) {
+ type = "dashed";
+ prefixes.push("INSECURE");
+ }
+
+ // Software protocols
+ if (viewUrl.includes(".tpl")) {
+ disabled = true; // IE not supported by FilterLists
+ }
+ if (
+ viewUrl.includes(".lsrules") ||
+ viewUrl.includes("?hostformat=littlesnitch")
+ ) {
+ href = `x-littlesnitch:subscribe-rules?url=${hrefLocation}`;
+ message = `Subscribe to ${name} with Little Snitch's rule group subscription feature.`;
+ }
+
+ const title = `${
+ prefixes.length ? prefixes.join(" | ") + " | " : ""
+ }${message}`;
+
+ return { disabled, type, href, title };
+};
diff --git a/directory/web/src/components/SyntaxTag.tsx b/directory/web/src/components/SyntaxTag.tsx
new file mode 100644
index 000000000..4c04f91b6
--- /dev/null
+++ b/directory/web/src/components/SyntaxTag.tsx
@@ -0,0 +1,32 @@
+import { Tag } from "antd";
+import * as React from "react";
+
+interface Props {
+ name: string;
+ definitionUrl?: string;
+ showLabel?: boolean;
+}
+
+export const SyntaxTag = (props: Props) =>
+ props.name ? (
+
+ {props.showLabel && Syntax:
}
+
+
+
+
+ ) : null;
+
+const TagContents = (props: Props) =>
+ props.definitionUrl ? (
+
+ {props.name}
+
+ ) : (
+ <>{props.name}>
+ );
diff --git a/directory/web/src/components/index.ts b/directory/web/src/components/index.ts
new file mode 100644
index 000000000..059e95e97
--- /dev/null
+++ b/directory/web/src/components/index.ts
@@ -0,0 +1,3 @@
+import { ListsTable } from "./listsTable";
+
+export { ListsTable };
diff --git a/directory/web/src/components/languageCloud/LanguageCloud.module.css b/directory/web/src/components/languageCloud/LanguageCloud.module.css
new file mode 100644
index 000000000..879bc4070
--- /dev/null
+++ b/directory/web/src/components/languageCloud/LanguageCloud.module.css
@@ -0,0 +1,7 @@
+.grow {
+ /* allow for 2nd row's tags' bottom border to not be chopped in AllListsTable */
+ max-height: none !important;
+
+ /* leave vertical gap between rows of tags */
+ line-height: 28px;
+}
diff --git a/directory/web/src/components/languageCloud/LanguageCloud.tsx b/directory/web/src/components/languageCloud/LanguageCloud.tsx
new file mode 100644
index 000000000..d6e6c8cfe
--- /dev/null
+++ b/directory/web/src/components/languageCloud/LanguageCloud.tsx
@@ -0,0 +1,24 @@
+import { Tag } from "antd";
+import React from "react";
+
+import { Language } from "../../interfaces/Language";
+import styles from "./LanguageCloud.module.css";
+
+interface Props {
+ languages: Language[];
+ showLabel?: boolean;
+}
+
+export const LanguageCloud = (props: Props) =>
+ props.languages && props.languages.length ? (
+
+ {props.showLabel && (
+
{`Language${props.languages.length > 1 ? "s" : ""}:`}
+ )}
+ {props.languages.map((l: Language, i: number) => (
+
+ {l.iso6391}
+
+ ))}
+
+ ) : null;
diff --git a/directory/web/src/components/languageCloud/index.ts b/directory/web/src/components/languageCloud/index.ts
new file mode 100644
index 000000000..c0509230a
--- /dev/null
+++ b/directory/web/src/components/languageCloud/index.ts
@@ -0,0 +1,3 @@
+import { LanguageCloud } from "./LanguageCloud";
+
+export { LanguageCloud };
diff --git a/directory/web/src/components/listInfoDrawer/ListInfoDrawer.tsx b/directory/web/src/components/listInfoDrawer/ListInfoDrawer.tsx
new file mode 100644
index 000000000..9a80c9b4b
--- /dev/null
+++ b/directory/web/src/components/listInfoDrawer/ListInfoDrawer.tsx
@@ -0,0 +1,156 @@
+import "./listInfoDrawer.css";
+
+import { Divider, Drawer } from "antd";
+import {
+ DollarOutlined,
+ FileExclamationOutlined,
+ FormOutlined,
+ GithubOutlined,
+ HomeOutlined,
+ MailOutlined,
+ MessageOutlined,
+ SearchOutlined,
+ TeamOutlined,
+} from "@ant-design/icons";
+import React, { useEffect, useState } from "react";
+
+import ButtonGroup from "antd/lib/button/button-group";
+import { Description } from "../Description";
+import { Language } from "../../interfaces/Language";
+import { LanguageCloud } from "../languageCloud";
+import { License } from "../../interfaces/License";
+import { LicenseTag } from "../LicenseTag";
+import { LinkButton } from "../LinkButton";
+import { List } from "../../interfaces/List";
+import { Maintainer } from "../../interfaces/Maintainer";
+import { Maintainers } from "../maintainers";
+import { PublishedDate } from "../PublishedDate";
+import { RouteComponentProps } from "react-router-dom";
+import { Software } from "../../interfaces/Software";
+import { SoftwareCloud } from "../softwareCloud";
+import { SubscribeButtons } from "../SubscribeButtons";
+import { Syntax } from "../../interfaces/Syntax";
+import { SyntaxTag } from "../SyntaxTag";
+import { Tag } from "../../interfaces/Tag";
+import { TagCloud } from "../tagCloud";
+
+interface Props {
+ list: List;
+ languages: Language[];
+ license: License | undefined;
+ maintainers: Maintainer[];
+ software: Software[];
+ syntax: Syntax | undefined;
+ tags: Tag[];
+}
+
+export const ListInfoDrawer = (props: RouteComponentProps & Props) => {
+ const [originalTitle] = useState(document.title);
+ useEffect(() => {
+ const updateTitle = () =>
+ (document.title = props.list.name + " | " + originalTitle);
+ updateTitle();
+ return () => {
+ document.title = originalTitle;
+ };
+ }, [props.list, originalTitle]);
+ return (
+ props.history.push("/")}
+ >
+
+
+
+ {props.license && (
+
+ )}
+ {props.syntax && (
+
+ )}
+
+
+
+
+
+
+ }
+ />
+ }
+ />
+ }
+ />
+ {props.list.emailAddress && (
+ }
+ />
+ )}
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+
+
+ );
+};
diff --git a/directory/web/src/components/listInfoDrawer/index.ts b/directory/web/src/components/listInfoDrawer/index.ts
new file mode 100644
index 000000000..03a1cedaf
--- /dev/null
+++ b/directory/web/src/components/listInfoDrawer/index.ts
@@ -0,0 +1,3 @@
+import { ListInfoDrawer } from "./ListInfoDrawer";
+
+export { ListInfoDrawer };
diff --git a/directory/web/src/components/listInfoDrawer/listInfoDrawer.css b/directory/web/src/components/listInfoDrawer/listInfoDrawer.css
new file mode 100644
index 000000000..fb65e5d6a
--- /dev/null
+++ b/directory/web/src/components/listInfoDrawer/listInfoDrawer.css
@@ -0,0 +1,4 @@
+h3 {
+ margin-top: 1em;
+ margin-bottom: 0.2em;
+}
diff --git a/directory/web/src/components/listsTable/ListDrawer.tsx b/directory/web/src/components/listsTable/ListDrawer.tsx
new file mode 100644
index 000000000..7c23b7074
--- /dev/null
+++ b/directory/web/src/components/listsTable/ListDrawer.tsx
@@ -0,0 +1,74 @@
+import React from "react";
+import {
+ Redirect,
+ Route,
+ RouteComponentProps,
+ StaticContext,
+} from "react-router";
+
+import { Language } from "../../interfaces/Language";
+import { License } from "../../interfaces/License";
+import { List } from "../../interfaces/List";
+import { Maintainer } from "../../interfaces/Maintainer";
+import { Software } from "../../interfaces/Software";
+import { Syntax } from "../../interfaces/Syntax";
+import { Tag } from "../../interfaces/Tag";
+import { ListInfoDrawer } from "../listInfoDrawer";
+
+interface Props {
+ lists: List[];
+ languages: Language[];
+ licenses: License[];
+ maintainers: Maintainer[];
+ software: Software[];
+ syntaxes: Syntax[];
+ tags: Tag[];
+}
+
+export const ListDrawer = (props: Props) => {
+ const renderDrawer = (rp: RouteComponentProps) => {
+ const list = props.lists.find((l) => l.slug === rp.match.params.listSlug);
+ return list ? (
+
+ list.languageIds.includes(l.id)
+ )
+ }
+ license={
+ list.licenseId
+ ? props.licenses.find((l: License) => list.licenseId === l.id)
+ : props.licenses.find((l: License) => l.id === 5)
+ }
+ maintainers={
+ list.maintainerIds &&
+ props.maintainers.filter((m: Maintainer) =>
+ list.maintainerIds.includes(m.id)
+ )
+ }
+ software={
+ list.syntaxId
+ ? props.software.filter((s: Software) =>
+ s.syntaxIds.includes(list.syntaxId)
+ )
+ : []
+ }
+ syntax={
+ list.syntaxId
+ ? props.syntaxes.find((s) => s.id === list.syntaxId)
+ : undefined
+ }
+ tags={
+ list.tagIds &&
+ props.tags.filter((t: Tag) => list.tagIds.includes(t.id))
+ }
+ {...rp}
+ />
+ ) : props.lists && props.lists.length ? (
+
+ ) : null;
+ };
+ return ;
+};
diff --git a/directory/web/src/components/listsTable/ListsTable.module.css b/directory/web/src/components/listsTable/ListsTable.module.css
new file mode 100644
index 000000000..f0e8cd19c
--- /dev/null
+++ b/directory/web/src/components/listsTable/ListsTable.module.css
@@ -0,0 +1,24 @@
+.nogrow > * {
+ /* limit table cells from wrapping more than 2 lines */
+ /* https://stackoverflow.com/a/13924997/2343739 */
+ /* https://stackoverflow.com/a/22042054/2343739 */
+ overflow: hidden;
+ text-overflow: ellipsis;
+ display: -webkit-box !important;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2; /* number of lines to show */
+ line-height: 1.5; /* fallback */
+ max-height: 3em; /* fallback */
+
+ /* fixed row height */
+ min-height: 3em;
+
+ /* fix left-side truncation of Description column on narrow viewports in Chrome */
+ word-break: break-word;
+}
+
+.nogrow {
+ /* eliminate top and bottom padding in cells to fit more rows per page */
+ padding-top: 0px !important;
+ padding-bottom: 0px !important;
+}
diff --git a/directory/web/src/components/listsTable/ListsTable.tsx b/directory/web/src/components/listsTable/ListsTable.tsx
new file mode 100644
index 000000000..c1f9af73f
--- /dev/null
+++ b/directory/web/src/components/listsTable/ListsTable.tsx
@@ -0,0 +1,386 @@
+import "./listsTable.css";
+
+import {
+ Key,
+ SorterResult,
+ TableCurrentDataSource,
+} from "antd/lib/table/interface";
+import React, { useEffect, useState } from "react";
+import { SoftwareCloud, SoftwareIcon } from "../softwareCloud";
+import { Table, Tag } from "antd";
+import { useSearchColumnFilter, useTablePageSizer } from "../../hooks";
+
+import { Description } from "../Description";
+import { Language } from "../../interfaces/Language";
+import { LanguageCloud } from "../languageCloud";
+import { License } from "../../interfaces/License";
+import { LicenseTag } from "../LicenseTag";
+import { List } from "../../interfaces/List";
+import { ListInfoButton } from "../ListInfoButton";
+import { Maintainer } from "../../interfaces/Maintainer";
+import { MaintainerCloud } from "../maintainerCloud";
+import { RouteComponentProps } from "react-router";
+import { Software } from "../../interfaces/Software";
+import { Syntax } from "../../interfaces/Syntax";
+import { SyntaxTag } from "../SyntaxTag";
+import { TagCloud } from "../tagCloud";
+import { Tag as TagInterface } from "../../interfaces/Tag";
+import { arraySorter } from "./arraySorter";
+import { nameof } from "../../utils";
+import styles from "./ListsTable.module.css";
+import { TablePaginationConfig } from "antd/lib/table";
+
+interface Props {
+ lists: List[];
+ languages: Language[];
+ licenses: License[];
+ maintainers: Maintainer[];
+ software: Software[];
+ syntaxes: Syntax[];
+ tags: TagInterface[];
+}
+
+export const ListsTable = (props: RouteComponentProps & Props) => {
+ const {
+ lists,
+ languages,
+ licenses,
+ maintainers,
+ software,
+ syntaxes,
+ tags,
+ ...routeComponentProps
+ } = props;
+ const tablePageSize = useTablePageSizer();
+ const searchNameColumn = useSearchColumnFilter(nameof("name"));
+ const searchDescriptionColumn = useSearchColumnFilter(
+ nameof("description")
+ );
+ const [visibleLists, setVisibleLists] = useState(lists);
+ useEffect(() => {
+ setVisibleLists(lists);
+ }, [lists]);
+ return (
+
+ dataSource={lists}
+ rowKey={(record) => record.id.toString()}
+ loading={lists.length ? false : true}
+ size="small"
+ pagination={{
+ simple: true,
+ style: { float: "left", margin: "4px 4px" },
+ pageSize: tablePageSize.pageSize,
+ }}
+ scroll={{
+ x:
+ tablePageSize.isNarrowWindow || tablePageSize.isWideWindow
+ ? undefined
+ : 1892,
+ }}
+ onChange={(
+ _pagination: TablePaginationConfig,
+ _filters: Record,
+ _sorter: SorterResult | SorterResult[],
+ extra: TableCurrentDataSource
+ ) => setVisibleLists(extra.currentDataSource)}
+ >
+
+ title="Info"
+ key="Info"
+ dataIndex={nameof("id")}
+ width={62}
+ className={styles.nogrow}
+ fixed={tablePageSize.isNarrowWindow ? undefined : "left"}
+ render={(_id: number, list: List) => (
+
+ )}
+ />
+
+ title="Name"
+ key="Name"
+ dataIndex={nameof("name")}
+ sorter={(a, b) => a.name.localeCompare(b.name)}
+ defaultSortOrder={"ascend"}
+ width={tablePageSize.isNarrowWindow ? undefined : 200}
+ className={styles.nogrow}
+ fixed={tablePageSize.isNarrowWindow ? undefined : "left"}
+ filterDropdown={searchNameColumn.filterDropdown}
+ filterIcon={searchNameColumn.filterIcon}
+ onFilter={searchNameColumn.onFilter}
+ render={(name: string) => {name}
}
+ />
+ {tablePageSize.isNarrowWindow ? null : (
+
+ title="Description"
+ key="Description"
+ dataIndex={nameof("description")}
+ sorter={(a, b) => a.description.localeCompare(b.description)}
+ width={
+ !tablePageSize.isNarrowWindow && !tablePageSize.isWideWindow
+ ? 423
+ : undefined
+ }
+ className={styles.nogrow}
+ filterDropdown={searchDescriptionColumn.filterDropdown}
+ filterIcon={searchDescriptionColumn.filterIcon}
+ onFilter={searchDescriptionColumn.onFilter}
+ render={(description: string, list: List) => (
+
+ )}
+ />
+ )}
+ {tablePageSize.isNarrowWindow ? null : (
+
+ title="Software"
+ key="Software"
+ dataIndex={nameof("syntaxId")}
+ sorter={(a, b) => {
+ const getSoftwareIds = (l: List) =>
+ software
+ .filter(
+ (s: Software) =>
+ s.syntaxIds && s.syntaxIds.includes(l.syntaxId)
+ )
+ .map((s) => s.id);
+ return arraySorter(getSoftwareIds(a), getSoftwareIds(b), software);
+ }}
+ width={170}
+ className={styles.nogrow}
+ filters={software.map((s) => ({
+ text: (
+ <>
+
+
+ {s.name} (
+ {
+ visibleLists.filter(
+ (l) => s.syntaxIds && s.syntaxIds.includes(l.syntaxId)
+ ).length
+ }
+ )
+ >
+ ),
+ value: s.name,
+ }))}
+ onFilter={(value, record) =>
+ software
+ .filter((s: Software) => s.name === value)
+ .flatMap((s) => s.syntaxIds)
+ .includes(record.syntaxId)
+ }
+ render={(syntaxId: number) =>
+ syntaxId ? (
+ s.syntaxIds && s.syntaxIds.includes(syntaxId)
+ )}
+ />
+ ) : null
+ }
+ />
+ )}
+ {tablePageSize.isNarrowWindow ? null : (
+
+ title="Syntax"
+ key="Syntax"
+ dataIndex={nameof("syntaxId")}
+ sorter={(a, b) => {
+ const syntaxA = syntaxes.find((s) => s.id === a.syntaxId);
+ const syntaxB = syntaxes.find((s) => s.id === b.syntaxId);
+ return syntaxA
+ ? syntaxB
+ ? syntaxA.name.localeCompare(syntaxB.name)
+ : -1
+ : 1;
+ }}
+ width={254}
+ className={styles.nogrow}
+ filters={syntaxes.map((s) => ({
+ text: (
+ <>
+ (
+ {
+ visibleLists.filter((l) => l.syntaxId && l.syntaxId === s.id)
+ .length
+ }
+ )
+ >
+ ),
+ value: s.id.toString(),
+ }))}
+ onFilter={(value, record) =>
+ record.syntaxId ? record.syntaxId.toString() === value : false
+ }
+ render={(syntaxId: number) => {
+ const syntax = syntaxes.find((s) => s.id === syntaxId);
+ return syntax ? (
+
+ ) : null;
+ }}
+ />
+ )}
+ {tablePageSize.isNarrowWindow ? null : (
+
+ title="Languages"
+ key="Languages"
+ dataIndex={nameof("languageIds")}
+ sorter={(a, b) =>
+ arraySorter(a.languageIds, b.languageIds, languages)
+ }
+ width={129}
+ className={styles.nogrow}
+ filters={languages.map((l) => ({
+ text: (
+ <>
+ {l.iso6391}
+ {l.name} (
+ {
+ visibleLists.filter(
+ (li) => li.languageIds && li.languageIds.includes(l.id)
+ ).length
+ }
+ )
+ >
+ ),
+ value: l.id.toString(),
+ }))}
+ onFilter={(value, record) =>
+ record.languageIds ? record.languageIds.includes(+value) : false
+ }
+ render={(languageIds: number[]) =>
+ languageIds ? (
+
+ languageIds.includes(l.id)
+ )}
+ />
+ ) : null
+ }
+ />
+ )}
+ {tablePageSize.isNarrowWindow ? null : (
+
+ title="Tags"
+ key="Tags"
+ dataIndex={nameof("tagIds")}
+ sorter={(a, b) => arraySorter(a.tagIds, b.tagIds, tags)}
+ width={275}
+ className={styles.nogrow}
+ filters={tags.map((t) => ({
+ text: (
+ <>
+ {t.name}(
+ {
+ visibleLists.filter(
+ (l) => l.tagIds && l.tagIds.includes(t.id)
+ ).length
+ }
+ )
+ >
+ ),
+ value: t.id.toString(),
+ }))}
+ onFilter={(value, record) =>
+ record.tagIds ? record.tagIds.includes(+value) : false
+ }
+ render={(tagIds: number[]) =>
+ tagIds ? (
+ tagIds.includes(t.id))}
+ />
+ ) : null
+ }
+ />
+ )}
+ {tablePageSize.isNarrowWindow ? null : (
+
+ title="Maintainers"
+ key="Maintainers"
+ dataIndex={nameof("maintainerIds")}
+ sorter={(a, b) =>
+ arraySorter(a.maintainerIds, b.maintainerIds, maintainers)
+ }
+ width={191}
+ className={styles.nogrow}
+ filters={maintainers.map((t) => ({
+ text: (
+ <>
+ {t.name}(
+ {
+ visibleLists.filter(
+ (l) => l.maintainerIds && l.maintainerIds.includes(t.id)
+ ).length
+ }
+ )
+ >
+ ),
+ value: t.id.toString(),
+ }))}
+ onFilter={(value, record) =>
+ record.maintainerIds ? record.maintainerIds.includes(+value) : false
+ }
+ render={(maintainerIds: number[]) =>
+ maintainerIds ? (
+
+ maintainerIds.includes(t.id)
+ )}
+ />
+ ) : null
+ }
+ />
+ )}
+ {tablePageSize.isNarrowWindow ? null : (
+
+ title="License"
+ key="License"
+ dataIndex={nameof("licenseId")}
+ sorter={(a, b) => {
+ const licenseA = licenses.find((s) => s.id === a.licenseId);
+ const licenseB = licenses.find((s) => s.id === b.licenseId);
+ return licenseA
+ ? licenseB
+ ? licenseA.name.localeCompare(licenseB.name)
+ : -1
+ : 1;
+ }}
+ width={215}
+ className={styles.nogrow}
+ filters={licenses.map((l) => ({
+ text: (
+ <>
+ (
+ {
+ visibleLists.filter(
+ (li) => li.licenseId && li.licenseId === l.id
+ ).length
+ }
+ )
+ >
+ ),
+ value: l.id.toString(),
+ }))}
+ onFilter={(value, record) =>
+ record.licenseId ? record.licenseId.toString() === value : false
+ }
+ render={(licenseId: number) => {
+ const license = licenses.find((l) => l.id === licenseId);
+ return license ? (
+
+ ) : null;
+ }}
+ />
+ )}
+
+ );
+};
diff --git a/directory/web/src/components/listsTable/ListsTableHoc.tsx b/directory/web/src/components/listsTable/ListsTableHoc.tsx
new file mode 100644
index 000000000..6c4b4c72a
--- /dev/null
+++ b/directory/web/src/components/listsTable/ListsTableHoc.tsx
@@ -0,0 +1,47 @@
+import React from "react";
+import { RouteComponentProps } from "react-router-dom";
+
+import {
+ useLanguages,
+ useLicenses,
+ useLists,
+ useMaintainers,
+ useSoftware,
+ useSyntaxes,
+ useTags,
+} from "../../hooks";
+import { ListDrawer } from "./ListDrawer";
+import { ListsTable } from "./ListsTable";
+
+export const ListsTableHoc = (props: RouteComponentProps) => {
+ const lists = useLists();
+ const languages = useLanguages();
+ const licenses = useLicenses();
+ const maintainers = useMaintainers();
+ const software = useSoftware();
+ const syntaxes = useSyntaxes();
+ const tags = useTags();
+ return (
+ <>
+
+
+ >
+ );
+};
diff --git a/directory/web/src/components/listsTable/arraySorter.ts b/directory/web/src/components/listsTable/arraySorter.ts
new file mode 100644
index 000000000..17a5a8a62
--- /dev/null
+++ b/directory/web/src/components/listsTable/arraySorter.ts
@@ -0,0 +1,30 @@
+interface ArraySortableEntity {
+ id: number;
+ name: string;
+}
+
+export const arraySorter = (
+ a: number[],
+ b: number[],
+ entities: ArraySortableEntity[]
+) =>
+ a && a.length
+ ? b && b.length
+ ? a.length === b.length
+ ? entities
+ .filter((e: ArraySortableEntity) => a.includes(e.id))
+ .map((e: ArraySortableEntity) => e.name)
+ .join()
+ .toLowerCase() >
+ entities
+ .filter((e: ArraySortableEntity) => b.includes(e.id))
+ .map((e: ArraySortableEntity) => e.name)
+ .join()
+ .toLowerCase()
+ ? 1
+ : -1
+ : a.length > b.length
+ ? -1
+ : 1
+ : -1
+ : 1;
diff --git a/directory/web/src/components/listsTable/index.ts b/directory/web/src/components/listsTable/index.ts
new file mode 100644
index 000000000..541bf2eb9
--- /dev/null
+++ b/directory/web/src/components/listsTable/index.ts
@@ -0,0 +1,3 @@
+import { ListsTableHoc } from "./ListsTableHoc";
+
+export { ListsTableHoc as ListsTable };
diff --git a/directory/web/src/components/listsTable/listsTable.css b/directory/web/src/components/listsTable/listsTable.css
new file mode 100644
index 000000000..34d395832
--- /dev/null
+++ b/directory/web/src/components/listsTable/listsTable.css
@@ -0,0 +1,3 @@
+.ant-table-header-column > div {
+ margin-top: 8px;
+}
diff --git a/directory/web/src/components/maintainerCloud/MaintainerCloud.module.css b/directory/web/src/components/maintainerCloud/MaintainerCloud.module.css
new file mode 100644
index 000000000..879bc4070
--- /dev/null
+++ b/directory/web/src/components/maintainerCloud/MaintainerCloud.module.css
@@ -0,0 +1,7 @@
+.grow {
+ /* allow for 2nd row's tags' bottom border to not be chopped in AllListsTable */
+ max-height: none !important;
+
+ /* leave vertical gap between rows of tags */
+ line-height: 28px;
+}
diff --git a/directory/web/src/components/maintainerCloud/MaintainerCloud.tsx b/directory/web/src/components/maintainerCloud/MaintainerCloud.tsx
new file mode 100644
index 000000000..155b92fe1
--- /dev/null
+++ b/directory/web/src/components/maintainerCloud/MaintainerCloud.tsx
@@ -0,0 +1,31 @@
+import { Tag } from "antd";
+import React from "react";
+
+import { Maintainer } from "../../interfaces/Maintainer";
+import styles from "./MaintainerCloud.module.css";
+
+interface Props {
+ maintainers: Maintainer[];
+}
+
+export const MaintainerCloud = (props: Props) =>
+ props.maintainers && props.maintainers.length ? (
+
+ {props.maintainers.map((m: Maintainer, i: number) =>
+ m.homeUrl ? (
+
+
+ {m.name}
+
+
+ ) : (
+
{m.name}
+ )
+ )}
+
+ ) : null;
diff --git a/directory/web/src/components/maintainerCloud/index.ts b/directory/web/src/components/maintainerCloud/index.ts
new file mode 100644
index 000000000..c0c593fa3
--- /dev/null
+++ b/directory/web/src/components/maintainerCloud/index.ts
@@ -0,0 +1,3 @@
+import { MaintainerCloud } from "./MaintainerCloud";
+
+export { MaintainerCloud };
diff --git a/directory/web/src/components/maintainers/Maintainers.tsx b/directory/web/src/components/maintainers/Maintainers.tsx
new file mode 100644
index 000000000..ee51c764a
--- /dev/null
+++ b/directory/web/src/components/maintainers/Maintainers.tsx
@@ -0,0 +1,67 @@
+import "./maintainers.css";
+
+import { HomeOutlined, MailOutlined, TwitterOutlined } from "@ant-design/icons";
+
+import { Card } from "antd";
+import React from "react";
+
+import { Maintainer } from "../../interfaces/Maintainer";
+
+interface Props {
+ maintainers: Maintainer[];
+}
+
+export const Maintainers = (props: Props) =>
+ props.maintainers && props.maintainers.length ? (
+ <>
+ Maintainer{props.maintainers.length > 1 ? "s" : ""}:
+ {props.maintainers.map((m: Maintainer, index: number) => (
+
+ ))}
+ >
+ ) : null;
+
+interface MaintainerComponentProps {
+ maintainer: Maintainer;
+}
+
+const MaintainerComponent = (props: MaintainerComponentProps) => {
+ let actions = [];
+ if (props.maintainer.homeUrl) {
+ actions.push(
+
+
+
+ );
+ }
+ if (props.maintainer.emailAddress) {
+ actions.push(
+
+
+
+ );
+ }
+ if (props.maintainer.twitterHandle) {
+ actions.push(
+
+
+
+ );
+ }
+ return ;
+};
diff --git a/directory/web/src/components/maintainers/index.ts b/directory/web/src/components/maintainers/index.ts
new file mode 100644
index 000000000..c44a20b8f
--- /dev/null
+++ b/directory/web/src/components/maintainers/index.ts
@@ -0,0 +1,3 @@
+import { Maintainers } from "./Maintainers";
+
+export { Maintainers };
diff --git a/directory/web/src/components/maintainers/maintainers.css b/directory/web/src/components/maintainers/maintainers.css
new file mode 100644
index 000000000..a4c829226
--- /dev/null
+++ b/directory/web/src/components/maintainers/maintainers.css
@@ -0,0 +1,3 @@
+.ant-card-body {
+ display: none;
+}
diff --git a/directory/web/src/components/softwareCloud/SoftwareCloud.module.css b/directory/web/src/components/softwareCloud/SoftwareCloud.module.css
new file mode 100644
index 000000000..5daff62ad
--- /dev/null
+++ b/directory/web/src/components/softwareCloud/SoftwareCloud.module.css
@@ -0,0 +1,4 @@
+/* allow for 2nd row's bottom border to not be chopped in AllListsTable */
+.grow {
+ max-height: none !important;
+}
diff --git a/directory/web/src/components/softwareCloud/SoftwareCloud.tsx b/directory/web/src/components/softwareCloud/SoftwareCloud.tsx
new file mode 100644
index 000000000..332a23f3c
--- /dev/null
+++ b/directory/web/src/components/softwareCloud/SoftwareCloud.tsx
@@ -0,0 +1,26 @@
+import React from "react";
+
+import { Software } from "../../interfaces/Software";
+import styles from "./SoftwareCloud.module.css";
+import { SoftwareIcon } from "./SoftwareIcon";
+
+interface Props {
+ software: Software[];
+ showLabel?: boolean;
+}
+
+export const SoftwareCloud = (props: Props) =>
+ props.software && props.software.length ? (
+
+ {props.showLabel &&
{`Software:`}
}
+ {props.software.map((s: Software, i: number) =>
+ s.homeUrl ? (
+
+
+
+ ) : (
+
+ )
+ )}
+
+ ) : null;
diff --git a/directory/web/src/components/softwareCloud/SoftwareIcon.tsx b/directory/web/src/components/softwareCloud/SoftwareIcon.tsx
new file mode 100644
index 000000000..cef779a1c
--- /dev/null
+++ b/directory/web/src/components/softwareCloud/SoftwareIcon.tsx
@@ -0,0 +1,110 @@
+import * as React from "react";
+
+import {
+ img01,
+ img02,
+ img03,
+ img04,
+ img05,
+ img06,
+ img07,
+ img08,
+ img10,
+ img11,
+ img12,
+ img13,
+ img14,
+ img15,
+ img16,
+ img17,
+ img18,
+ img19,
+ img20,
+ img21,
+ img22,
+ img23,
+ img24,
+ img25,
+ img26,
+ img27,
+ img28,
+ img29,
+ img30,
+ img31,
+ img32,
+ img33,
+ img34,
+ img35,
+ img36,
+ img37,
+ img38,
+ img39,
+ img40,
+ img41,
+ img42,
+ img43,
+} from "./imgs";
+
+interface Props {
+ id: number;
+}
+
+export const SoftwareIcon = (props: Props) =>
+ icons[props.id] ? (
+
+ ) : null;
+
+interface Icon {
+ image: any;
+ imageTitle: string;
+}
+
+const icons: { [id: number]: Icon } = {
+ 1: { image: img01, imageTitle: "uBlock Origin" },
+ 2: { image: img02, imageTitle: "Adblock Plus" },
+ 3: { image: img03, imageTitle: "AdGuard" },
+ 4: { image: img04, imageTitle: "DNS66" },
+ 5: { image: img05, imageTitle: "Nano Adblocker" },
+ 6: { image: img06, imageTitle: "AdBlock" },
+ 7: { image: img07, imageTitle: "AdAway" },
+ 8: { image: img08, imageTitle: "Personal Blocklist" },
+ 10: { image: img10, imageTitle: "Redirector" },
+ 11: { image: img11, imageTitle: "Hosts File Editor" },
+ 12: { image: img12, imageTitle: "Gas Mask" },
+ 13: { image: img13, imageTitle: "MinerBlock" },
+ 14: { image: img14, imageTitle: "Pi-hole" },
+ 15: { image: img15, imageTitle: "uBlock" },
+ 16: { image: img16, imageTitle: "Internet Explorer (TPL)" },
+ 17: { image: img17, imageTitle: "Google Hit Hider by Domain" },
+ 18: { image: img18, imageTitle: "FireHOL" },
+ 19: { image: img19, imageTitle: "Samsung Knox" },
+ 20: { image: img20, imageTitle: "Little Snitch" },
+ 21: { image: img21, imageTitle: "Privoxy" },
+ 22: { image: img22, imageTitle: "Diversion" },
+ 23: { image: img23, imageTitle: "dnsmasq" },
+ 24: { image: img24, imageTitle: "Slimjet" },
+ 25: { image: img25, imageTitle: "uMatrix" },
+ 26: { image: img26, imageTitle: "Blokada" },
+ 27: { image: img27, imageTitle: "hostsmgr" },
+ 28: { image: img28, imageTitle: "personalDNSfilter" },
+ 29: { image: img29, imageTitle: "Unbound" },
+ 30: { image: img30, imageTitle: "BIND" },
+ 31: { image: img31, imageTitle: "AdGuard Home" },
+ 32: { image: img32, imageTitle: "AdNauseam" },
+ 33: { image: img33, imageTitle: "Legacy Unix derivatives" },
+ 34: { image: img34, imageTitle: "Windows command line" },
+ 35: { image: img35, imageTitle: "Shadowsocks" },
+ 36: { image: img36, imageTitle: "ShadowsocksR" },
+ 37: { image: img37, imageTitle: "Shadowrocket" },
+ 38: { image: img38, imageTitle: "DNSRedirector" },
+ 39: { image: img39, imageTitle: "pfBlockerNG" },
+ 40: { image: img40, imageTitle: "Opera's built-in adblocker" },
+ 41: { image: img41, imageTitle: "Surge" },
+ 42: { image: img42, imageTitle: "dnscrypt-proxy" },
+ 43: { image: img43, imageTitle: "SmartDNS" },
+};
diff --git a/directory/web/src/components/softwareCloud/imgs/01-uBlock-Origin.svg b/directory/web/src/components/softwareCloud/imgs/01-uBlock-Origin.svg
new file mode 100644
index 000000000..03e7a9ebb
--- /dev/null
+++ b/directory/web/src/components/softwareCloud/imgs/01-uBlock-Origin.svg
@@ -0,0 +1 @@
+
diff --git a/directory/web/src/components/softwareCloud/imgs/02-Adblock-Plus.svg b/directory/web/src/components/softwareCloud/imgs/02-Adblock-Plus.svg
new file mode 100644
index 000000000..71ab1252f
--- /dev/null
+++ b/directory/web/src/components/softwareCloud/imgs/02-Adblock-Plus.svg
@@ -0,0 +1,15 @@
+
\ No newline at end of file
diff --git a/directory/web/src/components/softwareCloud/imgs/03-AdGuard.svg b/directory/web/src/components/softwareCloud/imgs/03-AdGuard.svg
new file mode 100644
index 000000000..b9cb8ebd4
--- /dev/null
+++ b/directory/web/src/components/softwareCloud/imgs/03-AdGuard.svg
@@ -0,0 +1,5 @@
+
diff --git a/directory/web/src/components/softwareCloud/imgs/04-DNS66.png b/directory/web/src/components/softwareCloud/imgs/04-DNS66.png
new file mode 100644
index 000000000..a32dc2c2f
Binary files /dev/null and b/directory/web/src/components/softwareCloud/imgs/04-DNS66.png differ
diff --git a/directory/web/src/components/softwareCloud/imgs/05-Nano-Adblocker.png b/directory/web/src/components/softwareCloud/imgs/05-Nano-Adblocker.png
new file mode 100644
index 000000000..cd2b2a06d
Binary files /dev/null and b/directory/web/src/components/softwareCloud/imgs/05-Nano-Adblocker.png differ
diff --git a/directory/web/src/components/softwareCloud/imgs/06-AdBlock.png b/directory/web/src/components/softwareCloud/imgs/06-AdBlock.png
new file mode 100644
index 000000000..84b1d1195
Binary files /dev/null and b/directory/web/src/components/softwareCloud/imgs/06-AdBlock.png differ
diff --git a/directory/web/src/components/softwareCloud/imgs/07-AdAway.png b/directory/web/src/components/softwareCloud/imgs/07-AdAway.png
new file mode 100644
index 000000000..0826b7478
Binary files /dev/null and b/directory/web/src/components/softwareCloud/imgs/07-AdAway.png differ
diff --git a/directory/web/src/components/softwareCloud/imgs/08-Personal-Blocklist.png b/directory/web/src/components/softwareCloud/imgs/08-Personal-Blocklist.png
new file mode 100644
index 000000000..9b6888a1b
Binary files /dev/null and b/directory/web/src/components/softwareCloud/imgs/08-Personal-Blocklist.png differ
diff --git a/directory/web/src/components/softwareCloud/imgs/10-Redirector.png b/directory/web/src/components/softwareCloud/imgs/10-Redirector.png
new file mode 100644
index 000000000..33fa33b01
Binary files /dev/null and b/directory/web/src/components/softwareCloud/imgs/10-Redirector.png differ
diff --git a/directory/web/src/components/softwareCloud/imgs/11-Hosts-File-Editor.png b/directory/web/src/components/softwareCloud/imgs/11-Hosts-File-Editor.png
new file mode 100644
index 000000000..e758cf5da
Binary files /dev/null and b/directory/web/src/components/softwareCloud/imgs/11-Hosts-File-Editor.png differ
diff --git a/directory/web/src/components/softwareCloud/imgs/12-Gas-Mask.png b/directory/web/src/components/softwareCloud/imgs/12-Gas-Mask.png
new file mode 100644
index 000000000..062dcbd76
Binary files /dev/null and b/directory/web/src/components/softwareCloud/imgs/12-Gas-Mask.png differ
diff --git a/directory/web/src/components/softwareCloud/imgs/13-MinerBlock.svg b/directory/web/src/components/softwareCloud/imgs/13-MinerBlock.svg
new file mode 100644
index 000000000..a2c2b0064
--- /dev/null
+++ b/directory/web/src/components/softwareCloud/imgs/13-MinerBlock.svg
@@ -0,0 +1,34 @@
+
+
+
+
\ No newline at end of file
diff --git a/directory/web/src/components/softwareCloud/imgs/14-Pi-hole.svg b/directory/web/src/components/softwareCloud/imgs/14-Pi-hole.svg
new file mode 100644
index 000000000..3d3bd57f7
--- /dev/null
+++ b/directory/web/src/components/softwareCloud/imgs/14-Pi-hole.svg
@@ -0,0 +1 @@
+
diff --git a/directory/web/src/components/softwareCloud/imgs/15-uBlock.svg b/directory/web/src/components/softwareCloud/imgs/15-uBlock.svg
new file mode 100644
index 000000000..6488ebb04
--- /dev/null
+++ b/directory/web/src/components/softwareCloud/imgs/15-uBlock.svg
@@ -0,0 +1,18 @@
+
diff --git a/directory/web/src/components/softwareCloud/imgs/16-Internet-Explorer-TPL.svg b/directory/web/src/components/softwareCloud/imgs/16-Internet-Explorer-TPL.svg
new file mode 100644
index 000000000..69780795f
--- /dev/null
+++ b/directory/web/src/components/softwareCloud/imgs/16-Internet-Explorer-TPL.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/directory/web/src/components/softwareCloud/imgs/17-Google-Hit-Hider-by-Domain.png b/directory/web/src/components/softwareCloud/imgs/17-Google-Hit-Hider-by-Domain.png
new file mode 100644
index 000000000..ca830def3
Binary files /dev/null and b/directory/web/src/components/softwareCloud/imgs/17-Google-Hit-Hider-by-Domain.png differ
diff --git a/directory/web/src/components/softwareCloud/imgs/18-FireHOL.png b/directory/web/src/components/softwareCloud/imgs/18-FireHOL.png
new file mode 100644
index 000000000..5db30bb01
Binary files /dev/null and b/directory/web/src/components/softwareCloud/imgs/18-FireHOL.png differ
diff --git a/directory/web/src/components/softwareCloud/imgs/19-Samsung-Knox.png b/directory/web/src/components/softwareCloud/imgs/19-Samsung-Knox.png
new file mode 100644
index 000000000..bdb2f96a1
Binary files /dev/null and b/directory/web/src/components/softwareCloud/imgs/19-Samsung-Knox.png differ
diff --git a/directory/web/src/components/softwareCloud/imgs/20-Little-Snitch.png b/directory/web/src/components/softwareCloud/imgs/20-Little-Snitch.png
new file mode 100644
index 000000000..1562eec0a
Binary files /dev/null and b/directory/web/src/components/softwareCloud/imgs/20-Little-Snitch.png differ
diff --git a/directory/web/src/components/softwareCloud/imgs/21-Privoxy.png b/directory/web/src/components/softwareCloud/imgs/21-Privoxy.png
new file mode 100644
index 000000000..0ba2b59a1
Binary files /dev/null and b/directory/web/src/components/softwareCloud/imgs/21-Privoxy.png differ
diff --git a/directory/web/src/components/softwareCloud/imgs/22-Diversion.png b/directory/web/src/components/softwareCloud/imgs/22-Diversion.png
new file mode 100644
index 000000000..511c8c8cd
Binary files /dev/null and b/directory/web/src/components/softwareCloud/imgs/22-Diversion.png differ
diff --git a/directory/web/src/components/softwareCloud/imgs/23-dnsmasq.png b/directory/web/src/components/softwareCloud/imgs/23-dnsmasq.png
new file mode 100644
index 000000000..99e986ba9
Binary files /dev/null and b/directory/web/src/components/softwareCloud/imgs/23-dnsmasq.png differ
diff --git a/directory/web/src/components/softwareCloud/imgs/24-Slimjet.png b/directory/web/src/components/softwareCloud/imgs/24-Slimjet.png
new file mode 100644
index 000000000..00f899268
Binary files /dev/null and b/directory/web/src/components/softwareCloud/imgs/24-Slimjet.png differ
diff --git a/directory/web/src/components/softwareCloud/imgs/25-uMatrix.png b/directory/web/src/components/softwareCloud/imgs/25-uMatrix.png
new file mode 100644
index 000000000..82663fc9e
Binary files /dev/null and b/directory/web/src/components/softwareCloud/imgs/25-uMatrix.png differ
diff --git a/directory/web/src/components/softwareCloud/imgs/26-Blokada.png b/directory/web/src/components/softwareCloud/imgs/26-Blokada.png
new file mode 100644
index 000000000..cfd376114
Binary files /dev/null and b/directory/web/src/components/softwareCloud/imgs/26-Blokada.png differ
diff --git a/directory/web/src/components/softwareCloud/imgs/27-hostsmgr.png b/directory/web/src/components/softwareCloud/imgs/27-hostsmgr.png
new file mode 100644
index 000000000..349d5b17e
Binary files /dev/null and b/directory/web/src/components/softwareCloud/imgs/27-hostsmgr.png differ
diff --git a/directory/web/src/components/softwareCloud/imgs/28-personalDNSfilter.svg b/directory/web/src/components/softwareCloud/imgs/28-personalDNSfilter.svg
new file mode 100644
index 000000000..90fd8f734
--- /dev/null
+++ b/directory/web/src/components/softwareCloud/imgs/28-personalDNSfilter.svg
@@ -0,0 +1,20 @@
+
\ No newline at end of file
diff --git a/directory/web/src/components/softwareCloud/imgs/29-Unbound.png b/directory/web/src/components/softwareCloud/imgs/29-Unbound.png
new file mode 100644
index 000000000..e6100dd8a
Binary files /dev/null and b/directory/web/src/components/softwareCloud/imgs/29-Unbound.png differ
diff --git a/directory/web/src/components/softwareCloud/imgs/30-BIND.png b/directory/web/src/components/softwareCloud/imgs/30-BIND.png
new file mode 100644
index 000000000..74723afaf
Binary files /dev/null and b/directory/web/src/components/softwareCloud/imgs/30-BIND.png differ
diff --git a/directory/web/src/components/softwareCloud/imgs/31-AdGuard-Home.png b/directory/web/src/components/softwareCloud/imgs/31-AdGuard-Home.png
new file mode 100644
index 000000000..e217b6879
Binary files /dev/null and b/directory/web/src/components/softwareCloud/imgs/31-AdGuard-Home.png differ
diff --git a/directory/web/src/components/softwareCloud/imgs/31-AdGuard-Home.svg b/directory/web/src/components/softwareCloud/imgs/31-AdGuard-Home.svg
new file mode 100644
index 000000000..d053374c3
--- /dev/null
+++ b/directory/web/src/components/softwareCloud/imgs/31-AdGuard-Home.svg
@@ -0,0 +1,18 @@
+
\ No newline at end of file
diff --git a/directory/web/src/components/softwareCloud/imgs/32-AdNauseam.png b/directory/web/src/components/softwareCloud/imgs/32-AdNauseam.png
new file mode 100644
index 000000000..773f3c556
Binary files /dev/null and b/directory/web/src/components/softwareCloud/imgs/32-AdNauseam.png differ
diff --git a/directory/web/src/components/softwareCloud/imgs/33-Legacy-Unix-Derivatives.png b/directory/web/src/components/softwareCloud/imgs/33-Legacy-Unix-Derivatives.png
new file mode 100644
index 000000000..617ecf5bd
Binary files /dev/null and b/directory/web/src/components/softwareCloud/imgs/33-Legacy-Unix-Derivatives.png differ
diff --git a/directory/web/src/components/softwareCloud/imgs/34-Windows-command-line.png b/directory/web/src/components/softwareCloud/imgs/34-Windows-command-line.png
new file mode 100644
index 000000000..0741aa5b9
Binary files /dev/null and b/directory/web/src/components/softwareCloud/imgs/34-Windows-command-line.png differ
diff --git a/directory/web/src/components/softwareCloud/imgs/35-Shadowsocks.png b/directory/web/src/components/softwareCloud/imgs/35-Shadowsocks.png
new file mode 100644
index 000000000..62cc490d4
Binary files /dev/null and b/directory/web/src/components/softwareCloud/imgs/35-Shadowsocks.png differ
diff --git a/directory/web/src/components/softwareCloud/imgs/36-ShadowsocksR.png b/directory/web/src/components/softwareCloud/imgs/36-ShadowsocksR.png
new file mode 100644
index 000000000..9e0e2e76f
Binary files /dev/null and b/directory/web/src/components/softwareCloud/imgs/36-ShadowsocksR.png differ
diff --git a/directory/web/src/components/softwareCloud/imgs/37-Shadowrocket.png b/directory/web/src/components/softwareCloud/imgs/37-Shadowrocket.png
new file mode 100644
index 000000000..816f0fd51
Binary files /dev/null and b/directory/web/src/components/softwareCloud/imgs/37-Shadowrocket.png differ
diff --git a/directory/web/src/components/softwareCloud/imgs/38-DNSRedirector.png b/directory/web/src/components/softwareCloud/imgs/38-DNSRedirector.png
new file mode 100644
index 000000000..9742d8280
Binary files /dev/null and b/directory/web/src/components/softwareCloud/imgs/38-DNSRedirector.png differ
diff --git a/directory/web/src/components/softwareCloud/imgs/39-pfBlockerNG.png b/directory/web/src/components/softwareCloud/imgs/39-pfBlockerNG.png
new file mode 100644
index 000000000..c831c6858
Binary files /dev/null and b/directory/web/src/components/softwareCloud/imgs/39-pfBlockerNG.png differ
diff --git a/directory/web/src/components/softwareCloud/imgs/40-Opera.png b/directory/web/src/components/softwareCloud/imgs/40-Opera.png
new file mode 100644
index 000000000..359defd58
Binary files /dev/null and b/directory/web/src/components/softwareCloud/imgs/40-Opera.png differ
diff --git a/directory/web/src/components/softwareCloud/imgs/41-Surge.png b/directory/web/src/components/softwareCloud/imgs/41-Surge.png
new file mode 100644
index 000000000..af42ee9d5
Binary files /dev/null and b/directory/web/src/components/softwareCloud/imgs/41-Surge.png differ
diff --git a/directory/web/src/components/softwareCloud/imgs/42-dnscrypt-proxy.png b/directory/web/src/components/softwareCloud/imgs/42-dnscrypt-proxy.png
new file mode 100644
index 000000000..6acfbe273
Binary files /dev/null and b/directory/web/src/components/softwareCloud/imgs/42-dnscrypt-proxy.png differ
diff --git a/directory/web/src/components/softwareCloud/imgs/43-SmartDNS.png b/directory/web/src/components/softwareCloud/imgs/43-SmartDNS.png
new file mode 100644
index 000000000..e5c398e72
Binary files /dev/null and b/directory/web/src/components/softwareCloud/imgs/43-SmartDNS.png differ
diff --git a/directory/web/src/components/softwareCloud/imgs/imgs.d.ts b/directory/web/src/components/softwareCloud/imgs/imgs.d.ts
new file mode 100644
index 000000000..a29b0ba00
--- /dev/null
+++ b/directory/web/src/components/softwareCloud/imgs/imgs.d.ts
@@ -0,0 +1,9 @@
+declare module "*.svg" {
+ const content: any;
+ export default content;
+}
+
+declare module "*.png" {
+ const content: any;
+ export default content;
+}
diff --git a/directory/web/src/components/softwareCloud/imgs/index.ts b/directory/web/src/components/softwareCloud/imgs/index.ts
new file mode 100644
index 000000000..fefbccf39
--- /dev/null
+++ b/directory/web/src/components/softwareCloud/imgs/index.ts
@@ -0,0 +1,87 @@
+import img01 from "./01-uBlock-Origin.svg";
+import img02 from "./02-Adblock-Plus.svg";
+import img03 from "./03-AdGuard.svg";
+import img04 from "./04-DNS66.png";
+import img05 from "./05-Nano-Adblocker.png";
+import img06 from "./06-AdBlock.png";
+import img07 from "./07-AdAway.png";
+import img08 from "./08-Personal-Blocklist.png";
+import img10 from "./10-Redirector.png";
+import img11 from "./11-Hosts-File-Editor.png";
+import img12 from "./12-Gas-Mask.png";
+import img13 from "./13-MinerBlock.svg";
+import img14 from "./14-Pi-hole.svg";
+import img15 from "./15-uBlock.svg";
+import img16 from "./16-Internet-Explorer-TPL.svg";
+import img17 from "./17-Google-Hit-Hider-by-Domain.png";
+import img18 from "./18-FireHOL.png";
+import img19 from "./19-Samsung-Knox.png";
+import img20 from "./20-Little-Snitch.png";
+import img21 from "./21-Privoxy.png";
+import img22 from "./22-Diversion.png";
+import img23 from "./23-dnsmasq.png";
+import img24 from "./24-Slimjet.png";
+import img25 from "./25-uMatrix.png";
+import img26 from "./26-Blokada.png";
+import img27 from "./27-hostsmgr.png";
+import img28 from "./28-personalDNSfilter.svg";
+import img29 from "./29-Unbound.png";
+import img30 from "./30-BIND.png";
+import img31 from "./31-AdGuard-Home.svg";
+import img32 from "./32-AdNauseam.png";
+import img33 from "./33-Legacy-Unix-Derivatives.png";
+import img34 from "./34-Windows-command-line.png";
+import img35 from "./35-Shadowsocks.png";
+import img36 from "./36-ShadowsocksR.png";
+import img37 from "./37-Shadowrocket.png";
+import img38 from "./38-DNSRedirector.png";
+import img39 from "./39-pfBlockerNG.png";
+import img40 from "./40-Opera.png";
+import img41 from "./41-Surge.png";
+import img42 from "./42-dnscrypt-proxy.png";
+import img43 from "./43-SmartDNS.png";
+
+export {
+ img01,
+ img02,
+ img03,
+ img04,
+ img05,
+ img06,
+ img07,
+ img08,
+ img10,
+ img11,
+ img12,
+ img13,
+ img14,
+ img15,
+ img16,
+ img17,
+ img18,
+ img19,
+ img20,
+ img21,
+ img22,
+ img23,
+ img24,
+ img25,
+ img26,
+ img27,
+ img28,
+ img29,
+ img30,
+ img31,
+ img32,
+ img33,
+ img34,
+ img35,
+ img36,
+ img37,
+ img38,
+ img39,
+ img40,
+ img41,
+ img42,
+ img43
+};
diff --git a/directory/web/src/components/softwareCloud/index.ts b/directory/web/src/components/softwareCloud/index.ts
new file mode 100644
index 000000000..6c9548df2
--- /dev/null
+++ b/directory/web/src/components/softwareCloud/index.ts
@@ -0,0 +1,4 @@
+import { SoftwareCloud } from "./SoftwareCloud";
+import { SoftwareIcon } from "./SoftwareIcon";
+
+export { SoftwareCloud, SoftwareIcon };
diff --git a/directory/web/src/components/tagCloud/TagCloud.module.css b/directory/web/src/components/tagCloud/TagCloud.module.css
new file mode 100644
index 000000000..879bc4070
--- /dev/null
+++ b/directory/web/src/components/tagCloud/TagCloud.module.css
@@ -0,0 +1,7 @@
+.grow {
+ /* allow for 2nd row's tags' bottom border to not be chopped in AllListsTable */
+ max-height: none !important;
+
+ /* leave vertical gap between rows of tags */
+ line-height: 28px;
+}
diff --git a/directory/web/src/components/tagCloud/TagCloud.tsx b/directory/web/src/components/tagCloud/TagCloud.tsx
new file mode 100644
index 000000000..ec36461fb
--- /dev/null
+++ b/directory/web/src/components/tagCloud/TagCloud.tsx
@@ -0,0 +1,22 @@
+import { Tag } from "antd";
+import React from "react";
+
+import { Tag as TagInterface } from "../../interfaces/Tag";
+import styles from "./TagCloud.module.css";
+
+interface Props {
+ tags: TagInterface[];
+ showLabel?: boolean;
+}
+
+export const TagCloud = (props: Props) =>
+ props.tags && props.tags.length ? (
+
+ {props.showLabel &&
{`Tag${props.tags.length > 1 ? "s" : ""}:`}
}
+ {props.tags.map((t: TagInterface, i: number) => (
+
+ {t.name}
+
+ ))}
+
+ ) : null;
diff --git a/directory/web/src/components/tagCloud/index.ts b/directory/web/src/components/tagCloud/index.ts
new file mode 100644
index 000000000..6cea6c0e9
--- /dev/null
+++ b/directory/web/src/components/tagCloud/index.ts
@@ -0,0 +1,3 @@
+import { TagCloud } from "./TagCloud";
+
+export { TagCloud };
diff --git a/directory/web/src/constants.ts b/directory/web/src/constants.ts
new file mode 100644
index 000000000..b8ecfa656
--- /dev/null
+++ b/directory/web/src/constants.ts
@@ -0,0 +1 @@
+export const SlugifyOptions = { remove: /[^a-zA-Z0-9- ]/g, lower: true };
diff --git a/directory/web/src/hooks/index.ts b/directory/web/src/hooks/index.ts
new file mode 100644
index 000000000..56d07702d
--- /dev/null
+++ b/directory/web/src/hooks/index.ts
@@ -0,0 +1,21 @@
+import { useLanguages } from "./useLanguages";
+import { useLicenses } from "./useLicenses";
+import { useLists } from "./useLists";
+import { useMaintainers } from "./useMaintainers";
+import { useSearchColumnFilter } from "./useSearchColumnFilter";
+import { useSoftware } from "./useSoftware";
+import { useSyntaxes } from "./useSyntaxes";
+import { useTablePageSizer } from "./useTablePageSizer";
+import { useTags } from "./useTags";
+
+export {
+ useLanguages,
+ useLicenses,
+ useLists,
+ useMaintainers,
+ useSearchColumnFilter,
+ useSoftware,
+ useSyntaxes,
+ useTablePageSizer,
+ useTags,
+};
diff --git a/directory/web/src/hooks/useApiData.tsx b/directory/web/src/hooks/useApiData.tsx
new file mode 100644
index 000000000..3fdc03d53
--- /dev/null
+++ b/directory/web/src/hooks/useApiData.tsx
@@ -0,0 +1,11 @@
+import { useEffect, useState } from "react";
+
+export const useApiData = (url: string) => {
+ const [data, setData] = useState();
+ useEffect(() => {
+ const fetchData = async () =>
+ (await fetch(url)).json().then((r) => setData(r));
+ fetchData();
+ }, [url]);
+ return data;
+};
diff --git a/directory/web/src/hooks/useLanguages.tsx b/directory/web/src/hooks/useLanguages.tsx
new file mode 100644
index 000000000..ece2dd0eb
--- /dev/null
+++ b/directory/web/src/hooks/useLanguages.tsx
@@ -0,0 +1,7 @@
+import { Language } from "../interfaces/Language";
+import { useApiData } from "./useApiData";
+
+export const useLanguages = () =>
+ (
+ useApiData("/api/v1/languages") || []
+ ).sort((a: Language, b: Language) => a.name.localeCompare(b.name));
diff --git a/directory/web/src/hooks/useLicenses.tsx b/directory/web/src/hooks/useLicenses.tsx
new file mode 100644
index 000000000..ccaeb9092
--- /dev/null
+++ b/directory/web/src/hooks/useLicenses.tsx
@@ -0,0 +1,7 @@
+import { License } from "../interfaces/License";
+import { useApiData } from "./useApiData";
+
+export const useLicenses = () =>
+ (
+ useApiData("/api/v1/licenses") || []
+ ).sort((a: License, b: License) => a.name.localeCompare(b.name));
diff --git a/directory/web/src/hooks/useLists.tsx b/directory/web/src/hooks/useLists.tsx
new file mode 100644
index 000000000..bdaa0f5b7
--- /dev/null
+++ b/directory/web/src/hooks/useLists.tsx
@@ -0,0 +1,11 @@
+import slugify from "slugify";
+
+import { SlugifyOptions } from "../constants";
+import { List } from "../interfaces/List";
+import { useApiData } from "./useApiData";
+
+export const useLists = () => {
+ const lists = useApiData("/api/v1/lists");
+ lists && lists.forEach((l) => (l.slug = slugify(l.name, SlugifyOptions)));
+ return (lists || []).sort((a: List, b: List) => a.name.localeCompare(b.name));
+};
diff --git a/directory/web/src/hooks/useMaintainers.tsx b/directory/web/src/hooks/useMaintainers.tsx
new file mode 100644
index 000000000..66964df97
--- /dev/null
+++ b/directory/web/src/hooks/useMaintainers.tsx
@@ -0,0 +1,7 @@
+import { Maintainer } from "../interfaces/Maintainer";
+import { useApiData } from "./useApiData";
+
+export const useMaintainers = () =>
+ (
+ useApiData("/api/v1/maintainers") || []
+ ).sort((a: Maintainer, b: Maintainer) => a.name.localeCompare(b.name));
diff --git a/directory/web/src/hooks/useSearchColumnFilter.tsx b/directory/web/src/hooks/useSearchColumnFilter.tsx
new file mode 100644
index 000000000..41872bd8b
--- /dev/null
+++ b/directory/web/src/hooks/useSearchColumnFilter.tsx
@@ -0,0 +1,81 @@
+import { SearchOutlined } from "@ant-design/icons";
+import { Button, Input } from "antd";
+import React, { useEffect, useState } from "react";
+import { FilterDropdownProps } from "antd/lib/table/interface";
+
+interface FilterPropsState {
+ filterDropdown?:
+ | React.ReactNode
+ | ((props: FilterDropdownProps) => React.ReactNode);
+ filterIcon?: React.ReactNode | ((filtered: boolean) => React.ReactNode);
+ onFilter?: (value: any, record: T) => boolean;
+}
+
+export const useSearchColumnFilter = (dataIndex: string) => {
+ const [filterProps, setFilterProps] = useState>({
+ filterDropdown: undefined,
+ filterIcon: undefined,
+ onFilter: undefined,
+ });
+ useEffect(() => {
+ const handleSearch = (confirm?: () => void) => {
+ confirm && confirm();
+ };
+ const handleReset = (clearFilters?: (selectedKeys: string[]) => void) => {
+ clearFilters && clearFilters([]);
+ };
+ setFilterProps({
+ filterDropdown: ({
+ setSelectedKeys,
+ selectedKeys,
+ confirm,
+ clearFilters,
+ }) => (
+
+
+ setSelectedKeys &&
+ setSelectedKeys(e.target.value ? [e.target.value] : [])
+ }
+ onPressEnter={() => handleSearch(confirm)}
+ style={{ width: 188, marginBottom: 8, display: "block" }}
+ />
+
+
+
+ ),
+ filterIcon: (filtered) => (
+
+ ),
+ onFilter: (value, record) => {
+ const searchValue = (record as any)[dataIndex];
+ return (
+ searchValue &&
+ searchValue
+ .toString()
+ .toLowerCase()
+ .includes(value.toString().toLowerCase())
+ );
+ },
+ });
+ }, [dataIndex]);
+ return filterProps;
+};
diff --git a/directory/web/src/hooks/useSoftware.tsx b/directory/web/src/hooks/useSoftware.tsx
new file mode 100644
index 000000000..2f33c0c3f
--- /dev/null
+++ b/directory/web/src/hooks/useSoftware.tsx
@@ -0,0 +1,7 @@
+import { Software } from "../interfaces/Software";
+import { useApiData } from "./useApiData";
+
+export const useSoftware = () =>
+ (
+ useApiData("/api/v1/software") || []
+ ).sort((a: Software, b: Software) => a.name.localeCompare(b.name));
diff --git a/directory/web/src/hooks/useSyntaxes.tsx b/directory/web/src/hooks/useSyntaxes.tsx
new file mode 100644
index 000000000..cf217ce06
--- /dev/null
+++ b/directory/web/src/hooks/useSyntaxes.tsx
@@ -0,0 +1,7 @@
+import { Syntax } from "../interfaces/Syntax";
+import { useApiData } from "./useApiData";
+
+export const useSyntaxes = () =>
+ (
+ useApiData("/api/v1/syntaxes") || []
+ ).sort((a: Syntax, b: Syntax) => a.name.localeCompare(b.name));
diff --git a/directory/web/src/hooks/useTablePageSizer.tsx b/directory/web/src/hooks/useTablePageSizer.tsx
new file mode 100644
index 000000000..eee29d7d5
--- /dev/null
+++ b/directory/web/src/hooks/useTablePageSizer.tsx
@@ -0,0 +1,23 @@
+import { useEffect, useState } from "react";
+
+interface TablePageSize {
+ pageSize: number;
+ isNarrowWindow: boolean;
+ isWideWindow: boolean;
+}
+
+export const useTablePageSizer = () => {
+ const [state, setState] = useState(calculateSize());
+ useEffect(() => {
+ const updatePageSize = () => setState(calculateSize());
+ window.addEventListener("resize", updatePageSize);
+ return () => window.removeEventListener("resize", updatePageSize);
+ }, []);
+ return state;
+};
+
+const calculateSize = () => ({
+ pageSize: Math.floor((window.innerHeight - 184) / 57),
+ isNarrowWindow: window.innerWidth < 576 ? true : false,
+ isWideWindow: window.innerWidth > 1918 ? true : false,
+});
diff --git a/directory/web/src/hooks/useTags.tsx b/directory/web/src/hooks/useTags.tsx
new file mode 100644
index 000000000..6faf3cb9d
--- /dev/null
+++ b/directory/web/src/hooks/useTags.tsx
@@ -0,0 +1,7 @@
+import { Tag } from "../interfaces/Tag";
+import { useApiData } from "./useApiData";
+
+export const useTags = () =>
+ (useApiData("/api/v1/tags") || []).sort((a: Tag, b: Tag) =>
+ a.name.localeCompare(b.name)
+ );
diff --git a/directory/web/src/index.css b/directory/web/src/index.css
new file mode 100644
index 000000000..4a1df4db7
--- /dev/null
+++ b/directory/web/src/index.css
@@ -0,0 +1,13 @@
+body {
+ margin: 0;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
+ "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
+ sans-serif;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+code {
+ font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
+ monospace;
+}
diff --git a/directory/web/src/index.tsx b/directory/web/src/index.tsx
new file mode 100644
index 000000000..f0dbcb5c2
--- /dev/null
+++ b/directory/web/src/index.tsx
@@ -0,0 +1,8 @@
+import "./index.css";
+
+import React from "react";
+import ReactDOM from "react-dom";
+
+import { App } from "./App";
+
+ReactDOM.render(, document.getElementById("root"));
diff --git a/directory/web/src/interfaces/Language.ts b/directory/web/src/interfaces/Language.ts
new file mode 100644
index 000000000..af4d69de7
--- /dev/null
+++ b/directory/web/src/interfaces/Language.ts
@@ -0,0 +1,6 @@
+export interface Language {
+ id: number;
+ filterListIds: number[];
+ iso6391: string;
+ name: string;
+}
diff --git a/directory/web/src/interfaces/License.ts b/directory/web/src/interfaces/License.ts
new file mode 100644
index 000000000..841df2905
--- /dev/null
+++ b/directory/web/src/interfaces/License.ts
@@ -0,0 +1,6 @@
+export interface License {
+ id: number;
+ descriptionUrl: string;
+ filterListIds: number[];
+ name: string;
+}
diff --git a/directory/web/src/interfaces/List.ts b/directory/web/src/interfaces/List.ts
new file mode 100644
index 000000000..901184ab1
--- /dev/null
+++ b/directory/web/src/interfaces/List.ts
@@ -0,0 +1,25 @@
+export interface List {
+ id: number;
+ chatUrl: string;
+ description: string;
+ descriptionSourceUrl: string;
+ donateUrl: string;
+ emailAddress: string;
+ forumUrl: string;
+ homeUrl: string;
+ issuesUrl: string;
+ languageIds: number[];
+ licenseId: number;
+ maintainerIds: number[];
+ name: string;
+ policyUrl: string;
+ publishedDate: string;
+ submissionUrl: string;
+ syntaxId: number;
+ tagIds: number[];
+ viewUrl: string;
+ viewUrlMirrors: string[];
+
+ // auto-generated by useLists hook
+ slug: string;
+}
diff --git a/directory/web/src/interfaces/Maintainer.ts b/directory/web/src/interfaces/Maintainer.ts
new file mode 100644
index 000000000..aa3746832
--- /dev/null
+++ b/directory/web/src/interfaces/Maintainer.ts
@@ -0,0 +1,8 @@
+export interface Maintainer {
+ id: number;
+ emailAddress: string;
+ filterListIds: number[];
+ homeUrl: string;
+ name: string;
+ twitterHandle: string;
+}
diff --git a/directory/web/src/interfaces/Software.ts b/directory/web/src/interfaces/Software.ts
new file mode 100644
index 000000000..dd8d151ac
--- /dev/null
+++ b/directory/web/src/interfaces/Software.ts
@@ -0,0 +1,7 @@
+export interface Software {
+ id: number;
+ homeUrl: string;
+ isAbpSubscribable: boolean;
+ name: string;
+ syntaxIds: number[];
+}
diff --git a/directory/web/src/interfaces/Syntax.ts b/directory/web/src/interfaces/Syntax.ts
new file mode 100644
index 000000000..0873306d9
--- /dev/null
+++ b/directory/web/src/interfaces/Syntax.ts
@@ -0,0 +1,7 @@
+export interface Syntax {
+ id: number;
+ definitionUrl: string;
+ filterListIds: number[];
+ name: string;
+ softwareIds: number[];
+}
diff --git a/directory/web/src/interfaces/Tag.ts b/directory/web/src/interfaces/Tag.ts
new file mode 100644
index 000000000..4141dd434
--- /dev/null
+++ b/directory/web/src/interfaces/Tag.ts
@@ -0,0 +1,6 @@
+export interface Tag {
+ id: number;
+ description: string;
+ filterListIds: number[];
+ name: string;
+}
diff --git a/directory/web/src/react-app-env.d.ts b/directory/web/src/react-app-env.d.ts
new file mode 100644
index 000000000..6431bc5fc
--- /dev/null
+++ b/directory/web/src/react-app-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/directory/web/src/utils/index.ts b/directory/web/src/utils/index.ts
new file mode 100644
index 000000000..844235139
--- /dev/null
+++ b/directory/web/src/utils/index.ts
@@ -0,0 +1,3 @@
+import { nameof } from "./nameof";
+
+export { nameof };
diff --git a/directory/web/src/utils/nameof.ts b/directory/web/src/utils/nameof.ts
new file mode 100644
index 000000000..282dd0ab2
--- /dev/null
+++ b/directory/web/src/utils/nameof.ts
@@ -0,0 +1,2 @@
+//https://stackoverflow.com/a/50470026/2343739
+export const nameof = (name: Extract) => name;
diff --git a/directory/web/tsconfig.json b/directory/web/tsconfig.json
new file mode 100644
index 000000000..af10394b4
--- /dev/null
+++ b/directory/web/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "compilerOptions": {
+ "target": "es5",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "module": "esnext",
+ "moduleResolution": "node",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react"
+ },
+ "include": ["src"]
+}
diff --git a/docker-compose.yml b/docker-compose.yml
index 004110c24..4e36ba615 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,20 +1,52 @@
version: "3.8"
services:
+ filterlists.reverse_proxy:
+ image: nginx:alpine
+ networks:
+ - reverse_proxy
+ depends_on:
+ - filterlists.directory.web
+ - filterlists.directory.api
+ volumes:
+ - ./reverse-proxy/svr-includes:/etc/nginx/svr-includes:ro
+
+ filterlists.directory.web:
+ image: ${DOCKER_REGISTRY-}filterlists_directory_web
+ build:
+ context: ./directory/web
+ networks:
+ - reverse_proxy
+ depends_on:
+ - filterlists.directory.api
+
filterlists.directory.api:
image: ${DOCKER_REGISTRY-}filterlists_directory_api
build:
context: ./directory/api
dockerfile: src/FilterLists.Directory.Api/Dockerfile
+ networks:
+ - reverse_proxy
+ - directory.api
depends_on:
- filterlists.directory.db
+ volumes:
+ - directory.db:/var/lib/postgresql/data
environment:
ConnectionStrings__DirectoryConnection: ${DIRECTORY_CONNECTION_STRING}
filterlists.directory.db:
image: postgres:alpine
- restart: always
+ networks:
+ - directory.api
environment:
POSTGRES_USER: ${DIRECTORY_DB_USER}
POSTGRES_PASSWORD: ${DIRECTORY_DB_PASSWORD}
- POSTGRES_DB: ${DIRECTORY_DB_DATABASE}
\ No newline at end of file
+ POSTGRES_DB: ${DIRECTORY_DB_DATABASE}
+
+networks:
+ reverse_proxy:
+ directory.api:
+
+volumes:
+ directory.db:
\ No newline at end of file
diff --git a/reverse-proxy/conf.d.dev/filterlists.conf b/reverse-proxy/conf.d.dev/filterlists.conf
new file mode 100644
index 000000000..ab9c4f6cc
--- /dev/null
+++ b/reverse-proxy/conf.d.dev/filterlists.conf
@@ -0,0 +1,26 @@
+server {
+ listen 80 default_server;
+
+ location ^~ /api {
+ rewrite ^/api/(.*)$ /$1 break;
+ proxy_pass http://api:80;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection keep-alive;
+ proxy_set_header Host $host;
+ proxy_cache_bypass $http_upgrade;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+
+ location / {
+ proxy_pass http://web:80;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection keep-alive;
+ proxy_set_header Host $host;
+ proxy_cache_bypass $http_upgrade;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+}
\ No newline at end of file
diff --git a/reverse-proxy/conf.d/filterlists.conf b/reverse-proxy/conf.d/filterlists.conf
new file mode 100644
index 000000000..758764ac4
--- /dev/null
+++ b/reverse-proxy/conf.d/filterlists.conf
@@ -0,0 +1,54 @@
+server {
+ listen 443 default_server ssl http2;
+ server_name filterlists.com;
+
+ include svr-includes/*;
+
+ ssl_certificate /etc/nginx/cert/filterlists_cert.pem;
+ ssl_certificate_key /etc/nginx/cert/filterlists_key.pem;
+
+ add_header Content-Security-Policy "default-src 'none'; script-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; manifest-src 'self';" always;
+
+ location ^~ /api {
+ rewrite ^/api/(.*)$ /$1 break;
+ proxy_pass http://api:80;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection keep-alive;
+ proxy_set_header Host $host;
+ proxy_cache_bypass $http_upgrade;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+
+ location / {
+ proxy_pass http://web:80;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection keep-alive;
+ proxy_set_header Host $host;
+ proxy_cache_bypass $http_upgrade;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+}
+
+# Redirect http to https
+server {
+ listen 80 default_server;
+ server_name filterlists.com;
+
+ return 301 https://filterlists.com$request_uri;
+}
+
+# Redirect www to non-www
+server {
+ listen 80;
+ listen 443 ssl http2;
+ server_name www.filterlists.com;
+
+ ssl_certificate /etc/nginx/cert/filterlists_cert.pem;
+ ssl_certificate_key /etc/nginx/cert/filterlists_key.pem;
+
+ return 301 https://filterlists.com$request_uri;
+}
\ No newline at end of file
diff --git a/reverse-proxy/conf.d/static-files.conf b/reverse-proxy/conf.d/static-files.conf
new file mode 100644
index 000000000..cad06a7a7
--- /dev/null
+++ b/reverse-proxy/conf.d/static-files.conf
@@ -0,0 +1,77 @@
+# https://raw.githubusercontent.com/h5bp/server-configs-nginx/master/h5bp/web_performance/cache_expiration.conf
+# ----------------------------------------------------------------------
+# | Cache expiration |
+# ----------------------------------------------------------------------
+
+# Serve resources with far-future expiration date.
+#
+# (!) If you don't control versioning with filename-based cache busting, you
+# should consider lowering the cache times to something like one week.
+#
+# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
+# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Expires
+# https://nginx.org/en/docs/http/ngx_http_headers_module.html#expires
+
+map $sent_http_content_type $expires {
+ default 1M;
+
+ # No content
+ "" off;
+
+ # CSS
+ ~*text/css 1y;
+
+ # Data interchange
+ ~*application/atom\+xml 1h;
+ ~*application/rdf\+xml 1h;
+ ~*application/rss\+xml 1h;
+
+ ~*application/json 4h;
+ ~*application/ld\+json 0;
+ ~*application/schema\+json 0;
+ ~*application/geo\+json 0;
+ ~*application/xml 0;
+ ~*text/calendar 0;
+ ~*text/xml 0;
+
+ # Favicon (cannot be renamed!) and cursor images
+ ~*image/vnd.microsoft.icon 1w;
+ ~*image/x-icon 1w;
+
+ # HTML
+ ~*text/html 0;
+
+ # JavaScript
+ ~*application/javascript 1y;
+ ~*application/x-javascript 1y;
+ ~*text/javascript 1y;
+
+ # Manifest files
+ ~*application/manifest\+json 1w;
+ ~*application/x-web-app-manifest\+json 0;
+ ~*text/cache-manifest 0;
+
+ # Markdown
+ ~*text/markdown 0;
+
+ # Media files
+ ~*audio/ 1M;
+ ~*image/ 1M;
+ ~*video/ 1M;
+
+ # WebAssembly
+ ~*application/wasm 1y;
+
+ # Web fonts
+ ~*font/ 1M;
+ ~*application/vnd.ms-fontobject 1M;
+ ~*application/x-font-ttf 1M;
+ ~*application/x-font-woff 1M;
+ ~*application/font-woff 1M;
+ ~*application/font-woff2 1M;
+
+ # Other
+ ~*text/x-cross-domain-policy 1w;
+}
+
+expires $expires;
diff --git a/reverse-proxy/svr-includes/security.conf b/reverse-proxy/svr-includes/security.conf
new file mode 100644
index 000000000..a90571540
--- /dev/null
+++ b/reverse-proxy/svr-includes/security.conf
@@ -0,0 +1,6 @@
+server_tokens off;
+
+add_header X-Content-Type-Options "nosniff" always;
+add_header X-Frame-Options "SAMEORIGIN" always;
+add_header X-XSS-Protection "1; mode=block" always;
+add_header Referrer-Policy "no-referrer" always;
\ No newline at end of file
diff --git a/reverse-proxy/svr-includes/ssl.conf b/reverse-proxy/svr-includes/ssl.conf
new file mode 100644
index 000000000..e36eacd41
--- /dev/null
+++ b/reverse-proxy/svr-includes/ssl.conf
@@ -0,0 +1,18 @@
+# https://ssl-config.mozilla.org/#server=nginx&config=intermediate&ocsp=false
+
+ssl_session_timeout 1d;
+ssl_session_cache shared:MozSSL:10m; # about 40000 sessions
+ssl_session_tickets off;
+
+ssl_dhparam /etc/nginx/cert/dhparam.pem;
+
+ssl_protocols TLSv1.2 TLSv1.3;
+ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
+ssl_prefer_server_ciphers off;
+
+add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
+
+# https://support.cloudflare.com/hc/en-us/articles/204899617-Authenticated-Origin-Pulls
+
+ssl_client_certificate /etc/nginx/cert/cloudflare.crt;
+ssl_verify_client on;