Add masonry layout and small fixes

This commit is contained in:
2024-05-14 20:56:02 +04:00
parent ab6eca4661
commit 0563abd669
12 changed files with 334 additions and 293 deletions

16
package-lock.json generated
View File

@@ -13,12 +13,14 @@
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"react": "^18", "react": "^18",
"react-dom": "^18", "react-dom": "^18",
"react-responsive-masonry": "^2.2.0",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^18", "@types/react": "^18",
"@types/react-dom": "^18", "@types/react-dom": "^18",
"@types/react-responsive-masonry": "^2.1.3",
"eslint": "^8", "eslint": "^8",
"eslint-config-next": "14.2.3", "eslint-config-next": "14.2.3",
"postcss": "^8", "postcss": "^8",
@@ -485,6 +487,15 @@
"@types/react": "*" "@types/react": "*"
} }
}, },
"node_modules/@types/react-responsive-masonry": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@types/react-responsive-masonry/-/react-responsive-masonry-2.1.3.tgz",
"integrity": "sha512-aOFUtv3QwNMmy0BgpQpvivQ/+vivMTB6ARrzf9eTSXsLzXpVnfEtjpHpSknYDnr8KaQmlgeauAj8E7wo/qMOTg==",
"dev": true,
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@typescript-eslint/parser": { "node_modules/@typescript-eslint/parser": {
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.2.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.2.0.tgz",
@@ -3744,6 +3755,11 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true "dev": true
}, },
"node_modules/react-responsive-masonry": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/react-responsive-masonry/-/react-responsive-masonry-2.2.0.tgz",
"integrity": "sha512-IYbnfe2tWCZ3pvyTLyBWPj7uv5ZmNOULYMcAZi5a47ZLhSotOck1vkkISq6gP2qiyWdMvPfeMhjvYzUYGw9BOQ=="
},
"node_modules/read-cache": { "node_modules/read-cache": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",

View File

@@ -14,12 +14,14 @@
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"react": "^18", "react": "^18",
"react-dom": "^18", "react-dom": "^18",
"react-responsive-masonry": "^2.2.0",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^18", "@types/react": "^18",
"@types/react-dom": "^18", "@types/react-dom": "^18",
"@types/react-responsive-masonry": "^2.1.3",
"eslint": "^8", "eslint": "^8",
"eslint-config-next": "14.2.3", "eslint-config-next": "14.2.3",
"postcss": "^8", "postcss": "^8",

View File

@@ -1,126 +1,130 @@
import { GameService } from "@/entities/game"; import { GameService } from "@/entities/game";
import { GameCard } from "@/features/gameCard"; import { GameCard } from "@/features/gameCard";
import { getYouTubeID } from "@/shared/utils";
import { Section } from "@/widgets/section"; import { Section } from "@/widgets/section";
import clsx from "clsx"; import clsx from "clsx";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
export default async function Games({ export default async function Games({
params: { game_id }, params: { game_id },
}: { }: {
params: { game_id: number }; params: { game_id: number };
}) { }) {
const gameCards = await GameService.getGameCards(); const gameCards = await GameService.getGameCards();
const game = await GameService.getGame(game_id); const game = await GameService.getGame(game_id);
return ( return (
<> <>
{game && ( {game && (
<div className="p-4 flex flex-col lp:block"> <div className="p-4 flex flex-col lp:block">
{game.cover && ( {game.cover && (
<div className="lp:w-[60%] lp:px-4 lp:pl-0 pt-2 float-left"> <div className="lp:w-[60%] lp:px-4 lp:pl-0 py-2 float-left">
<Image <Image
src={game.cover} src={game.cover}
className="rounded-lg aspect-video object-cover" className="rounded-lg w-full object-contain"
alt="" alt=""
width={1280} width={1280}
height={720} height={720}
/> />
</div> </div>
)} )}
<span className="pt-2 lp:max-w-[40%]"> <span className="lp:max-w-[40%]">
<h1 className="text-4xl">{game.title}</h1> <h1 className="text-4xl">{game.title}</h1>
{game.description && ( {game.description && (
<p className="text-md text-justify text-fg4 pt-2"> <p className="text-md text-justify text-fg4 pt-2">
{game.description} {game.description}
</p> </p>
)} )}
</span> </span>
<div className="flex justify-between pt-6"> <div className="w-full flex justify-between pt-4">
{[ {[
[ [
{ name: "Система", value: game.system }, { name: "Система", value: game.system },
{ name: "Процессор", value: game.processor }, { name: "Процессор", value: game.processor },
{ name: "Оперативная память", value: game.memory }, { name: "Оперативная память", value: game.memory },
{ name: "Видеокарта", value: game.graphics }, { name: "Видеокарта", value: game.graphics },
{ name: "Место на диске", value: game.storage }, { name: "Место на диске", value: game.storage },
], ],
[ [
{ {
name: "Версия игры", name: "Версия игры",
value: `${ value: `${
game.version game.version
} (обновлена ${game.update_date.toLocaleDateString( } (обновлена ${game.update_date.toLocaleDateString(
"ru-ru" "ru-ru"
)})`, )})`,
}, },
{ name: "Язык", value: game.language }, { name: "Язык", value: game.language },
{ name: "Разработчик", value: game.developer }, { name: "Разработчик", value: game.developer },
{ {
name: "Год выхода", name: "Год выхода",
value: game.release_date.toLocaleDateString("en-us", { value: game.release_date.toLocaleDateString("en-us", {
year: "numeric", year: "numeric",
}), }),
}, },
{ name: "Объём загрузки", value: game.download_size }, { name: "Объём загрузки", value: game.download_size },
], ],
].map((section, i) => ( ].map((section, i) => (
<ul key={i} className="w-[48%] bg-bg1 rounded-lg py-1 px-4"> <ul key={i} className="w-[48%] bg-bg1 rounded-lg py-1 px-4">
{section.map((req) => ( {section.map((req) => (
<li <li
key={req.name} key={req.name}
className="font-bold text-sm lp:text-md py-1" className="font-bold text-sm lp:text-md py-1"
> >
{req.name + ": "} {req.name + ": "}
<span <span
className={clsx( className={clsx(
"font-normal", "font-normal",
req.value === undefined && "text-fg4" req.value === undefined && "text-fg4"
)} )}
> >
{req.value ?? "Не известно"} {req.value ?? "Не известно"}
</span> </span>
</li> </li>
))} ))}
</ul> </ul>
))} ))}
</div> </div>
{game.trailer && ( {game.trailer && getYouTubeID(game.trailer) && (
<iframe <iframe
src={game.trailer.replace("/watch?v=", "/embed/")} src={"https://youtube.com/embed/" + getYouTubeID(game.trailer)}
className="w-full aspect-video rounded-lg my-4" className="w-full aspect-video rounded-lg mt-4"
allowFullScreen allowFullScreen
/> />
)} )}
<div className="relative w-full flex items-center justify-around"> <div className="relative w-full flex items-center justify-around pt-4">
<Link <Link
href={ href={
process.env.NEXT_PUBLIC_CONTENT_URL + "/" + game.torrent_file process.env.NEXT_PUBLIC_CONTENT_URL + "/" + game.torrent_file
} }
className="p-4 bg-ac0 text-fg1 text-xl rounded-lg" className="p-4 bg-ac0 text-fg1 text-2xl rounded-lg"
> >
Скачать {game.title}.torrent Скачать {game.title}
</Link> </Link>
</div> </div>
<div className="w-full flex justify-end"> <div className="w-full flex justify-end">
<Link className="text-right text-sm" href="/how_to_download"> <Link
Как скачать игру className="text-right text-sm relative top-4 lp:-top-4"
<br /> с помощью .torrent файла? href="/how_to_download"
</Link> >
</div> Как скачать игру
</div> <br /> с помощью .torrent файла?
)} </Link>
</div>
</div>
)}
{gameCards && ( {gameCards && (
<Section <Section
name="Другие популярные игры" name="Популярные игры"
link="/games" link="/games"
invite_text={'Перейти в раздел "Игры"'} invite_text={'Перейти в раздел "Игры"'}
> >
{gameCards.map((card) => ( {gameCards.map((card) => (
<GameCard key={card.id} card={card} /> <GameCard key={card.id} card={card} />
))} ))}
</Section> </Section>
)} )}
</> </>
); );
} }

View File

@@ -16,7 +16,7 @@
--color-ac2: #8ec07c; --color-ac2: #8ec07c;
--app-width: 70%; --app-width: 70%;
font-size: calc((100vw / 1920) * 24); font-size: calc((100vw / 1920) * 20);
} }
[data-theme="dark"] { [data-theme="dark"] {
@@ -50,17 +50,14 @@ body {
background-color: var(--color-bg0); background-color: var(--color-bg0);
} }
@media (max-width: 1280px) {
:root {
--app-width: 100%;
}
}
@media (max-width: 1024px) { @media (max-width: 1024px) {
:root { :root {
font-size: calc((100vw / 1920) * 56); font-size: calc((100vw / 1920) * 56);
--app-width: 100%;
} }
} }
@media (max-width: 640px) { @media (max-width: 640px) {
:root { :root {
font-size: calc((100vw / 1920) * 64); font-size: calc((100vw / 1920) * 64);

View File

@@ -1,61 +1,61 @@
import { Metadata } from "next"; import { Metadata } from "next";
export const metadata: Metadata = { export const metadata: Metadata = {
title: ".Torrent: Как скачать?", title: ".Torrent: Как скачать?",
description: description:
".Torrent: Как скачать? - краткое руководство по скачиваю данных с помощью .torrent файлов", ".Torrent: Как скачать? - краткое руководство по скачиваю данных с помощью .torrent файлов",
}; };
export default async function HowToDownload() { export default async function HowToDownload() {
return ( return (
<div className="w-full flex flex-col lp:flex-row justify-between p-4"> <div className="w-full flex flex-col lp:flex-row justify-between p-4">
<div className="w-full p-4 lp:w-[50%] lp:pr-10"> <div className="w-full p-4 lp:w-[50%] lp:pr-10">
<h1 className="text-4xl lp:text-3xl">Как скачать?</h1> <h1 className="text-4xl lp:text-3xl">Как скачать?</h1>
<div className="text-fg4 text-justify pt-2"> <div className="text-fg4 text-justify pt-2">
Чтобы скачать что-либо с помощью торрент-файла, выполните следующие Чтобы скачать данные с помощью торрент-файла, выполните следующие
шаги: шаги:
<ul className="*:text-fg4"> <ul className="*:text-fg4">
<li> <li>
1. Найдите и загрузите торрент-файл или magnet-ссылку, содержащую 1. Загрузите торрент-файл, содержащий информацию о файлах, которые
информацию о файле, который вы хотите скачать. вы хотите скачать с нашего сайта.
</li> </li>
<li> <li>
2. Откройте программу-клиент для загрузки торрентов, например, 2. Откройте программу-клиент для загрузки торрентов, например,
uTorrent, BitTorrent или qBittorrent. uTorrent, BitTorrent или qBittorrent.
</li> </li>
<li> <li>
3. В программе-клиенте выберите опцию "Open Torrent File" или "Add 3. В программе-клиенте выберите опцию "Open Torrent File" или "Add
Torrent" и выберите торрент-файл, который вы скачали в первом Torrent" и выберите торрент-файл, который вы скачали в первом
шаге. шаге.
</li> </li>
<li> <li>
4. После этого начнется загрузка файлов, указанных в 4. После этого начнется загрузка файлов, указанных в
торрент-файле. Вы также можете выбрать папку, куда сохранять торрент-файле. Вы также можете выбрать папку, куда сохранять
скачанные файлы. скачанные файлы.
</li> </li>
<li> <li>
5. Дождитесь завершения загрузки файлов. После этого вы сможете 5. Дождитесь завершения загрузки файлов. После этого вы сможете
открыть и использовать скачанные файлы на своем компьютере. открыть и использовать скачанные файлы на своем компьютере.
</li> </li>
</ul> </ul>
</div> </div>
</div> </div>
<div className="w-full p-4 lp:w-[50%] lp:pl-10"> <div className="w-full p-4 lp:w-[50%] lp:pl-10">
<h2 className="text-4xl lp:text-3xl">Что такое .torrent файл?</h2> <h2 className="text-4xl lp:text-3xl">Что такое .torrent файл?</h2>
<p className="text-fg4 text-justify pt-2"> <p className="text-fg4 text-justify pt-2">
Торрент-файл (или .torrent-файл) - это небольшой файл, который Торрент-файл (или .torrent-файл) - это небольшой файл, который
содержит метаданные о файле или наборе файлов, которые можно загрузить содержит метаданные о файле или наборе файлов, которые можно загрузить
с помощью протокола BitTorrent. В торрент-файле обычно указан адрес с помощью протокола BitTorrent. В торрент-файле обычно указан адрес
трекера (специального сервера, отслеживающего пиров) и хеш-суммы трекера (специального сервера, отслеживающего пиров) и хеш-суммы
частей файлов, которые необходимы для скачивания. частей файлов, которые необходимы для скачивания.
<br /> <br />
<br /> Пользователь, желающий загрузить файл через BitTorrent, сначала <br /> Пользователь, желающий загрузить файл через BitTorrent, сначала
скачивает торрент-файл или magnet-ссылку, загружает ее в скачивает торрент-файл или magnet-ссылку, загружает ее в
торрент-клиент (программу для скачивания торрентов), и затем начинает торрент-клиент (программу для скачивания торрентов), и затем начинает
загрузку файлов, участвуя в обмене данными с другими пользователями загрузку файлов, участвуя в обмене данными с другими пользователями
(пирами) через сеть BitTorrent. (пирами) через сеть BitTorrent.
</p> </p>
</div> </div>
</div> </div>
); );
} }

View File

@@ -1,4 +1,3 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google"; import { Inter } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { ThemeProvider } from "next-themes"; import { ThemeProvider } from "next-themes";
@@ -6,28 +5,22 @@ import { Header } from "@/widgets/header";
const inter = Inter({ subsets: ["latin"] }); const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: ".Torrent",
description:
".Torrent - сервис обмена .torrent файлами видеоигр, фильмов и аудиокниг",
};
export default function RootLayout({ export default function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
// suppressHydrationWarning for theme support // suppressHydrationWarning for theme support
<html lang="ru" suppressHydrationWarning> <html lang="ru" suppressHydrationWarning>
<body className={inter.className}> <body className={inter.className}>
<ThemeProvider enableSystem={false} defaultTheme="light"> <ThemeProvider enableSystem={false} defaultTheme="light">
<Header /> <Header />
<div className="w-full h-full max-w-[var(--app-width)] m-auto"> <div className="w-full h-full max-w-[var(--app-width)] m-auto">
{children} {children}
</div> </div>
</ThemeProvider> </ThemeProvider>
</body> </body>
</html> </html>
); );
} }

View File

@@ -1,22 +1,29 @@
import { GameService } from "@/entities/game"; import { GameService } from "@/entities/game";
import { GameCard } from "@/features/gameCard"; import { GameCard } from "@/features/gameCard";
import { Section } from "@/widgets/section"; import { Section } from "@/widgets/section";
import { Metadata } from "next";
export const metadata: Metadata = {
title: ".Torrent",
description:
".Torrent - сервис обмена .torrent файлами видеоигр, фильмов и аудиокниг",
};
export default async function Home() { export default async function Home() {
const gameCards = await GameService.getGameCards(); const gameCards = await GameService.getGameCards();
return ( return (
<> <>
{gameCards && gameCards.length > 0 && ( {gameCards && gameCards.length > 0 && (
<Section <Section
name="Игры" name="Игры"
link="/games" link="/games"
invite_text={'Перейти в раздел "Игры"'} invite_text={'Перейти в раздел "Игры"'}
> >
{gameCards.map((card) => ( {gameCards.map((card) => (
<GameCard key={card.id} card={card} /> <GameCard key={card.id} card={card} />
))} ))}
</Section> </Section>
)} )}
</> </>
); );
} }

View File

@@ -3,30 +3,30 @@ import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
export const GameCard = ({ card }: { card: GameCardType }) => { export const GameCard = ({ card }: { card: GameCardType }) => {
return ( return (
<Link className="group/gamecard cursor-pointer" href={"/games/" + card.id}> <Link className="group/gamecard cursor-pointer" href={"/games/" + card.id}>
{!!card.cover_preview && ( {!!card.cover_preview && (
<Image <Image
src={card.cover_preview} src={card.cover_preview}
className="rounded-lg" className="rounded-lg object-contain"
alt="" alt=""
width={700} width={1280}
height={400} height={720}
/> />
)} )}
<div className="flex items-center justify-between pr-2"> <div className="flex items-center justify-between pr-2">
<h2 className="text-2xl py-1 group-hover/gamecard:underline underline-offset-4"> <h2 className="text-3xl tb:text-xl py-1 group-hover/gamecard:underline underline-offset-4">
{card.title} {card.title}
</h2> </h2>
{card.version && ( {card.version && (
<span className="text-xs max-w-[30%] line-clamp-2 text-fg4"> <span className="text-xs max-w-[30%] text-right line-clamp-2 text-fg4">
{card.version} {card.version}
</span> </span>
)} )}
</div> </div>
<p className="text-lg tb:text-sm pr-2 text-justify line-clamp-5 text-fg4"> <p className="text-lg tb:text-sm pr-2 text-justify line-clamp-5 text-fg4">
{card.description} {card.description}
</p> </p>
</Link> </Link>
); );
}; };

View File

@@ -0,0 +1,6 @@
export const getYouTubeID = (url: string) => {
const regExp =
/^.*((youtu.be\/)|(v\/)|(\/u\/\w\/)|(embed\/)|(watch\?))\??v?=?([^#&?]*).*/;
const match = url.match(regExp);
return match && match[7].length == 11 ? match[7] : false;
};

View File

@@ -0,0 +1,3 @@
import { getYouTubeID } from "./get_youtube_id";
export { getYouTubeID };

View File

@@ -1,62 +1,71 @@
"use client";
import { SchemeSwitch } from "@/features/colorSchemeSwitch"; import { SchemeSwitch } from "@/features/colorSchemeSwitch";
import { PersonIcon, SearchIcon } from "@/shared/assets/icons"; import { PersonIcon, SearchIcon } from "@/shared/assets/icons";
import { MobileMenu } from "./mobileMenu/mobileMenu"; import { MobileMenu } from "./mobileMenu/mobileMenu";
import Link from "next/link"; import Link from "next/link";
import { useSelectedLayoutSegment } from "next/navigation";
import clsx from "clsx";
const sections = [ const sections = [
{ title: "Игры", href: "/games" }, { title: "Игры", href: "games" },
{ title: "Фильмы", href: "/films" }, { title: "Фильмы", href: "films" },
{ title: "Аудиокниги", href: "/audiobooks" }, { title: "Аудиокниги", href: "audiobooks" },
]; ];
export const Header = () => { export const Header = () => {
return ( const currentPageName = useSelectedLayoutSegment();
<header className="w-full h-20 bg-bg1 sticky top-0">
<div return (
className="w-full h-full max-w-[var(--app-width)] m-auto px-5 <header className="w-full h-20 bg-bg1 sticky top-0 shadow-xl">
<div
className="w-full h-full max-w-[var(--app-width)] m-auto px-5
flex items-center justify-between" flex items-center justify-between"
> >
<h1 className="text-4xl font-bold flex items-center"> <h1 className="text-4xl font-bold flex items-center">
<div className="lp:hidden"> <div className="lp:hidden">
<MobileMenu sections={sections} /> <MobileMenu sections={sections} />
</div> </div>
<Link href="/">.Torrent</Link> <Link href="/">.Torrent</Link>
</h1> </h1>
<div className="hidden text-2xl dsk:block"> <div className="hidden text-2xl dsk:block">
{sections.map((section) => ( {sections.map((section) => (
<Link <Link
key={section.title} key={section.title}
className="px-5 cursor-pointer hover:underline" className={clsx(
href={section.href} "px-5 cursor-pointer hover:underline underline-offset-2",
> currentPageName === section.href && "underline"
{section.title} )}
</Link> href={section.href}
))} >
</div> {section.title}
<div className="flex flex-col items-end"> </Link>
<span className="flex items-center mb-1 "> ))}
<SchemeSwitch /> </div>
<span className="cursor-pointer flex items-center"> <div className="flex flex-col items-end">
<PersonIcon className="mr-1 h-4 w-4" /> <span className="flex items-center mb-1 ">
Войти <SchemeSwitch />
</span> <span className="cursor-pointer flex items-center">
</span> <PersonIcon className="mr-1 h-4 w-4" />
<label className="flex flex-col items-start relative w-36"> Войти
<input </span>
className="peer/search w-full rounded-lg bg-bg4 px-2" </span>
placeholder=" " <label className="flex flex-col items-start relative w-36">
/> <input
<span className="peer/search w-full rounded-lg bg-bg4 px-2"
className="peer-focus/search:opacity-0 placeholder=" "
/>
<span
className="peer-focus/search:opacity-0
peer-[:not(:placeholder-shown)]/search:opacity-0 peer-[:not(:placeholder-shown)]/search:opacity-0
transition-opacity h-0 flex items-center relative bottom-3" transition-opacity h-0 flex items-center relative bottom-3"
> >
<SearchIcon className="w-4 h-4 mx-2" /> <SearchIcon className="w-4 h-4 mx-2" />
Поиск Поиск
</span> </span>
</label> </label>
</div> </div>
</div> </div>
</header> </header>
); );
}; };

View File

@@ -2,6 +2,7 @@
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Masonry, { ResponsiveMasonry } from "react-responsive-masonry";
export const Section = ({ export const Section = ({
name, name,
@@ -17,7 +18,7 @@ export const Section = ({
const router = useRouter(); const router = useRouter();
return ( return (
<section className="w-full h-fit p-5 mb-20 pt-8"> <section className="w-full h-fit p-2 mb-20 pt-8">
{name && ( {name && (
<h2 <h2
className="text-4xl pb-2 cursor-pointer w-fit" className="text-4xl pb-2 cursor-pointer w-fit"
@@ -26,12 +27,15 @@ export const Section = ({
{name} {name}
</h2> </h2>
)} )}
<div className="grid grid-cols-1 tb:grid-cols-2 lp:grid-cols-3 gap-y-10 gap-x-3"> <ResponsiveMasonry columnsCountBreakPoints={{ 0: 1, 640: 2, 1024: 3 }}>
{children} <Masonry gutter="1rem">{children}</Masonry>
</div> </ResponsiveMasonry>
{link && invite_text && ( {link && invite_text && (
<div className="w-full flex justify-end pt-5"> <div className="w-full flex justify-end pt-5">
<Link href={link} className="text-lg"> <Link
href={link}
className="text-lg hover:underline underline-offset-4"
>
{invite_text} {invite_text}
</Link> </Link>
</div> </div>