feat: /titles page with search and sort functionality. Website header added
This commit is contained in:
parent
31a95fabea
commit
f1f7feffaa
12 changed files with 625 additions and 155 deletions
|
|
@ -1,42 +0,0 @@
|
|||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
|
|
@ -2,10 +2,13 @@ import React from "react";
|
|||
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
|
||||
import UserPage from "./pages/UserPage/UserPage";
|
||||
import TitlesPage from "./pages/TitlesPage/TitlesPage";
|
||||
import { Header } from "./components/Header/Header";
|
||||
|
||||
const App: React.FC = () => {
|
||||
const username = "nihonium";
|
||||
return (
|
||||
<Router>
|
||||
<Header username={username} />
|
||||
<Routes>
|
||||
<Route path="/users/:id" element={<UserPage />} />
|
||||
<Route path="/titles" element={<TitlesPage />} />
|
||||
|
|
|
|||
90
modules/frontend/src/components/Header/Header.tsx
Normal file
90
modules/frontend/src/components/Header/Header.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import React, { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Bars3Icon, XMarkIcon } from "@heroicons/react/24/solid";
|
||||
|
||||
type HeaderProps = {
|
||||
username?: string;
|
||||
};
|
||||
|
||||
export const Header: React.FC<HeaderProps> = ({ username }) => {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
const toggleMenu = () => setMenuOpen(!menuOpen);
|
||||
|
||||
return (
|
||||
<header className="w-full bg-white shadow-md fixed top-0 left-0 z-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between h-16 items-center">
|
||||
|
||||
{/* Левый блок — логотип / название */}
|
||||
<div className="flex-shrink-0">
|
||||
<Link to="/" className="text-xl font-bold text-gray-800 hover:text-blue-600">
|
||||
NyanimeDB
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Центр — ссылки на разделы (desktop) */}
|
||||
<nav className="hidden md:flex space-x-4">
|
||||
<Link to="/titles" className="text-gray-700 hover:text-blue-600">
|
||||
Titles
|
||||
</Link>
|
||||
<Link to="/users" className="text-gray-700 hover:text-blue-600">
|
||||
Users
|
||||
</Link>
|
||||
<Link to="/about" className="text-gray-700 hover:text-blue-600">
|
||||
About
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
{/* Правый блок — профиль */}
|
||||
<div className="hidden md:flex items-center space-x-4">
|
||||
{username ? (
|
||||
<Link to="/profile" className="text-gray-700 hover:text-blue-600 font-medium">
|
||||
{username}
|
||||
</Link>
|
||||
) : (
|
||||
<Link to="/login" className="text-gray-700 hover:text-blue-600 font-medium">
|
||||
Login
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Бургер для мобильного */}
|
||||
<div className="md:hidden flex items-center">
|
||||
<button
|
||||
onClick={toggleMenu}
|
||||
className="p-2 rounded-md hover:bg-gray-200 transition"
|
||||
>
|
||||
{menuOpen ? (
|
||||
<XMarkIcon className="w-6 h-6 text-gray-800" />
|
||||
) : (
|
||||
<Bars3Icon className="w-6 h-6 text-gray-800" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Мобильное меню */}
|
||||
{menuOpen && (
|
||||
<div className="md:hidden bg-white border-t border-gray-200 shadow-md">
|
||||
<nav className="flex flex-col p-4 space-y-2">
|
||||
<Link to="/titles" className="text-gray-700 hover:text-blue-600" onClick={() => setMenuOpen(false)}>Titles</Link>
|
||||
<Link to="/users" className="text-gray-700 hover:text-blue-600" onClick={() => setMenuOpen(false)}>Users</Link>
|
||||
<Link to="/about" className="text-gray-700 hover:text-blue-600" onClick={() => setMenuOpen(false)}>About</Link>
|
||||
{username ? (
|
||||
<Link to="/profile" className="text-gray-700 hover:text-blue-600 font-medium" onClick={() => setMenuOpen(false)}>
|
||||
{username}
|
||||
</Link>
|
||||
) : (
|
||||
<Link to="/login" className="text-gray-700 hover:text-blue-600 font-medium" onClick={() => setMenuOpen(false)}>
|
||||
Login
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import React from "react";
|
||||
import { Squares2X2Icon, Bars3Icon } from "@heroicons/react/24/solid";
|
||||
|
||||
export type LayoutSwitchProps = {
|
||||
layout: "square" | "horizontal"
|
||||
setLayout: (value: React.SetStateAction<"square" | "horizontal">) => void
|
||||
};
|
||||
|
||||
export function LayoutSwitch({
|
||||
layout,
|
||||
setLayout
|
||||
}: LayoutSwitchProps) {
|
||||
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
className="p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
|
||||
onClick={() =>
|
||||
setLayout(prev => (prev === "square" ? "horizontal" : "square"))
|
||||
}>
|
||||
{layout === "square"
|
||||
? <Squares2X2Icon className="w-6 h-6" />
|
||||
: <Bars3Icon className="w-6 h-6" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,103 +1,49 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState } from "react";
|
||||
import { Squares2X2Icon, Bars3Icon } from "@heroicons/react/24/solid";
|
||||
import type { CursorObj } from "../../api";
|
||||
|
||||
export type ListViewProps<T> = {
|
||||
fetchItems: (cursor: string, limit: number) => Promise<{ items: T[]; cursor: CursorObj}>;
|
||||
items: T[];
|
||||
layout: "square" | "horizontal";
|
||||
renderItem: (item: T, layout: "square" | "horizontal") => React.ReactNode;
|
||||
pageSize?: number;
|
||||
searchPlaceholder?: string;
|
||||
setSearch: any;
|
||||
onLoadMore: () => void;
|
||||
hasMore: boolean;
|
||||
loadingMore: boolean;
|
||||
};
|
||||
|
||||
export function ListView<T>({
|
||||
fetchItems,
|
||||
items,
|
||||
layout,
|
||||
renderItem,
|
||||
pageSize = 20,
|
||||
searchPlaceholder = "Search...",
|
||||
onLoadMore,
|
||||
hasMore,
|
||||
loadingMore
|
||||
}: ListViewProps<T>) {
|
||||
const [items, setItems] = useState<T[]>([]);
|
||||
const [cursorObj, setCursorObj] = useState<CursorObj | undefined>(undefined);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [layout, setLayout] = useState<"square" | "horizontal">("horizontal");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadItems = async (reset: boolean = false) => {
|
||||
try {
|
||||
if (reset) {
|
||||
setLoading(true);
|
||||
setCursorObj(undefined);
|
||||
} else {
|
||||
setLoadingMore(true);
|
||||
}
|
||||
|
||||
const cursorStr = cursorObj ? btoa(JSON.stringify(cursorObj)) : ""
|
||||
console.log("encoded cursor: " + cursorStr)
|
||||
|
||||
const result = await fetchItems(cursorStr, pageSize);
|
||||
|
||||
if (reset) setItems(result.items);
|
||||
else setItems(prev => [...prev, ...result.items]);
|
||||
|
||||
setCursorObj(result.cursor);
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
setError("Failed to fetch items.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadItems(true);
|
||||
}, [search]);
|
||||
|
||||
return (
|
||||
<div className="w-full min-h-screen bg-gray-50 p-6 text-black flex flex-col items-center">
|
||||
<div className="w-full sm:w-4/5 flex gap-4 mb-8">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={searchPlaceholder}
|
||||
// value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-black"
|
||||
/>
|
||||
<button
|
||||
className="p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
|
||||
onClick={() =>
|
||||
setLayout(prev => (prev === "square" ? "horizontal" : "square"))
|
||||
}>
|
||||
{layout === "square" ? <Squares2X2Icon className="w-6 h-6" /> : <Bars3Icon className="w-6 h-6" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-red-600 mb-6 font-medium">{error}</div>}
|
||||
|
||||
<div className="w-full flex flex-col items-center">
|
||||
{/* Items */}
|
||||
<div
|
||||
className={`w-full sm:w-4/5 grid gap-6 ${
|
||||
layout === "square" ? "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" : "grid-cols-1"
|
||||
layout === "square"
|
||||
? "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4"
|
||||
: "grid-cols-1"
|
||||
}`}
|
||||
>
|
||||
{items.map(item => renderItem(item, layout))}
|
||||
</div>
|
||||
|
||||
{cursorObj && (
|
||||
<div className="mt-8 flex justify-center w-full sm:w-4/5">
|
||||
{/* Load More */}
|
||||
{hasMore && (
|
||||
<div className="mt-8">
|
||||
<button
|
||||
className="px-6 py-3 bg-blue-600 text-white font-semibold rounded-lg hover:bg-blue-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={() => loadItems(false)}
|
||||
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition disabled:opacity-50"
|
||||
disabled={loadingMore}
|
||||
onClick={onLoadMore}
|
||||
>
|
||||
{loadingMore ? "Loading..." : "Load More"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && <div className="mt-20 font-medium">Loading...</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
34
modules/frontend/src/components/SearchBar/SearchBar.tsx
Normal file
34
modules/frontend/src/components/SearchBar/SearchBar.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
type SearchBarProps = {
|
||||
placeholder?: string;
|
||||
search: string;
|
||||
setSearch: (value: string) => void;
|
||||
};
|
||||
|
||||
export function SearchBar({
|
||||
placeholder = "Search...",
|
||||
search,
|
||||
setSearch,
|
||||
}: SearchBarProps) {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="
|
||||
w-full
|
||||
px-4
|
||||
py-2
|
||||
border
|
||||
border-gray-300
|
||||
rounded-lg
|
||||
focus:outline-none
|
||||
focus:ring-2
|
||||
focus:ring-blue-500
|
||||
text-black
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import React, { useState } from "react";
|
||||
import type { TitleSort } from "../../api";
|
||||
import { ChevronDownIcon, ArrowUpIcon, ArrowDownIcon } from "@heroicons/react/24/solid";
|
||||
|
||||
type TitlesSortBoxProps = {
|
||||
sort: TitleSort;
|
||||
setSort: (value: TitleSort) => void;
|
||||
sortForward: boolean;
|
||||
setSortForward: (value: boolean) => void;
|
||||
};
|
||||
|
||||
const SORT_OPTIONS: TitleSort[] = ["id", "rating", "year", "views"];
|
||||
|
||||
export function TitlesSortBox({
|
||||
sort,
|
||||
setSort,
|
||||
sortForward,
|
||||
setSortForward,
|
||||
}: TitlesSortBoxProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const toggleSortDirection = () => setSortForward(!sortForward);
|
||||
const handleSortSelect = (newSort: TitleSort) => {
|
||||
setSort(newSort);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="inline-flex relative z-50">
|
||||
{/* Левая часть — смена направления */}
|
||||
<button
|
||||
onClick={toggleSortDirection}
|
||||
className="px-4 py-2 flex items-center justify-center bg-gray-100 hover:bg-gray-200 border border-gray-300 rounded-l-lg transition"
|
||||
>
|
||||
{sortForward ? <ArrowUpIcon className="w-4 h-4 mr-1" /> : <ArrowDownIcon className="w-4 h-4 mr-1" />}
|
||||
<span className="text-sm font-medium">Order</span>
|
||||
</button>
|
||||
|
||||
{/* Правая часть — выбор параметра */}
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="px-4 py-2 flex items-center justify-center bg-gray-100 hover:bg-gray-200 border border-gray-300 border-l-0 rounded-r-lg transition"
|
||||
>
|
||||
<span className="text-sm font-medium">{sort}</span>
|
||||
<ChevronDownIcon className="w-4 h-4 ml-1" />
|
||||
</button>
|
||||
|
||||
{/* Dropdown */}
|
||||
{open && (
|
||||
<ul className="absolute top-full left-0 mt-1 w-40 bg-white border border-gray-300 rounded-md shadow-lg z-[1000]">
|
||||
{SORT_OPTIONS.map(option => (
|
||||
<li key={option}>
|
||||
<button
|
||||
className={`w-full text-left px-4 py-2 hover:bg-gray-100 transition ${
|
||||
option === sort ? "font-bold bg-gray-100" : ""
|
||||
}`}
|
||||
onClick={() => handleSortSelect(option)}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,4 +5,5 @@ html, body, #root {
|
|||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@apply text-black bg-white;
|
||||
}
|
||||
|
|
@ -1,52 +1,154 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { ListView } from "../../components/ListView/ListView";
|
||||
import { SearchBar } from "../../components/SearchBar/SearchBar";
|
||||
import { TitlesSortBox } from "../../components/TitlesSortBox/TitlesSortBox";
|
||||
import { DefaultService } from "../../api/services/DefaultService";
|
||||
import { TitleCardSquare } from "../../components/cards/TitleCardSquare";
|
||||
import { TitleCardHorizontal } from "../../components/cards/TitleCardHorizontal";
|
||||
import type { Title } from "../../api";
|
||||
import { useState } from "react";
|
||||
import type { CursorObj, Title, TitleSort } from "../../api";
|
||||
import { LayoutSwitch } from "../../components/LayoutSwitch/LayoutSwitch";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
export default function TitlesPage() {
|
||||
const [titles, setTitles] = useState<Title[]>([]);
|
||||
const [nextPage, setNextPage] = useState<Title[]>([]);
|
||||
const [cursor, setCursor] = useState<CursorObj | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [sort, setSort] = useState<TitleSort>("id");
|
||||
const [sortForward, setSortForward] = useState(true);
|
||||
const [layout, setLayout] = useState<"square" | "horizontal">("square");
|
||||
|
||||
const loadTitles = async (cursor: string, limit: number) => {
|
||||
const result = await DefaultService.getTitles(
|
||||
cursor,
|
||||
undefined,
|
||||
true,
|
||||
search,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
limit,
|
||||
undefined,
|
||||
'all'
|
||||
);
|
||||
const fetchPage = async (cursorObj: CursorObj | null) => {
|
||||
const cursorStr = cursorObj ? btoa(JSON.stringify(cursorObj)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') : "";
|
||||
|
||||
return {
|
||||
items: result.data ?? [],
|
||||
cursor: result.cursor ?? null,
|
||||
};
|
||||
try {
|
||||
const result = await DefaultService.getTitles(
|
||||
cursorStr,
|
||||
sort,
|
||||
sortForward,
|
||||
search.trim() || undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
PAGE_SIZE,
|
||||
undefined,
|
||||
"all"
|
||||
);
|
||||
|
||||
if ((result === undefined) || !result.data?.length) {
|
||||
return { items: [], nextCursor: null };
|
||||
}
|
||||
return {
|
||||
items: result.data ?? [],
|
||||
nextCursor: result.cursor ?? null
|
||||
};
|
||||
} catch (err: any) {
|
||||
if (err.status === 204) {
|
||||
return { items: [], nextCursor: null };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full min-h-screen bg-gray-50 p-6 text-black flex flex-col items-center">
|
||||
|
||||
<h1 className="text-4xl font-bold mb-6 text-center">Titles</h1>
|
||||
// Инициализация: загружаем сразу две страницы
|
||||
useEffect(() => {
|
||||
const initLoad = async () => {
|
||||
setLoading(true);
|
||||
setTitles([]);
|
||||
setNextPage([]);
|
||||
setCursor(null);
|
||||
|
||||
<ListView<Title>
|
||||
pageSize={PAGE_SIZE}
|
||||
fetchItems={loadTitles}
|
||||
searchPlaceholder="Search titles..."
|
||||
renderItem={(title, layout) =>
|
||||
layout === "square"
|
||||
? <TitleCardSquare title={title} />
|
||||
: <TitleCardHorizontal title={title} />
|
||||
}
|
||||
setSearch={setSearch}
|
||||
/>
|
||||
const firstPage = await fetchPage(null);
|
||||
const secondPage = firstPage.nextCursor ? await fetchPage(firstPage.nextCursor) : { items: [], nextCursor: null };
|
||||
|
||||
setTitles(firstPage.items);
|
||||
setNextPage(secondPage.items);
|
||||
setCursor(secondPage.nextCursor);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
initLoad();
|
||||
}, [search, sort, sortForward]);
|
||||
|
||||
|
||||
const handleLoadMore = async () => {
|
||||
if (nextPage.length === 0) {
|
||||
setLoadingMore(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingMore(true);
|
||||
|
||||
setTitles(prev => [...prev, ...nextPage]);
|
||||
setNextPage([]);
|
||||
|
||||
// Подгружаем следующую страницу с сервера
|
||||
if (cursor) {
|
||||
try {
|
||||
const next = await fetchPage(cursor);
|
||||
if (next.items.length > 0) {
|
||||
setNextPage(next.items);
|
||||
}
|
||||
setCursor(next.nextCursor);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Любой сценарий – выключаем loadingMore
|
||||
setLoadingMore(false);
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="w-full min-h-screen bg-gray-50 p-6 flex flex-col items-center">
|
||||
|
||||
<h1 className="text-4xl font-bold mb-6 text-center text-black">Titles</h1>
|
||||
|
||||
<div className="w-full sm:w-4/5 flex flex-col sm:flex-row gap-4 mb-6 items-center">
|
||||
<SearchBar placeholder="Search titles..." search={search} setSearch={setSearch} />
|
||||
<LayoutSwitch layout={layout} setLayout={setLayout} />
|
||||
<TitlesSortBox
|
||||
sort={sort}
|
||||
setSort={setSort}
|
||||
sortForward={sortForward}
|
||||
setSortForward={setSortForward}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading && <div className="mt-20 font-medium text-black">Loading...</div>}
|
||||
|
||||
{!loading && titles.length === 0 && (
|
||||
<div className="mt-20 font-medium text-black">No titles found.</div>
|
||||
)}
|
||||
|
||||
{titles.length > 0 && (
|
||||
<>
|
||||
<ListView<Title>
|
||||
items={titles}
|
||||
layout={layout}
|
||||
hasMore={!!cursor || nextPage.length > 1}
|
||||
loadingMore={loadingMore}
|
||||
onLoadMore={handleLoadMore}
|
||||
renderItem={(title, layout) =>
|
||||
layout === "square"
|
||||
? <TitleCardSquare title={title} />
|
||||
: <TitleCardHorizontal title={title} />
|
||||
}
|
||||
/>
|
||||
|
||||
{!cursor && nextPage.length == 0 && (
|
||||
<div className="mt-6 font-medium text-black">
|
||||
Результатов больше нет, было найдено {titles.length} тайтлов.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue