This commit is contained in:
2024-07-09 12:10:18 +04:00
parent e42777db8d
commit 1577eabcde
55 changed files with 1779 additions and 139 deletions

6
.env.development Normal file
View File

@@ -0,0 +1,6 @@
IMAGES_PROTOCOL=https
IMAGES_DOMAIN=cdn-tp1.mozu.com
IMAGES_PORT=80
NEXT_PUBLIC_BASE_URL=http://127.0.0.1:3000
NEXT_PUBLIC_API_URL=https://jellybellywikiapi.onrender.com/api

View File

@@ -1,4 +1,14 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
const nextConfig = {
images: {
remotePatterns: [
{
protocol: process.env.IMAGES_PROTOCOL,
hostname: process.env.IMAGES_DOMAIN,
},
],
},
};
export default nextConfig;

49
package-lock.json generated
View File

@@ -8,14 +8,19 @@
"name": "jelly_belly_wiki",
"version": "0.1.0",
"dependencies": {
"clsx": "^2.1.1",
"next": "14.2.4",
"next-themes": "^0.3.0",
"react": "^18",
"react-dom": "^18"
"react-dom": "^18",
"react-responsive-masonry": "^2.2.1",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/react-responsive-masonry": "^2.1.3",
"eslint": "^8",
"eslint-config-next": "14.2.4",
"postcss": "^8",
@@ -472,6 +477,15 @@
"@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": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.2.0.tgz",
@@ -1080,6 +1094,14 @@
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -3213,6 +3235,15 @@
}
}
},
"node_modules/next-themes": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.3.0.tgz",
"integrity": "sha512-/QHIrsYpd6Kfk7xakK4svpDI5mmXP0gfvCoJdGpZQ2TOrQZmsW0QxjaiLn8wbIKjtm4BTSqLoix4lxYYOnLJ/w==",
"peerDependencies": {
"react": "^16.8 || ^17 || ^18",
"react-dom": "^16.8 || ^17 || ^18"
}
},
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
@@ -3799,6 +3830,14 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true
},
"node_modules/react-responsive-masonry": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/react-responsive-masonry/-/react-responsive-masonry-2.2.1.tgz",
"integrity": "sha512-QY1vH8vWd8YpW2g40zsFp4CjttK2NWw2btzHbxks8vDRe+0JZfsrtK7Ob3siCtg+9mttwsofmAB6dp9ujSYwKw==",
"dependencies": {
"caniuse-lite": "^1.0.30001638"
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -4895,6 +4934,14 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zod": {
"version": "3.23.8",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz",
"integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View File

@@ -9,18 +9,23 @@
"lint": "next lint"
},
"dependencies": {
"clsx": "^2.1.1",
"next": "14.2.4",
"next-themes": "^0.3.0",
"react": "^18",
"react-dom": "^18",
"next": "14.2.4"
"react-responsive-masonry": "^2.2.1",
"zod": "^3.23.8"
},
"devDependencies": {
"typescript": "^5",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/react-responsive-masonry": "^2.1.3",
"eslint": "^8",
"eslint-config-next": "14.2.4",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"eslint": "^8",
"eslint-config-next": "14.2.4"
"typescript": "^5"
}
}

View File

@@ -0,0 +1,54 @@
import { ItemService } from "@/entities/item";
import { SectionService } from "@/features/sections";
import { redirect } from "next/navigation";
import { Metadata } from "next";
import { ItemInfo } from "@/features/itemInfo";
import { ItemCard } from "@/features/itemCard";
export async function generateMetadata({
params: { section, item_id },
}: {
params: { section: string; item_id: number };
}): Promise<Metadata> {
if (!SectionService.isSection(section)) redirect("/"); //
return {
title: `JellyBelly: ${SectionService.sectionsConfiguration[section].sectionName}`,
};
}
export default async function Item({
params: { section, item_id },
}: {
params: { section: string; item_id: number };
}) {
const item = SectionService.isSection(section)
? await ItemService.itemsConfiguration[
SectionService.sectionsConfiguration[section].itemType
].service.Get(item_id)
: redirect("/");
const firstPage =
SectionService.isSection(section) &&
(await ItemService.itemsConfiguration[
SectionService.sectionsConfiguration[section].itemType
].service.GetPage(1));
return (
<>
{item && <ItemInfo item={item} />}
{SectionService.isSection(section) && firstPage && (
<>
<h2 className="text-5xl p-2 pt-8">
{SectionService.sectionsConfiguration[section].partOfSectionName}
</h2>
<div className="grid grid-cols-1 tb:grid-cols-2 lp:grid-cols-3 gap-3 px-2">
{firstPage.items.map((item) => (
<ItemCard item={item} key={ItemService.GetItemId(item)} />
))}
</div>
</>
)}
</>
);
}

View File

@@ -0,0 +1,39 @@
import { ItemService } from "@/entities/item";
import { redirect } from "next/navigation";
import { Metadata } from "next";
import { SectionService } from "@/features/sections";
import { Grid } from "@/widgets/grid";
export async function generateMetadata({
params: { section },
}: {
params: { section: string };
}): Promise<Metadata> {
if (!SectionService.isSection(section)) redirect("/");
return {
title: `JellyBelly: ${SectionService.sectionsConfiguration[section].sectionName}`,
};
}
export default async function SectionPage({
params: { section },
}: {
params: { section: string };
}) {
const pageOfItems = SectionService.isSection(section)
? await ItemService.itemsConfiguration[
SectionService.sectionsConfiguration[section].itemType
].service.GetPage(1)
: redirect("/");
return (
<>
{SectionService.isSection(section) && pageOfItems && (
<Grid
firstPage={pageOfItems}
typeOfItems={SectionService.sectionsConfiguration[section].itemType}
/>
)}
</>
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -3,31 +3,72 @@
@tailwind utilities;
:root {
--foreground-rgb: 0, 0, 0;
--background-start-rgb: 214, 219, 220;
--background-end-rgb: 255, 255, 255;
--color-bg0: #fdf6e3;
--color-bg1: #eee8d5;
--color-bg4: #839496;
--color-fg0: #002b36;
--color-fg1: #073642;
--color-fg4: #657b83;
--color-ac0: #268bd2;
--color-ac1: #859900;
--color-ac2: #b58900;
--color-err: #dc322f;
--app-width: 70%;
font-size: calc((100vw / 1920) * 20);
}
@media (prefers-color-scheme: dark) {
:root {
--foreground-rgb: 255, 255, 255;
--background-start-rgb: 0, 0, 0;
--background-end-rgb: 0, 0, 0;
}
[data-theme="dark"] {
--color-bg0: #002b36;
--color-bg1: #073642;
--color-bg4: #657b83;
--color-fg0: #fdf6e3;
--color-fg1: #eee8d5;
--color-fg4: #839496;
--color-ac0: #268bd2;
--color-ac1: #859900;
--color-ac2: #b58900;
--color-err: #dc322f;
}
html,
body {
padding: 0;
margin: 0;
width: 100%;
height: 100%;
}
body {
color: rgb(var(--foreground-rgb));
background: linear-gradient(
to bottom,
transparent,
rgb(var(--background-end-rgb))
)
rgb(var(--background-start-rgb));
transition-property: color, background-color, border-color;
transition-duration: 0.3s;
color: var(--color-fg1);
background-color: var(--color-bg0);
}
@layer utilities {
.text-balance {
text-wrap: balance;
body * {
scrollbar-color: var(--color-ac0) var(--color-bg1);
scrollbar-width: thin;
}
@media (max-width: 1024px) {
:root {
font-size: calc((100vw / 1920) * 56);
--app-width: 100%;
}
}
@media (max-width: 640px) {
:root {
font-size: calc((100vw / 1920) * 64);
}
}

View File

@@ -1,12 +1,14 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "next-themes";
import { Header } from "@/widgets/header";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Jelly Belly Wiki",
description: "Information about everything Jelly belly",
};
export default function RootLayout({
@@ -15,8 +17,16 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
// suppressHydrationWarning for theme support
<html lang="ru" suppressHydrationWarning>
<body className={inter.className}>
<ThemeProvider enableSystem={false} defaultTheme="light">
<Header />
<main className="w-full h-[calc(100%_-_5rem)] max-w-[var(--app-width)] m-auto">
{children}
</main>
</ThemeProvider>
</body>
</html>
);
}

View File

@@ -1,113 +1,67 @@
import Image from "next/image";
import { ItemService, ItemType } from "@/entities/item";
import { ItemCard } from "@/features/itemCard";
import { SectionService, SectionType } from "@/features/sections";
import { Metadata } from "next";
import Link from "next/link";
export const metadata: Metadata = {
title: ".Torrent",
description:
".Torrent - сервис обмена .torrent файлами видеоигр, фильмов и аудиокниг",
};
export default async function Home() {
const requests = SectionService.sections.map((section) =>
ItemService.itemsConfiguration[
SectionService.sectionsConfiguration[section].itemType
].service.GetPage(1)
);
const data = await Promise.all(requests);
const items = await SectionService.sections.reduce(
(cards, section, i) => ({
...cards,
[section]: data[i]?.items,
}),
{} as { [k in SectionType]: ItemType[] | null }
);
export default function Home() {
return (
<main className="flex min-h-screen flex-col items-center justify-between p-24">
<div className="z-10 w-full max-w-5xl items-center justify-between font-mono text-sm lg:flex">
<p className="fixed left-0 top-0 flex w-full justify-center border-b border-gray-300 bg-gradient-to-b from-zinc-200 pb-6 pt-8 backdrop-blur-2xl dark:border-neutral-800 dark:bg-zinc-800/30 dark:from-inherit lg:static lg:w-auto lg:rounded-xl lg:border lg:bg-gray-200 lg:p-4 lg:dark:bg-zinc-800/30">
Get started by editing&nbsp;
<code className="font-mono font-bold">src/app/page.tsx</code>
</p>
<div className="fixed bottom-0 left-0 flex h-48 w-full items-end justify-center bg-gradient-to-t from-white via-white dark:from-black dark:via-black lg:static lg:size-auto lg:bg-none">
<a
className="pointer-events-none flex place-items-center gap-2 p-8 lg:pointer-events-auto lg:p-0"
href="https://vercel.com?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
By{" "}
<Image
src="/vercel.svg"
alt="Vercel Logo"
className="dark:invert"
width={100}
height={24}
priority
/>
</a>
</div>
</div>
<div className="relative z-[-1] flex place-items-center before:absolute before:h-[300px] before:w-full before:-translate-x-1/2 before:rounded-full before:bg-gradient-radial before:from-white before:to-transparent before:blur-2xl before:content-[''] after:absolute after:-z-20 after:h-[180px] after:w-full after:translate-x-1/3 after:bg-gradient-conic after:from-sky-200 after:via-blue-200 after:blur-2xl after:content-[''] before:dark:bg-gradient-to-br before:dark:from-transparent before:dark:to-blue-700 before:dark:opacity-10 after:dark:from-sky-900 after:dark:via-[#0141ff] after:dark:opacity-40 sm:before:w-[480px] sm:after:w-[240px] before:lg:h-[360px]">
<Image
className="relative dark:drop-shadow-[0_0_0.3rem_#ffffff70] dark:invert"
src="/next.svg"
alt="Next.js Logo"
width={180}
height={37}
priority
/>
</div>
<div className="mb-32 grid text-center lg:mb-0 lg:w-full lg:max-w-5xl lg:grid-cols-4 lg:text-left">
<a
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30"
target="_blank"
rel="noopener noreferrer"
>
<h2 className="mb-3 text-2xl font-semibold">
Docs{" "}
<span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
-&gt;
</span>
</h2>
<p className="m-0 max-w-[30ch] text-sm opacity-50">
Find in-depth information about Next.js features and API.
</p>
</a>
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30"
target="_blank"
rel="noopener noreferrer"
>
<h2 className="mb-3 text-2xl font-semibold">
Learn{" "}
<span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
-&gt;
</span>
</h2>
<p className="m-0 max-w-[30ch] text-sm opacity-50">
Learn about Next.js in an interactive course with&nbsp;quizzes!
</p>
</a>
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30"
target="_blank"
rel="noopener noreferrer"
>
<h2 className="mb-3 text-2xl font-semibold">
Templates{" "}
<span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
-&gt;
</span>
</h2>
<p className="m-0 max-w-[30ch] text-sm opacity-50">
Explore starter templates for Next.js.
</p>
</a>
<a
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30"
target="_blank"
rel="noopener noreferrer"
>
<h2 className="mb-3 text-2xl font-semibold">
Deploy{" "}
<span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
-&gt;
</span>
</h2>
<p className="m-0 max-w-[30ch] text-balance text-sm opacity-50">
Instantly deploy your Next.js site to a shareable URL with Vercel.
</p>
</a>
</div>
</main>
<div className="h-full overflow-auto pb-4">
{items &&
SectionService.sections.map((section, i) => (
<section key={section}>
{items[section] && (
<>
<h2 className="text-5xl p-2 pt-8">
{
SectionService.sectionsConfiguration[section]
.partOfSectionName
}
</h2>
<div className="grid grid-cols-1 tb:grid-cols-2 lp:grid-cols-3 gap-3 px-2">
{items[section].map((item) => (
<ItemCard item={item} key={ItemService.GetItemId(item)} />
))}
</div>
<div className="w-full flex justify-end p-2">
<Link
className="text-2xl text-fg4 cursor-pointer hover:underline"
href={
"/" +
SectionService.sectionsConfiguration[section].sectionUrl
}
>
{
SectionService.sectionsConfiguration[section]
.sectionInviteText
}
</Link>
</div>
</>
)}
</section>
))}
</div>
);
}

View File

@@ -0,0 +1,19 @@
import {
beanSchema,
beansSchema,
pageOfBeansSchema,
thisItemIsBean,
type BeanType,
type PageOfBeansType,
} from "./schema";
export {
beanSchema,
beansSchema,
pageOfBeansSchema,
thisItemIsBean,
type BeanType,
type PageOfBeansType,
};
import { BeansService } from "./service";
export { BeansService };

View File

@@ -0,0 +1,51 @@
import { z } from "zod";
import { ItemType, TypesOfItems } from "../types";
export const beanSchema = z.object({
beanId: z.number(),
groupName: z.array(z.string()),
ingredients: z.array(z.string()),
flavorName: z.string(),
description: z.string(),
colorGroup: z.string(),
backgroundColor: z.string(),
imageUrl: z.string(),
glutenFree: z.boolean(),
sugarFree: z.boolean(),
seasonal: z.boolean(),
kosher: z.boolean(),
// Показывает, что этот item - bean
type: z
.any()
.optional()
.transform(() => TypesOfItems.bean),
});
export type BeanType = z.infer<typeof beanSchema>;
export const isBean = (a: any): a is BeanType => {
return beanSchema.safeParse(a).success;
};
export const beansSchema = z.array(z.any()).transform((a) => {
const beans: BeanType[] = [];
a.forEach((e) => {
if (isBean(e)) beans.push(beanSchema.parse(e));
else console.error("Bean parse error - ", e);
});
return beans;
});
export const pageOfBeansSchema = z.object({
totalCount: z.number(),
pageSize: z.number(),
currentPage: z.number(),
totalPages: z.number(),
items: beansSchema,
});
export type PageOfBeansType = z.infer<typeof pageOfBeansSchema>;
export const thisItemIsBean = (i: ItemType): i is BeanType => {
return (i as BeanType).type === TypesOfItems.bean;
};

View File

@@ -0,0 +1,29 @@
import { HTTPService } from "@/shared/utils/http";
import { IItemService, staticImplements } from "../types";
import { beanSchema, pageOfBeansSchema } from "./schema";
@staticImplements<IItemService>()
export abstract class BeansService {
public static urlPrefix = "beans";
public static cacheOptions = {
next: {
revalidate: 60 * 5,
},
};
public static async Get(id: number) {
return await HTTPService.get(
`/${this.urlPrefix}/${id}`,
beanSchema,
this.cacheOptions
);
}
public static async GetPage(page: number, pageSize?: number) {
return await HTTPService.get(
`/${this.urlPrefix}?pageIndex=${page}&pageSize=${pageSize ?? 2 * 3 * 2}`,
pageOfBeansSchema,
this.cacheOptions
);
}
}

View File

@@ -0,0 +1,19 @@
import {
combinationSchema,
combinationsSchema,
pageOfCombinationsSchema,
thisItemIsCombination,
type CombinationType,
type PageOfCombinationsType,
} from "./schema";
export {
combinationSchema,
combinationsSchema,
pageOfCombinationsSchema,
thisItemIsCombination,
type CombinationType,
type PageOfCombinationsType,
};
import { CombinationsService } from "./service";
export { CombinationsService };

View File

@@ -0,0 +1,42 @@
import { z } from "zod";
import { ItemType, TypesOfItems } from "../types";
export const combinationSchema = z.object({
combinationId: z.number(),
name: z.string(),
tag: z.array(z.string()),
// Показывает, что этот item - combination
type: z
.any()
.optional()
.transform(() => TypesOfItems.combination),
});
export type CombinationType = z.infer<typeof combinationSchema>;
export const isCombination = (a: any): a is CombinationType => {
return combinationSchema.safeParse(a).success;
};
export const combinationsSchema = z.array(z.any()).transform((a) => {
const combinations: CombinationType[] = [];
a.forEach((e) => {
if (isCombination(e)) combinations.push(combinationSchema.parse(e));
else console.error("Combination parse error - ", e);
});
return combinations;
});
export const pageOfCombinationsSchema = z.object({
totalCount: z.number(),
pageSize: z.number(),
currentPage: z.number(),
totalPages: z.number(),
items: combinationsSchema,
});
export type PageOfCombinationsType = z.infer<typeof pageOfCombinationsSchema>;
export const thisItemIsCombination = (i: ItemType): i is CombinationType => {
return (i as CombinationType).type === TypesOfItems.combination;
};

View File

@@ -0,0 +1,29 @@
import { HTTPService } from "@/shared/utils/http";
import { IItemService, staticImplements } from "../types";
import { combinationSchema, pageOfCombinationsSchema } from "./schema";
@staticImplements<IItemService>()
export abstract class CombinationsService {
public static urlPrefix = "combinations";
public static cacheOptions = {
next: {
revalidate: 60 * 5,
},
};
public static async Get(id: number) {
return await HTTPService.get(
`/${this.urlPrefix}/${id}`,
combinationSchema,
this.cacheOptions
);
}
public static async GetPage(page: number, pageSize?: number) {
return await HTTPService.get(
`/${this.urlPrefix}?pageIndex=${page}&pageSize=${pageSize ?? 2 * 3 * 3}`,
pageOfCombinationsSchema,
this.cacheOptions
);
}
}

View File

@@ -0,0 +1,19 @@
import {
factSchema,
factsSchema,
pageOfFactsSchema,
thisItemIsFact,
type FactType,
type PageOfFactsType,
} from "./schema";
export {
factSchema,
factsSchema,
pageOfFactsSchema,
thisItemIsFact,
type FactType,
type PageOfFactsType,
};
import { FactsService } from "./service";
export { FactsService };

View File

@@ -0,0 +1,42 @@
import { z } from "zod";
import { ItemType, TypesOfItems } from "../types";
export const factSchema = z.object({
factId: z.number(),
title: z.string(),
description: z.string(),
// Показывает, что этот item - fact
type: z
.any()
.optional()
.transform(() => TypesOfItems.fact),
});
export type FactType = z.infer<typeof factSchema>;
export const isFact = (a: any): a is FactType => {
return factSchema.safeParse(a).success;
};
export const factsSchema = z.array(z.any()).transform((a) => {
const facts: FactType[] = [];
a.forEach((e) => {
if (isFact(e)) facts.push(factSchema.parse(e));
else console.error("Fact parse error - ", e);
});
return facts;
});
export const pageOfFactsSchema = z.object({
totalCount: z.number(),
pageSize: z.number(),
currentPage: z.number(),
totalPages: z.number(),
items: factsSchema,
});
export type PageOfFactsType = z.infer<typeof pageOfFactsSchema>;
export const thisItemIsFact = (i: ItemType): i is FactType => {
return (i as FactType).type === TypesOfItems.fact;
};

View File

@@ -0,0 +1,29 @@
import { HTTPService } from "@/shared/utils/http";
import { IItemService, staticImplements } from "../types";
import { factSchema, pageOfFactsSchema } from "./schema";
@staticImplements<IItemService>()
export abstract class FactsService {
public static urlPrefix = "facts";
public static cacheOptions = {
next: {
revalidate: 60 * 5,
},
};
public static async Get(id: number) {
return await HTTPService.get(
`/${this.urlPrefix}/${id}`,
factSchema,
this.cacheOptions
);
}
public static async GetPage(page: number, pageSize?: number) {
return await HTTPService.get(
`/${this.urlPrefix}?pageIndex=${page}&pageSize=${pageSize ?? 2 * 3 * 3}`,
pageOfFactsSchema,
this.cacheOptions
);
}
}

View File

@@ -0,0 +1,22 @@
import { thisItemIsBean, type BeanType } from "./beans";
export { thisItemIsBean, type BeanType };
import { thisItemIsFact, type FactType } from "./facts";
export { thisItemIsFact, type FactType };
import { thisItemIsRecipe, type RecipeType } from "./recipes";
export { thisItemIsRecipe, type RecipeType };
import { thisItemIsCombination, type CombinationType } from "./combinations";
export { thisItemIsCombination, type CombinationType };
import { ItemService } from "./item";
export { ItemService };
import {
TypesOfItems,
type IItemService,
type ItemType,
type PageOfItemsType,
} from "./types";
export { TypesOfItems, type IItemService, type ItemType, type PageOfItemsType };

49
src/entities/item/item.ts Normal file
View File

@@ -0,0 +1,49 @@
import { BeansService, thisItemIsBean } from "./beans";
import { CombinationsService, thisItemIsCombination } from "./combinations";
import { FactsService, thisItemIsFact } from "./facts";
import { MileStonesService, thisItemIsMileStone } from "./mileStones";
import { RecipesService, thisItemIsRecipe } from "./recipes";
import { IItemService, ItemType, TypesOfItems } from "./types";
export abstract class ItemService {
static get itemsConfiguration(): {
[k in TypesOfItems]: {
service: IItemService;
};
} {
return {
[TypesOfItems.bean]: {
service: BeansService,
},
[TypesOfItems.fact]: {
service: FactsService,
},
[TypesOfItems.recipe]: {
service: RecipesService,
},
[TypesOfItems.combination]: {
service: CombinationsService,
},
[TypesOfItems.mileStone]: {
service: MileStonesService,
},
};
}
public static GetTypeOfItem(i: ItemType): TypesOfItems {
if (thisItemIsBean(i)) return TypesOfItems.bean;
if (thisItemIsFact(i)) return TypesOfItems.fact;
if (thisItemIsRecipe(i)) return TypesOfItems.recipe;
if (thisItemIsCombination(i)) return TypesOfItems.combination;
if (thisItemIsMileStone(i)) return TypesOfItems.mileStone;
throw Error("unknown Item");
}
public static GetItemId(i: ItemType): number {
if (thisItemIsBean(i)) return i.beanId;
if (thisItemIsFact(i)) return i.factId;
if (thisItemIsRecipe(i)) return i.recipeId;
if (thisItemIsCombination(i)) return i.combinationId;
if (thisItemIsMileStone(i)) return i.mileStoneId;
throw Error("unknown Item");
}
}

View File

@@ -0,0 +1,19 @@
import {
mileStoneSchema,
mileStonesSchema,
pageOfMileStonesSchema,
thisItemIsMileStone,
type MileStoneType,
type PageOfMileStonesType,
} from "./schema";
export {
mileStoneSchema,
mileStonesSchema,
pageOfMileStonesSchema,
thisItemIsMileStone,
type MileStoneType,
type PageOfMileStonesType,
};
import { MileStonesService } from "./service";
export { MileStonesService };

View File

@@ -0,0 +1,42 @@
import { z } from "zod";
import { ItemType, TypesOfItems } from "../types";
export const mileStoneSchema = z.object({
mileStoneId: z.number(),
year: z.number(),
description: z.string(),
// Показывает, что этот item - mileStone
type: z
.any()
.optional()
.transform(() => TypesOfItems.mileStone),
});
export type MileStoneType = z.infer<typeof mileStoneSchema>;
export const isMileStone = (a: any): a is MileStoneType => {
return mileStoneSchema.safeParse(a).success;
};
export const mileStonesSchema = z.array(z.any()).transform((a) => {
const mileStones: MileStoneType[] = [];
a.forEach((e) => {
if (isMileStone(e)) mileStones.push(mileStoneSchema.parse(e));
else console.error("MileStone parse error - ", e);
});
return mileStones;
});
export const pageOfMileStonesSchema = z.object({
totalCount: z.number(),
pageSize: z.number(),
currentPage: z.number(),
totalPages: z.number(),
items: mileStonesSchema,
});
export type PageOfMileStonesType = z.infer<typeof pageOfMileStonesSchema>;
export const thisItemIsMileStone = (i: ItemType): i is MileStoneType => {
return (i as MileStoneType).type === TypesOfItems.mileStone;
};

View File

@@ -0,0 +1,29 @@
import { HTTPService } from "@/shared/utils/http";
import { IItemService, staticImplements } from "../types";
import { mileStoneSchema, pageOfMileStonesSchema } from "./schema";
@staticImplements<IItemService>()
export abstract class MileStonesService {
public static urlPrefix = "mileStones";
public static cacheOptions = {
next: {
revalidate: 60 * 5,
},
};
public static async Get(id: number) {
return await HTTPService.get(
`/${this.urlPrefix}/${id}`,
mileStoneSchema,
this.cacheOptions
);
}
public static async GetPage(page: number, pageSize?: number) {
return await HTTPService.get(
`/${this.urlPrefix}?pageIndex=${page}&pageSize=${pageSize ?? 2 * 3 * 3}`,
pageOfMileStonesSchema,
this.cacheOptions
);
}
}

View File

@@ -0,0 +1,19 @@
import {
recipeSchema,
recipesSchema,
pageOfRecipesSchema,
thisItemIsRecipe,
type RecipeType,
type PageOfRecipesType,
} from "./schema";
export {
recipeSchema,
recipesSchema,
pageOfRecipesSchema,
thisItemIsRecipe,
type RecipeType,
type PageOfRecipesType,
};
import { RecipesService } from "./service";
export { RecipesService };

View File

@@ -0,0 +1,53 @@
import { z } from "zod";
import { ItemType, TypesOfItems } from "../types";
export const recipeSchema = z.object({
recipeId: z.number(),
name: z.string(),
description: z.string(),
prepTime: z.string(),
cookTime: z.string(),
totalTime: z.string(),
makingAmount: z.string(),
imageUrl: z.string(),
ingredients: z.array(z.string()),
additions1: z.array(z.string()),
additions2: z.array(z.unknown()),
additions3: z.array(z.unknown()),
directions: z.array(z.string()),
tips: z.array(z.string()),
// Показывает, что этот item - recipe
type: z
.any()
.optional()
.transform(() => TypesOfItems.recipe),
});
export type RecipeType = z.infer<typeof recipeSchema>;
export const isRecipe = (a: any): a is RecipeType => {
return recipeSchema.safeParse(a).success;
};
export const recipesSchema = z.array(z.any()).transform((a) => {
const recipes: RecipeType[] = [];
a.forEach((e) => {
if (isRecipe(e)) recipes.push(recipeSchema.parse(e));
else console.error("Recipe parse error - ", e);
});
return recipes;
});
export const pageOfRecipesSchema = z.object({
totalCount: z.number(),
pageSize: z.number(),
currentPage: z.number(),
totalPages: z.number(),
items: recipesSchema,
});
export type PageOfRecipesType = z.infer<typeof pageOfRecipesSchema>;
export const thisItemIsRecipe = (i: ItemType): i is RecipeType => {
return (i as RecipeType).type === TypesOfItems.recipe;
};

View File

@@ -0,0 +1,29 @@
import { HTTPService } from "@/shared/utils/http";
import { IItemService, staticImplements } from "../types";
import { recipeSchema, pageOfRecipesSchema } from "./schema";
@staticImplements<IItemService>()
export abstract class RecipesService {
public static urlPrefix = "recipes";
public static cacheOptions = {
next: {
revalidate: 60 * 5,
},
};
public static async Get(id: number) {
return await HTTPService.get(
`/${this.urlPrefix}/${id}`,
recipeSchema,
this.cacheOptions
);
}
public static async GetPage(page: number, pageSize?: number) {
return await HTTPService.get(
`/${this.urlPrefix}?pageIndex=${page}&pageSize=${pageSize ?? 2 * 3 * 2}`,
pageOfRecipesSchema,
this.cacheOptions
);
}
}

View File

@@ -0,0 +1,37 @@
import { BeanType, PageOfBeansType } from "./beans";
import { CombinationType, PageOfCombinationsType } from "./combinations";
import { FactType, PageOfFactsType } from "./facts";
import { MileStoneType, PageOfMileStonesType } from "./mileStones";
import { PageOfRecipesType, RecipeType } from "./recipes";
export type ItemType =
| BeanType
| FactType
| RecipeType
| CombinationType
| MileStoneType;
export type PageOfItemsType =
| PageOfBeansType
| PageOfFactsType
| PageOfRecipesType
| PageOfCombinationsType
| PageOfMileStonesType;
export enum TypesOfItems {
bean,
fact,
recipe,
combination,
mileStone,
}
export interface IItemService {
urlPrefix: string;
Get(id: number): Promise<ItemType | null>;
GetPage(page: number, pageSize?: number): Promise<PageOfItemsType | null>;
}
export const staticImplements =
<T>() =>
<U extends T>(constructor: U) =>
constructor;

View File

@@ -0,0 +1,17 @@
"use client";
import { SunIcon } from "@/shared/assets/icons";
import { useTheme } from "next-themes";
export const ColorSchemeSwitch = () => {
const { theme, setTheme } = useTheme();
return (
<>
<SunIcon
className="mr-5 h-8 w-8 cursor-pointer"
onClick={() => setTheme(theme == "light" ? "dark" : "light")}
/>
</>
);
};

View File

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

View File

@@ -0,0 +1,41 @@
import { BeanType, TypesOfItems } from "@/entities/item";
import Link from "next/link";
import { SectionService } from "@/features/sections";
import Image from "next/image";
import React from "react";
export const BeanCard = React.forwardRef<HTMLDivElement, { item: BeanType }>(
({ item: bean }, ref) => {
return (
<div ref={ref} className="p-4 shadow-md rounded-lg h-full w-full">
<Link
className="group/itemcard cursor-pointer flex flex-col justify-evenly h-full"
href={
"/" +
SectionService.sectionsConfiguration[
SectionService.itemTypeToSection[TypesOfItems.bean]
].sectionUrl +
"/" +
bean.beanId
}
>
<Image
src={bean.imageUrl}
alt=""
className="w-full rounded-lg object-contain p-4"
width={1280}
height={720}
/>
<div className="flex items-center justify-between pr-2">
<h2 className="text-3xl tb:text-xl py-1 group-hover/itemcard:underline underline-offset-1">
{bean.flavorName}
</h2>
</div>
<p className="text-lg tb:text-sm pr-2 text-justify line-clamp-5 text-fg4">
{bean.description}
</p>
</Link>
</div>
);
}
);

View File

@@ -0,0 +1,25 @@
import { CombinationType, TypesOfItems } from "@/entities/item";
import Link from "next/link";
import { SectionService } from "@/features/sections";
import Image from "next/image";
import React from "react";
export const CombinationCard = React.forwardRef<
HTMLDivElement,
{ item: CombinationType }
>(({ item: combination }, ref) => {
return (
<div ref={ref} className="p-4 shadow-md rounded-lg h-full w-full">
<div className="flex flex-col justify-between h-full">
<div className="flex items-center justify-between pr-2">
<h2 className="text-3xl tb:text-xl py-1 underline-offset-1">
{combination.name}
</h2>
</div>
<p className="text-lg tb:text-sm pr-2 text-justify line-clamp-5 text-fg4">
{combination.tag.join(" ")}
</p>
</div>
</div>
);
});

View File

@@ -0,0 +1,27 @@
import { FactType, TypesOfItems } from "@/entities/item";
import Link from "next/link";
import { SectionService } from "@/features/sections";
import Image from "next/image";
import React from "react";
export const FactCard = React.forwardRef<HTMLDivElement, { item: FactType }>(
({ item: fact }, ref) => {
return (
<div
ref={ref}
className="p-4 shadow-lg shadow-bg1 rounded-lg h-full w-full"
>
<div className="flex flex-col justify-evenly h-full">
<div className="flex items-center justify-between pr-2">
<h2 className="text-3xl tb:text-xl py-1 underline-offset-1">
{fact.title}
</h2>
</div>
<p className="text-lg tb:text-sm pr-2 text-justify line-clamp-5 text-fg4">
{fact.description}
</p>
</div>
</div>
);
}
);

View File

@@ -0,0 +1,2 @@
import { ItemCard } from "./itemCard";
export { ItemCard };

View File

@@ -0,0 +1,39 @@
import {
BeanType,
CombinationType,
FactType,
ItemService,
ItemType,
RecipeType,
TypesOfItems,
} from "@/entities/item";
import { BeanCard } from "./beanCard";
import React from "react";
import { FactCard } from "./factCard";
import { RecipeCard } from "./recipeCard";
import { CombinationCard } from "./combinationCard";
import { MileStoneCard } from "./mileStoneCard";
import { MileStoneType } from "@/entities/item/mileStones";
const ItemTypeToCard = (
item: ItemType,
ref: React.ForwardedRef<HTMLDivElement>
) => {
return {
[TypesOfItems.bean]: <BeanCard item={item as BeanType} ref={ref} />,
[TypesOfItems.fact]: <FactCard item={item as FactType} ref={ref} />,
[TypesOfItems.recipe]: <RecipeCard item={item as RecipeType} ref={ref} />,
[TypesOfItems.combination]: (
<CombinationCard item={item as CombinationType} ref={ref} />
),
[TypesOfItems.mileStone]: (
<MileStoneCard item={item as MileStoneType} ref={ref} />
),
}[ItemService.GetTypeOfItem(item)];
};
export const ItemCard = React.forwardRef<HTMLDivElement, { item: ItemType }>(
({ item }, ref) => {
return ItemTypeToCard(item, ref);
}
);

View File

@@ -0,0 +1,26 @@
import { TypesOfItems } from "@/entities/item";
import Link from "next/link";
import { SectionService } from "@/features/sections";
import Image from "next/image";
import React from "react";
import { MileStoneType } from "@/entities/item/mileStones";
export const MileStoneCard = React.forwardRef<
HTMLDivElement,
{ item: MileStoneType }
>(({ item: mileStone }, ref) => {
return (
<div ref={ref} className="p-4 shadow-md rounded-lg h-full w-full">
<div className="flex flex-col justify-between h-full">
<div className="flex items-center justify-between pr-2">
<h2 className="text-3xl tb:text-xl py-1 underline-offset-1">
{mileStone.year}
</h2>
</div>
<p className="text-lg tb:text-sm pr-2 text-justify line-clamp-5 text-fg4">
{mileStone.description}
</p>
</div>
</div>
);
});

View File

@@ -0,0 +1,42 @@
import { BeanType, RecipeType, TypesOfItems } from "@/entities/item";
import Link from "next/link";
import { SectionService } from "@/features/sections";
import Image from "next/image";
import React from "react";
export const RecipeCard = React.forwardRef<
HTMLDivElement,
{ item: RecipeType }
>(({ item: recipe }, ref) => {
return (
<div ref={ref} className="p-4 shadow-md rounded-lg h-full w-full">
<Link
className="group/itemcard cursor-pointer flex flex-col justify-between h-full"
href={
"/" +
SectionService.sectionsConfiguration[
SectionService.itemTypeToSection[TypesOfItems.recipe]
].sectionUrl +
"/" +
recipe.recipeId
}
>
<Image
src={recipe.imageUrl}
alt=""
className="w-full rounded-lg object-cover aspect-video"
width={1280}
height={720}
/>
<div className="flex items-center justify-between pr-2">
<h2 className="text-3xl tb:text-base py-1 group-hover/itemcard:underline underline-offset-1">
{recipe.name}
</h2>
</div>
<p className="text-lg tb:text-sm pr-2 text-justify line-clamp-5 text-fg4">
{recipe.description}
</p>
</Link>
</div>
);
});

View File

@@ -0,0 +1,71 @@
import { BeanType } from "@/entities/item";
import { CheckIcon, CrossIcon } from "@/shared/assets/icons";
import Image from "next/image";
const BeanPropertyDescription: { [k in keyof BeanType]?: string } = {
glutenFree: "Gluten free",
sugarFree: "Sugar free",
seasonal: "Seasonal",
kosher: "Kosher",
};
export const BeanInfo = ({ item: bean }: { item: BeanType }) => {
return (
<div className="w-full float-start flex flex-col lp:flex-row">
<Image
src={bean.imageUrl}
alt=""
width={1280}
height={720}
className="w-full lp:max-w-[50%] object-contain float-left p-4"
/>
<div className="px-4 mb-10">
<h1 className="text-4xl pt-4 pb-1">{bean.flavorName}</h1>
<span className="text-fg4">{bean.description}</span>
<div className="py-1 flex justify-between">
<div className="w-1/2">
Ingredients:
<ul>
{bean.ingredients.length > 0 &&
bean.ingredients.map((ingredient) => (
<li className="text-sm tb:text-xs">- {ingredient}</li>
))}
</ul>
{bean.ingredients.length == 0 && (
<span className="text-xl text-fg4">Classified</span>
)}
</div>
<div className="text-fg1 w-[40%]">
<div className="py-2">
In theese groups:
<ul>
{bean.groupName.map((group) => (
<li className="text-sm">- {group}</li>
))}
</ul>
</div>
<div className="py-2">
Properties:
<ul>
{(
Object.keys(
BeanPropertyDescription
) as (keyof typeof BeanPropertyDescription)[]
).map((property) => (
<li className="text-sm flex items-center pt-1">
- {BeanPropertyDescription[property]}:{" "}
{bean[property] ? (
<CheckIcon className="h-5 pl-2" />
) : (
<CrossIcon className="h-5 pl-2" />
)}
</li>
))}
</ul>
</div>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,2 @@
import { ItemInfo } from "./itemInfo";
export { ItemInfo };

View File

@@ -0,0 +1,32 @@
import {
BeanType,
ItemService,
ItemType,
RecipeType,
TypesOfItems,
} from "@/entities/item";
import React from "react";
import { redirect } from "next/navigation";
import { SectionService } from "../sections";
import { BeanInfo } from "./beanInfo";
import { RecipeInfo } from "./recipeInfo";
const ItemTypeToInfo = (item: ItemType) => {
const ItemInfoComponents = {
[TypesOfItems.bean]: <BeanInfo item={item as BeanType} />,
[TypesOfItems.recipe]: <RecipeInfo item={item as RecipeType} />,
};
const typeOfItem = ItemService.GetTypeOfItem(item);
if (typeOfItem in ItemInfoComponents)
return ItemInfoComponents[typeOfItem as keyof typeof ItemInfoComponents];
redirect(
"/" +
SectionService.sectionsConfiguration[
SectionService.itemTypeToSection[ItemService.GetTypeOfItem(item)]
].sectionUrl
);
};
export const ItemInfo = ({ item }: { item: ItemType }) => {
return ItemTypeToInfo(item);
};

View File

@@ -0,0 +1,102 @@
import { RecipeType } from "@/entities/item";
import { CheckIcon, CrossIcon } from "@/shared/assets/icons";
import Image from "next/image";
const RecipePropertyDescription: { [k in keyof RecipeType]?: string } = {
prepTime: "Prepare time",
cookTime: "Cook time",
totalTime: "Total time",
};
const RecipeIngredientsDescription: { [k in keyof RecipeType]?: string } = {
ingredients: "Ingredients",
additions1: "Additional ingredients",
additions2: "Optional additives",
additions3: "You can add these products",
};
const RecipeСookingDescription: { [k in keyof RecipeType]?: string } = {
directions: "Directions",
tips: "Tips",
};
export const RecipeInfo = ({ item: recipe }: { item: RecipeType }) => {
return (
<div className="w-full flex flex-col lp:flex-row mb-10 px-4">
<Image
src={recipe.imageUrl}
alt=""
width={1280}
height={720}
className="w-full lp:max-w-[50%] object-cover rounded-lg mt-4"
/>
<div className="lp:pl-4 text-fg1">
<h1 className="text-4xl pt-4 pb-1">{recipe.name}</h1>
<span className="text-fg4">{recipe.description}</span>
<div className="py-1 flex flex-col-reverse tb:flex-row tb:justify-between">
<div className="w-full tb:w-[60%]">
{(
Object.keys(
RecipeСookingDescription
) as (keyof typeof RecipeСookingDescription)[]
).map((property) => (
<>
<span className="text-lg">
{(recipe[property] as string[]).length > 0 &&
RecipeСookingDescription[property] + ":"}
</span>
<ul>
{(recipe[property] as string[]).length > 0 &&
(recipe[property] as string[]).map((ingredient) => (
<li className="text-sm tb:text-xs">- {ingredient}</li>
))}
</ul>
</>
))}
</div>
<div className=" w-full tb:w-[35%]">
<div className="py-2">
<span className="text-lg">How long does it take:</span>
<ul>
{(
Object.keys(
RecipePropertyDescription
) as (keyof typeof RecipePropertyDescription)[]
).map((property) => (
<>
{
<li className="text-sm flex items-center pt-1">
{`- ${RecipePropertyDescription[property]}: ${recipe[property]}`}
{recipe[property] == "" && (
<span className="text-fg4 pl-1">Classified</span>
)}
</li>
}
</>
))}
</ul>
</div>
<div className="py-2">
{(
Object.keys(
RecipeIngredientsDescription
) as (keyof typeof RecipeIngredientsDescription)[]
).map((property) => (
<>
<span className="text-lg">
{(recipe[property] as string[]).length > 0 &&
RecipeIngredientsDescription[property] + ":"}
</span>
<ul>
{(recipe[property] as string[]).length > 0 &&
(recipe[property] as string[]).map((ingredient) => (
<li className="text-sm tb:text-xs">- {ingredient}</li>
))}
</ul>
</>
))}
</div>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,2 @@
import { SectionService, type SectionType } from "./sections";
export { SectionService, type SectionType };

View File

@@ -0,0 +1,75 @@
import { TypesOfItems } from "@/entities/item";
export type SectionType = (typeof SectionService.sections)[number];
export abstract class SectionService {
static get itemTypeToSection(): { [k in TypesOfItems]: SectionType } {
return {
[TypesOfItems.bean]: "beans",
[TypesOfItems.fact]: "facts",
[TypesOfItems.recipe]: "recipes",
[TypesOfItems.combination]: "combinations",
[TypesOfItems.mileStone]: "history",
};
}
static get sectionsConfiguration(): {
[k in SectionType]: {
sectionName: string;
sectionUrl: string;
itemType: TypesOfItems;
partOfSectionName: string;
sectionInviteText: string;
};
} {
return {
beans: {
sectionName: "Beans",
sectionUrl: "beans",
itemType: TypesOfItems.bean,
partOfSectionName: "Some beans",
sectionInviteText: 'Go to section "Beans"',
},
facts: {
sectionName: "Facts",
sectionUrl: "facts",
itemType: TypesOfItems.fact,
partOfSectionName: "Some facts",
sectionInviteText: 'Go to section "Facts"',
},
recipes: {
sectionName: "Recipes",
sectionUrl: "recipes",
itemType: TypesOfItems.recipe,
partOfSectionName: "Some recipes",
sectionInviteText: 'Go to section "Recipes"',
},
combinations: {
sectionName: "Combinations",
sectionUrl: "combinations",
itemType: TypesOfItems.combination,
partOfSectionName: "Some combinations",
sectionInviteText: 'Go to section "Combinations"',
},
history: {
sectionName: "History",
sectionUrl: "history",
itemType: TypesOfItems.mileStone,
partOfSectionName: "Some history",
sectionInviteText: 'Go to section "History"',
},
};
}
static sections = [
"beans",
"facts",
"recipes",
"combinations",
"history",
] as const;
static isSection = (a: string): a is SectionType => {
return this.sections.includes(a as SectionType);
};
}

View File

@@ -0,0 +1,26 @@
export const CheckIcon = ({ className }: { className?: string }) => {
return (
<svg
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
fill="none"
className={className}
>
<g id="SVGRepo_bgCarrier" strokeWidth="0"></g>
<g
id="SVGRepo_tracerCarrier"
stroke-linecap="round"
stroke-linejoin="round"
></g>
<g id="SVGRepo_iconCarrier">
<path
stroke="var(--color-ac0)"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M17 5L8 15l-5-4"
></path>{" "}
</g>
</svg>
);
};

View File

@@ -0,0 +1,24 @@
export const CrossIcon = ({ className }: { className?: string }) => {
return (
<svg
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
fill="none"
className={className}
>
<g id="SVGRepo_bgCarrier" stroke-width="0"></g>
<g
id="SVGRepo_tracerCarrier"
stroke-linecap="round"
stroke-linejoin="round"
></g>
<g id="SVGRepo_iconCarrier">
<path
fill="var(--color-err)"
fill-rule="evenodd"
d="M16.293 17.707a1 1 0 001.414-1.414L11.414 10l6.293-6.293a1 1 0 00-1.414-1.414L10 8.586 3.707 2.293a1 1 0 00-1.414 1.414L8.586 10l-6.293 6.293a1 1 0 101.414 1.414L10 11.414l6.293 6.293z"
></path>
</g>
</svg>
);
};

View File

@@ -0,0 +1,5 @@
import { SunIcon } from "./sunIcon";
import { CheckIcon } from "./check";
import { CrossIcon } from "./cross";
export { SunIcon, CheckIcon, CrossIcon };

View File

@@ -0,0 +1,31 @@
export const SunIcon = ({
className,
onClick,
}: {
className?: string;
onClick?: () => void;
}) => {
return (
<svg
width="800px"
height="800px"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
onClick={onClick}
>
<path
d="M12 3V4M12 20V21M4 12H3M6.31412 6.31412L5.5 5.5M17.6859
6.31412L18.5 5.5M6.31412 17.69L5.5 18.5001M17.6859
17.69L18.5 18.5001M21 12H20M16 12C16 14.2091 14.2091
16 12 16C9.79086 16 8 14.2091 8 12C8 9.79086 9.79086
8 12 8C14.2091 8 16 9.79086 16 12Z"
stroke="var(--color-fg1)"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
};

93
src/shared/utils/http.ts Normal file
View File

@@ -0,0 +1,93 @@
import { z } from "zod";
export type RequestCacheOptions = {
cache?: RequestCache;
next?: NextFetchRequestConfig;
};
type GetRequestOptions = RequestCacheOptions & {
headers?: HeadersInit;
};
type RequestOptions = GetRequestOptions & {
body?: BodyInit | object;
stringify?: boolean;
};
export abstract class HTTPService {
private static deepUndefinedToNull(o?: object): object | undefined {
if (Array.isArray(o)) return o;
if (o)
return Object.fromEntries(
Object.entries(o).map(([k, v]) => {
if (v === undefined) return [k, null];
if (typeof v === "object") return [k, this.deepUndefinedToNull(v)];
return [k, v];
})
);
}
public static async request<Z extends z.ZodTypeAny>(
method: "GET" | "POST" | "PUT" | "DELETE",
url: string,
schema: Z,
options?: RequestOptions
) {
return await fetch(process.env.NEXT_PUBLIC_API_URL + url, {
method: method,
headers: {
accept: "application/json",
...((options?.stringify ?? true) != true
? {}
: { "Content-Type": "application/json" }),
...options?.headers,
},
body:
(options?.stringify ?? true) != true
? (options?.body as BodyInit)
: JSON.stringify(
this.deepUndefinedToNull(options?.body as object | undefined)
),
cache: options?.cache ?? options?.next ? undefined : "no-cache",
next: options?.next ?? {},
})
.then((r) => {
if (r && r.ok) return r;
else throw Error("Response ok = false");
})
.then((r) => r.json())
.then((d) => {
const parsed = schema.safeParse(d);
if (parsed.success) return parsed.data as z.infer<Z>;
else throw new Error(parsed.error.message);
})
.catch((e) => {
console.error(e);
return null;
});
}
public static async get<Z extends z.ZodTypeAny>(
url: string,
schema: Z,
options?: GetRequestOptions
) {
return await this.request<Z>("GET", url, schema, options);
}
public static async post<Z extends z.ZodTypeAny>(
url: string,
schema: Z,
options?: RequestOptions
) {
return await this.request<Z>("POST", url, schema, options);
}
public static async put<Z extends z.ZodType>(
url: string,
schema: Z,
options?: RequestOptions
) {
return await this.request<Z>("PUT", url, schema, options);
}
}

64
src/widgets/grid/grid.tsx Normal file
View File

@@ -0,0 +1,64 @@
"use client";
import {
ItemService,
ItemType,
PageOfItemsType,
TypesOfItems,
} from "@/entities/item";
import { ItemCard } from "@/features/itemCard";
import React, { useRef } from "react";
import { useState } from "react";
import Masonry, { ResponsiveMasonry } from "react-responsive-masonry";
export const Grid = <U extends ItemType>({
firstPage,
typeOfItems,
}: {
firstPage: PageOfItemsType;
typeOfItems: TypesOfItems;
}) => {
const [pageIndex, changePageIndex] = useState(1);
const [totalPages, _] = useState(firstPage.totalPages);
const [items, changeItems] = useState(firstPage.items as U[]);
const [loadingPage, changeLoadingPage] = useState(false);
const firstItemRef = useRef<HTMLDivElement>(null);
const handleGridScroll = async (
e: React.UIEvent<HTMLDivElement, UIEvent>
) => {
const { scrollTop, scrollHeight, clientHeight } =
e.target as HTMLDivElement;
if ((scrollTop + clientHeight) / scrollHeight > 0.5) {
if (!loadingPage) {
changeLoadingPage(true);
setTimeout(() => changeLoadingPage(false), 1000);
if (pageIndex >= totalPages) return;
const nextPage = await ItemService.itemsConfiguration[
typeOfItems
].service.GetPage(pageIndex + 1);
if (nextPage) {
changePageIndex(pageIndex + 1);
changeItems([...items, ...(nextPage.items as U[])]);
console.log("new page");
}
}
}
};
return (
<div
className="grid grid-cols-1 tb:grid-cols-2 lp:grid-cols-3 gap-3 p-2 h-full overflow-auto"
onScroll={handleGridScroll}
>
{items.length > 0 && <ItemCard item={items[0]} ref={firstItemRef} />}
{items.length > 1 &&
items
.slice(1)
.map((item, i) => (
<ItemCard item={item} key={ItemService.GetItemId(item)} />
))}
</div>
);
};

View File

@@ -0,0 +1,2 @@
import { Grid } from "./grid";
export { Grid };

View File

@@ -0,0 +1,59 @@
"use client";
import { MobileMenu } from "./mobileMenu/mobileMenu";
import Link from "next/link";
import { useSelectedLayoutSegment } from "next/navigation";
import clsx from "clsx";
import { SectionService } from "@/features/sections";
import { ColorSchemeSwitch } from "@/features/colorSchemeSwitch";
export const Header = () => {
const currentPageName = useSelectedLayoutSegment();
return (
<header className="w-full h-20 z-10 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"
>
<h1 className="text-4xl font-bold flex items-center">
<div className="lp:hidden">
<MobileMenu />
</div>
<Link href="/">JellyBelly</Link>
</h1>
<div className="hidden text-2xl lp:block">
{SectionService.sections.map((section) => (
<Link
key={section}
className={clsx(
"px-5 cursor-pointer hover:underline underline-offset-2",
currentPageName === section && "underline"
)}
href={"/" + section}
>
{SectionService.sectionsConfiguration[section].sectionName}
</Link>
))}
</div>
<ColorSchemeSwitch />
{/* <label className="flex flex-col items-start relative w-36">
<input
type="search"
className="peer/search w-full rounded-lg bg-bg4 px-2"
placeholder=" "
/>
<span
className="peer-focus/search:opacity-0
peer-[:not(:placeholder-shown)]/search:opacity-0
transition-opacity h-0 flex items-center relative bottom-3"
>
<SearchIcon className="w-4 h-4 mx-2" />
Поиск
</span>
</label> */}
</div>
</header>
);
};

View File

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

View File

@@ -0,0 +1,50 @@
"use client";
import { SectionService } from "@/features/sections";
import clsx from "clsx";
import Link from "next/link";
import { useState } from "react";
export const MobileMenu = () => {
const [open, changeMenuOpen] = useState<boolean>(false);
return (
<div className="relative">
<button
className="w-16 h-16 *:w-12 *:h-1 *:bg-fg1 *:my-3
*:transition-all *:duration-300 *:relative"
onClick={(e) => {
changeMenuOpen(!open);
e.stopPropagation();
}}
onBlur={() => changeMenuOpen(false)}
>
<div
className={clsx(open && "rotate-45 top-4", !open && "top-0")}
></div>
<div className={clsx(open && "opacity-0")}></div>
<div
className={clsx(open && "-rotate-45 bottom-4", !open && "bottom-0")}
></div>
</button>
<div
className={clsx(
"h-0 absolute transition-all duration-300 overflow-hidden\
bg-bg4 rounded-lg px-4 flex flex-col shadow-xl",
open && "h-56"
)}
onClick={() => changeMenuOpen(false)}
>
{SectionService.sections.map((section) => (
<Link
key={section}
className="text-xl py-2 cursor-pointer hover:underline"
href={"/" + section}
>
{SectionService.sectionsConfiguration[section].sectionName}
</Link>
))}
</div>
</div>
);
};

View File

@@ -5,6 +5,7 @@ const config: Config = {
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
"./src/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
@@ -13,6 +14,37 @@ const config: Config = {
"gradient-conic":
"conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
},
colors: {
bg0: "var(--color-bg0)",
bg1: "var(--color-bg1)",
bg4: "var(--color-bg4)",
fg0: "var(--color-fg0)",
fg1: "var(--color-fg1)",
fg4: "var(--color-fg4)",
ac0: "var(--color-ac0)",
ac1: "var(--color-ac1)",
ac2: "var(--color-ac2)",
err: "var(--color-err)",
},
animation: {
fadeIn: "fadeIn 0.25s ease-in-out",
fadeOut: "fadeOut 0.25s ease-in-out",
},
keyframes: () => ({
fadeIn: {
"0%": { opacity: "0" },
"100%": { opacity: "1" },
},
fadeOut: {
"0%": { opacity: "1" },
"100%": { opacity: "0" },
},
}),
},
screens: {
tb: "640px",
lp: "1024px",
dsk: "1280px",
},
},
plugins: [],

View File

@@ -19,7 +19,8 @@
],
"paths": {
"@/*": ["./src/*"]
}
},
"experimentalDecorators": true
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]