new file: .github/workflows/ci.yml new file: .gitignore new file: .prettierignore new file: .storybook/main.ts new file: .storybook/preview.tsx new file: LICENSE.md new file: README.md new file: app/[...slug]/not-found.tsx new file: app/[...slug]/page.tsx new file: app/layout.tsx new file: app/loading.tsx new file: app/og/route.tsx new file: app/page.tsx new file: app/posts/[...slug]/not-found.tsx new file: app/posts/[...slug]/page.tsx new file: app/posts/page.tsx new file: components/analytics.tsx new file: components/blog-title.tsx new file: components/button.tsx new file: components/callout.tsx new file: components/comments.tsxmain
@ -0,0 +1,10 @@ |
||||
{ |
||||
"extends": ["next/core-web-vitals", "plugin:storybook/recommended"], |
||||
"plugins": ["eslint-plugin-tailwindcss"], |
||||
"rules": { |
||||
"tailwindcss/classnames-order": ["error", { "callees": ["cn"] }], |
||||
"tailwindcss/no-custom-classname": ["error", { "callees": ["cn"] }], |
||||
"tailwindcss/no-contradicting-classname": ["error", { "callees": ["cn"] }], |
||||
"tailwindcss/enforces-shorthand": ["error", { "callees": ["cn"] }] |
||||
} |
||||
} |
@ -0,0 +1,43 @@ |
||||
name: 'CI' |
||||
|
||||
on: |
||||
pull_request: |
||||
push: |
||||
branches: ['prod'] |
||||
workflow_dispatch: |
||||
|
||||
jobs: |
||||
test: |
||||
runs-on: ${{ matrix.os }} |
||||
strategy: |
||||
matrix: |
||||
os: ['ubuntu-latest'] |
||||
node-version: ['16.x'] |
||||
steps: |
||||
- name: 'Checkout repository' |
||||
uses: actions/checkout@v3 |
||||
|
||||
- name: 'Use Node.js v${{ matrix.node-version }}' |
||||
uses: actions/setup-node@v3 |
||||
with: |
||||
node-version: ${{ matrix.node-version }} |
||||
|
||||
- name: 'Install pnpm' |
||||
uses: pnpm/action-setup@v2 |
||||
with: |
||||
version: 6.0.2 |
||||
|
||||
- name: 'Install Dependencies' |
||||
run: pnpm install |
||||
|
||||
- name: 'Build Contentlayer' |
||||
run: NODE_ENV=production npx contentlayer build |
||||
|
||||
- name: 'Run Type Checks' |
||||
run: npx tsc --noEmit |
||||
|
||||
- name: 'Run Lint and Format Checks' |
||||
run: pnpm run style:all |
||||
|
||||
- name: 'Run Tests' |
||||
run: pnpm run test |
@ -0,0 +1,38 @@ |
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. |
||||
|
||||
# dependencies |
||||
/node_modules |
||||
/.pnp |
||||
.pnp.js |
||||
|
||||
# testing |
||||
/coverage |
||||
|
||||
# next.js |
||||
/.next/ |
||||
/out/ |
||||
|
||||
# production |
||||
/build |
||||
|
||||
# misc |
||||
.DS_Store |
||||
*.pem |
||||
.vscode |
||||
.contentlayer |
||||
|
||||
# debug |
||||
npm-debug.log* |
||||
yarn-debug.log* |
||||
yarn-error.log* |
||||
.pnpm-debug.log* |
||||
|
||||
# local env files |
||||
.env*.local |
||||
|
||||
# vercel |
||||
.vercel |
||||
|
||||
# typescript |
||||
*.tsbuildinfo |
||||
next-env.d.ts |
@ -0,0 +1,38 @@ |
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. |
||||
|
||||
# dependencies |
||||
/node_modules |
||||
/.pnp |
||||
.pnp.js |
||||
|
||||
# testing |
||||
/coverage |
||||
|
||||
# next.js |
||||
/.next/ |
||||
/out/ |
||||
|
||||
# production |
||||
/build |
||||
|
||||
# misc |
||||
.DS_Store |
||||
*.pem |
||||
.vscode |
||||
.contentlayer |
||||
|
||||
# debug |
||||
npm-debug.log* |
||||
yarn-debug.log* |
||||
yarn-error.log* |
||||
.pnpm-debug.log* |
||||
|
||||
# local env files |
||||
.env*.local |
||||
|
||||
# vercel |
||||
.vercel |
||||
|
||||
# typescript |
||||
*.tsbuildinfo |
||||
next-env.d.ts |
@ -0,0 +1,17 @@ |
||||
import { type StorybookConfig } from '@storybook/nextjs'; |
||||
|
||||
const config: StorybookConfig = { |
||||
stories: ['../components/stories/**/*.stories.@(js|jsx|ts|tsx)'], |
||||
addons: [ |
||||
'@storybook/addon-links', |
||||
'@storybook/addon-essentials', |
||||
'@storybook/addon-interactions', |
||||
'storybook-tailwind-dark-mode', |
||||
], |
||||
framework: { |
||||
name: '@storybook/nextjs', |
||||
options: {}, |
||||
}, |
||||
}; |
||||
|
||||
export default config; |
@ -0,0 +1,66 @@ |
||||
import '@/styles/globals.css'; |
||||
import 'react-tooltip/dist/react-tooltip.css'; |
||||
import { Red_Hat_Display } from 'next/font/google'; |
||||
import type { Decorator, Parameters } from '@storybook/react'; |
||||
import type { GlobalTypes } from '@storybook/types'; |
||||
|
||||
import { cn } from '@/lib/utils'; |
||||
|
||||
const fontSans = Red_Hat_Display({ |
||||
subsets: ['latin'], |
||||
variable: '--font-red-hat', |
||||
}); |
||||
|
||||
export const parameters: Parameters = { |
||||
nextjs: { appDirectory: true }, |
||||
layout: 'fullscreen', |
||||
actions: { argTypesRegex: '^on[A-Z].*' }, |
||||
controls: { |
||||
matchers: { |
||||
color: /(background|color)$/i, |
||||
date: /Date$/, |
||||
}, |
||||
}, |
||||
}; |
||||
|
||||
export const globalTypes: GlobalTypes = { |
||||
darkMode: { |
||||
type: 'boolean', |
||||
defaultValue: false, |
||||
}, |
||||
}; |
||||
|
||||
export const decorators: Decorator[] = [ |
||||
(Story) => ( |
||||
<div |
||||
className="font-sans" |
||||
style={ |
||||
{ '--font-red-hat': fontSans.style.fontFamily } as React.CSSProperties |
||||
} |
||||
> |
||||
<div className="grid min-h-screen grid-cols-1 grid-rows-1 bg-slate-200 dark:bg-slate-700 sm:grid-cols-layout"> |
||||
<div className="col-span-1 min-h-screen bg-slate-200 dark:bg-slate-700 sm:col-start-2"> |
||||
<Story /> |
||||
</div> |
||||
<div // left column |
||||
className={cn( |
||||
'col-span-1 col-start-1 row-span-3 row-start-1 hidden bg-gradient-to-r sm:block', |
||||
'from-slate-300 via-slate-400 to-slate-500', |
||||
'dark:from-slate-800 dark:via-slate-700 dark:to-slate-600', |
||||
)} |
||||
> |
||||
<div className="invisible h-full w-full bg-gradient-to-l from-rose-50 to-slate-700 opacity-25 dark:visible" /> |
||||
</div> |
||||
<div // right column |
||||
className={cn( |
||||
'col-span-1 col-start-3 row-span-3 row-start-1 hidden bg-gradient-to-l sm:block', |
||||
'from-slate-300 via-slate-400 to-slate-500', |
||||
'dark:from-slate-800 dark:via-slate-700 dark:to-slate-600', |
||||
)} |
||||
> |
||||
<div className="invisible h-full w-full bg-gradient-to-r from-rose-50 to-slate-700 opacity-25 dark:visible" /> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
), |
||||
]; |
@ -0,0 +1,21 @@ |
||||
MIT License |
||||
|
||||
Copyright (c) 2022 Kfir Fitousi |
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy |
||||
of this software and associated documentation files (the "Software"), to deal |
||||
in the Software without restriction, including without limitation the rights |
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
||||
copies of the Software, and to permit persons to whom the Software is |
||||
furnished to do so, subject to the following conditions: |
||||
|
||||
The above copyright notice and this permission notice shall be included in all |
||||
copies or substantial portions of the Software. |
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
||||
SOFTWARE. |
@ -0,0 +1,15 @@ |
||||
import { Home, XCircle } from 'lucide-react'; |
||||
|
||||
import { Button } from '@/components/button'; |
||||
|
||||
export default function NotFound() { |
||||
return ( |
||||
<div className="flex h-full flex-col items-center justify-center space-y-4"> |
||||
<XCircle className="h-24 w-24 text-slate-700 dark:text-rose-100" /> |
||||
<h2 className="text-3xl font-bold text-slate-700 dark:text-rose-50"> |
||||
Page not found |
||||
</h2> |
||||
<Button href="/" label="Home" icon={<Home className="h-4 w-4" />} /> |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,64 @@ |
||||
import { notFound } from 'next/navigation'; |
||||
import { type Metadata } from 'next/types'; |
||||
import { allPages } from 'contentlayer/generated'; |
||||
|
||||
import { blogConfig } from '@/config'; |
||||
import { MDXContent } from '@/components/mdx-content'; |
||||
|
||||
type PageProps = { |
||||
params: { |
||||
slug: string[]; |
||||
}; |
||||
}; |
||||
|
||||
export async function generateStaticParams(): Promise<PageProps['params'][]> { |
||||
return allPages.map(({ slug }) => ({ |
||||
slug: slug.split('/'), |
||||
})); |
||||
} |
||||
|
||||
export function generateMetadata({ params }: PageProps): Metadata { |
||||
const { title, description, url } = allPages.find( |
||||
({ slug }) => slug === params.slug.join('/'), |
||||
) || { |
||||
title: 'Page Not Found', |
||||
description: 'Page Not Found', |
||||
url: '/', |
||||
}; |
||||
|
||||
const ogImage = { |
||||
url: `${blogConfig.url}/og?title=${title}`, |
||||
}; |
||||
|
||||
return { |
||||
title, |
||||
description, |
||||
openGraph: { |
||||
type: 'website', |
||||
url: `${blogConfig.url}${url}`, |
||||
title, |
||||
description, |
||||
images: [ogImage], |
||||
}, |
||||
twitter: { |
||||
title, |
||||
description, |
||||
images: ogImage, |
||||
card: 'summary_large_image', |
||||
}, |
||||
}; |
||||
} |
||||
|
||||
export default async function Page({ params }: PageProps) { |
||||
const page = allPages.find(({ slug }) => slug === params.slug.join('/')); |
||||
|
||||
if (!page) { |
||||
notFound(); |
||||
} |
||||
|
||||
return ( |
||||
<div className="h-full px-8"> |
||||
<MDXContent code={page.body.code} /> |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,117 @@ |
||||
import '@/styles/globals.css'; |
||||
import 'react-tooltip/dist/react-tooltip.css'; |
||||
import { Newsreader, Red_Hat_Display } from 'next/font/google'; |
||||
import { type Metadata } from 'next/types'; |
||||
import { allPosts } from 'contentlayer/generated'; |
||||
|
||||
import { blogConfig } from '@/config'; |
||||
import { Analytics } from '@/components/analytics'; |
||||
import { FontStyleProvider } from '@/components/font-style-provider'; |
||||
import { Footer } from '@/components/footer'; |
||||
import { Header } from '@/components/header'; |
||||
import { Search } from '@/components/search'; |
||||
import { cn } from '@/lib/utils'; |
||||
|
||||
const fontSans = Red_Hat_Display({ |
||||
subsets: ['latin'], |
||||
variable: '--font-red-hat', |
||||
}); |
||||
|
||||
const fontSerif = Newsreader({ |
||||
subsets: ['latin'], |
||||
variable: '--font-newsreader', |
||||
}); |
||||
|
||||
type RootLayoutProps = { |
||||
children: React.ReactNode; |
||||
}; |
||||
|
||||
export const metadata: Metadata = { |
||||
title: { |
||||
default: blogConfig.title, |
||||
template: `${blogConfig.title} | %s`, |
||||
}, |
||||
openGraph: { |
||||
title: { |
||||
default: blogConfig.title, |
||||
template: `${blogConfig.title} | %s`, |
||||
}, |
||||
}, |
||||
twitter: { |
||||
title: { |
||||
default: blogConfig.title, |
||||
template: `${blogConfig.title} | %s`, |
||||
}, |
||||
}, |
||||
robots: { |
||||
index: true, |
||||
follow: true, |
||||
}, |
||||
icons: [ |
||||
{ |
||||
rel: 'apple-touch-icon', |
||||
sizes: '180x180', |
||||
url: '/apple-touch-icon.png', |
||||
}, |
||||
{ |
||||
rel: 'icon', |
||||
type: 'image/png', |
||||
sizes: '32x32', |
||||
url: '/favicon-32x32.png', |
||||
}, |
||||
{ |
||||
rel: 'icon', |
||||
type: 'image/png', |
||||
sizes: '16x16', |
||||
url: '/favicon-16x16.png', |
||||
}, |
||||
], |
||||
}; |
||||
|
||||
export default function RootLayout({ children }: RootLayoutProps) { |
||||
return ( |
||||
<html |
||||
lang="ru" |
||||
className={cn( |
||||
'scroll-pt-16 overflow-auto overscroll-none', |
||||
fontSans.variable, |
||||
fontSerif.variable, |
||||
)} |
||||
> |
||||
<head /> |
||||
<body className="grid min-h-screen grid-cols-1 grid-rows-layout bg-slate-200 dark:bg-slate-700 sm:grid-cols-layout"> |
||||
<FontStyleProvider> |
||||
<section className="sticky top-0 z-30 col-span-1 row-span-1 row-start-1 h-full self-start sm:col-start-2"> |
||||
<Header /> |
||||
</section> |
||||
<main className="col-span-1 row-start-2 sm:col-start-2"> |
||||
{children} |
||||
</main> |
||||
<section className="col-span-3 row-span-1 row-start-3 sm:col-span-1 sm:col-start-2"> |
||||
<Footer /> |
||||
</section> |
||||
<div // left column |
||||
className={cn( |
||||
'col-span-1 col-start-1 row-span-3 row-start-1 hidden bg-gradient-to-r sm:block', |
||||
'from-slate-300 via-slate-400 to-slate-500', |
||||
'dark:from-slate-800 dark:via-slate-700 dark:to-slate-600', |
||||
)} |
||||
> |
||||
<div className="invisible h-full w-full bg-gradient-to-l from-rose-50 to-slate-700 opacity-25 dark:visible" /> |
||||
</div> |
||||
<div // right column |
||||
className={cn( |
||||
'col-span-1 col-start-3 row-span-3 row-start-1 hidden bg-gradient-to-l sm:block', |
||||
'from-slate-300 via-slate-400 to-slate-500', |
||||
'dark:from-slate-800 dark:via-slate-700 dark:to-slate-600', |
||||
)} |
||||
> |
||||
<div className="invisible h-full w-full bg-gradient-to-r from-rose-50 to-slate-700 opacity-25 dark:visible" /> |
||||
</div> |
||||
<Search posts={allPosts} /> |
||||
<Analytics /> |
||||
</FontStyleProvider> |
||||
</body> |
||||
</html> |
||||
); |
||||
} |
@ -0,0 +1,9 @@ |
||||
import { Loader2 } from 'lucide-react'; |
||||
|
||||
export default function Loading() { |
||||
return ( |
||||
<div className="flex h-full flex-col items-center justify-center"> |
||||
<Loader2 className="h-12 w-12 animate-spin text-slate-700 dark:text-rose-50" /> |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,122 @@ |
||||
import { NextRequest } from 'next/server'; |
||||
import { ImageResponse } from '@vercel/og'; |
||||
import colors from 'tailwindcss/colors'; |
||||
|
||||
import { blogConfig } from '@/config'; |
||||
|
||||
export const runtime = 'edge'; |
||||
|
||||
const fontRegular = fetch( |
||||
new URL('../../public/assets/RedHatDisplay-Regular.ttf', import.meta.url), |
||||
).then((res) => res.arrayBuffer()); |
||||
|
||||
const fontSemiBold = fetch( |
||||
new URL('../../public/assets/RedHatDisplay-SemiBold.ttf', import.meta.url), |
||||
).then((res) => res.arrayBuffer()); |
||||
|
||||
const fontBold = fetch( |
||||
new URL('../../public/assets/RedHatDisplay-Bold.ttf', import.meta.url), |
||||
).then((res) => res.arrayBuffer()); |
||||
|
||||
export async function GET(req: NextRequest) { |
||||
const fontRegularData = await fontRegular; |
||||
const fontSemiBoldData = await fontSemiBold; |
||||
const fontBoldData = await fontBold; |
||||
|
||||
const accentColor = blogConfig.theme?.accentColor?.light || colors.rose[700]; |
||||
|
||||
try { |
||||
const { searchParams } = new URL(req.url); |
||||
|
||||
// ?title=<title>&subtitle=<subtitle>
|
||||
const hasTitle = searchParams.has('title'); |
||||
const title = hasTitle && searchParams.get('title')?.slice(0, 100); |
||||
const hasSubtitle = searchParams.has('subtitle'); |
||||
const subtitle = hasSubtitle && searchParams.get('subtitle')?.slice(0, 100); |
||||
|
||||
return new ImageResponse( |
||||
( |
||||
<div tw="h-full w-full flex flex-col items-center justify-center bg-slate-300"> |
||||
<div |
||||
tw="flex flex-row items-center text-7xl" |
||||
style={{ |
||||
marginBottom: title ? '8rem' : '0rem', |
||||
fontFamily: 'Red Hat Display', |
||||
}} |
||||
> |
||||
{blogConfig.titleParts && ( |
||||
<div style={{ color: accentColor }}>‹</div> |
||||
)} |
||||
<h1 |
||||
tw="mx-0.5 font-semibold text-center text-slate-800" |
||||
style={{ |
||||
fontFamily: 'Red Hat Display Bold', |
||||
}} |
||||
> |
||||
{blogConfig.titleParts ? ( |
||||
<> |
||||
{blogConfig.titleParts[0]} |
||||
<span |
||||
tw="px-px font-light" |
||||
style={{ |
||||
fontFamily: 'Red Hat Display', |
||||
color: accentColor, |
||||
}} |
||||
> |
||||
/ |
||||
</span> |
||||
{blogConfig.titleParts[1]} |
||||
</> |
||||
) : ( |
||||
blogConfig.title |
||||
)} |
||||
</h1> |
||||
{blogConfig.titleParts && ( |
||||
<div style={{ color: accentColor }}>›</div> |
||||
)} |
||||
</div> |
||||
|
||||
<div |
||||
tw="text-5xl text-slate-800 text-center mb-4" |
||||
style={{ fontFamily: 'Red Hat Display SemiBold' }} |
||||
> |
||||
{title} |
||||
</div> |
||||
|
||||
<div |
||||
tw="text-2xl text-slate-800 text-center" |
||||
style={{ fontFamily: 'Red Hat Display' }} |
||||
> |
||||
{subtitle} |
||||
</div> |
||||
</div> |
||||
), |
||||
{ |
||||
width: 1200, |
||||
height: 630, |
||||
fonts: [ |
||||
{ |
||||
name: 'Red Hat Display', |
||||
data: fontRegularData, |
||||
style: 'normal', |
||||
}, |
||||
{ |
||||
name: 'Red Hat Display SemiBold', |
||||
data: fontSemiBoldData, |
||||
style: 'normal', |
||||
}, |
||||
{ |
||||
name: 'Red Hat Display Bold', |
||||
data: fontBoldData, |
||||
style: 'normal', |
||||
}, |
||||
], |
||||
}, |
||||
); |
||||
} catch (e: any) { |
||||
console.log(`${e.message}`); |
||||
return new Response('Failed to generate the image', { |
||||
status: 500, |
||||
}); |
||||
} |
||||
} |
@ -0,0 +1,55 @@ |
||||
import { type Metadata } from 'next/types'; |
||||
import { allPosts } from 'contentlayer/generated'; |
||||
import { compareDesc } from 'date-fns'; |
||||
import { FileText } from 'lucide-react'; |
||||
|
||||
import { blogConfig } from '@/config'; |
||||
import { Button } from '@/components/button'; |
||||
import { HeroSection } from '@/components/hero-section'; |
||||
import { PostCard } from '@/components/post-card'; |
||||
|
||||
const { title, description } = blogConfig.pages.home; |
||||
|
||||
const ogImage = { |
||||
url: `${blogConfig.url}/og`, |
||||
}; |
||||
|
||||
export const metadata: Metadata = { |
||||
title, |
||||
description, |
||||
openGraph: { |
||||
type: 'website', |
||||
url: blogConfig.url, |
||||
title, |
||||
description, |
||||
images: [ogImage], |
||||
}, |
||||
twitter: { |
||||
description, |
||||
images: ogImage, |
||||
card: 'summary_large_image', |
||||
}, |
||||
}; |
||||
|
||||
export default function Home() { |
||||
const latestPosts = allPosts |
||||
.sort((a, b) => compareDesc(new Date(a.date), new Date(b.date))) |
||||
.slice(0, 3); |
||||
|
||||
return ( |
||||
<div className="flex h-full flex-col space-y-4 px-6 pb-12 sm:px-12"> |
||||
<HeroSection /> |
||||
<section className="flex w-full flex-col space-y-4"> |
||||
{latestPosts.map((post) => ( |
||||
<PostCard key={post._id} post={post} /> |
||||
))} |
||||
<Button |
||||
href="/posts" |
||||
label="Все статьи" |
||||
className="place-self-end" |
||||
icon={<FileText className="h-4 w-4" />} |
||||
/> |
||||
</section> |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,22 @@ |
||||
import { FileQuestion, FileText, Home } from 'lucide-react'; |
||||
|
||||
import { Button } from '@/components/button'; |
||||
|
||||
export default function NotFound() { |
||||
return ( |
||||
<div className="flex h-full flex-col items-center justify-center space-y-4"> |
||||
<FileQuestion className="h-24 w-24 text-slate-700 dark:text-rose-100" /> |
||||
<h2 className="text-3xl font-bold text-slate-700 dark:text-rose-50"> |
||||
Post not found |
||||
</h2> |
||||
<div className="flex flex-row space-x-2"> |
||||
<Button |
||||
href="/posts" |
||||
label="All Posts" |
||||
icon={<FileText className="h-4 w-4" />} |
||||
/> |
||||
<Button href="/" label="Home" icon={<Home className="h-4 w-4" />} /> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,76 @@ |
||||
import { notFound } from 'next/navigation'; |
||||
import { type Metadata } from 'next/types'; |
||||
import { allPosts } from 'contentlayer/generated'; |
||||
|
||||
import { blogConfig } from '@/config'; |
||||
import { Comments } from '@/components/comments'; |
||||
import { MDXContent } from '@/components/mdx-content'; |
||||
import { PostIntro } from '@/components/post-intro'; |
||||
|
||||
type PostPageProps = { |
||||
params: { |
||||
slug: string[]; |
||||
}; |
||||
}; |
||||
|
||||
export async function generateStaticParams(): Promise< |
||||
PostPageProps['params'][] |
||||
> { |
||||
return allPosts.map(({ slug }) => ({ |
||||
slug: slug.split('/'), |
||||
})); |
||||
} |
||||
|
||||
export function generateMetadata({ params }: PostPageProps): Metadata { |
||||
const { title, excerpt, url, date } = allPosts.find( |
||||
({ slug }) => slug === params.slug.join('/'), |
||||
) || { |
||||
title: 'Post Not Found', |
||||
excerpt: null, |
||||
url: '/posts', |
||||
date: new Date().toISOString(), |
||||
}; |
||||
|
||||
const ogImage = { |
||||
url: `${blogConfig.url}/og?title=${title}&subtitle=${excerpt ?? ''}`, |
||||
}; |
||||
|
||||
const description = excerpt ?? 'Post Not Found'; |
||||
|
||||
return { |
||||
title, |
||||
description, |
||||
openGraph: { |
||||
type: 'article', |
||||
url: `${blogConfig.url}${url}`, |
||||
title, |
||||
description, |
||||
publishedTime: date, |
||||
images: [ogImage], |
||||
}, |
||||
twitter: { |
||||
title, |
||||
description, |
||||
images: ogImage, |
||||
card: 'summary_large_image', |
||||
}, |
||||
}; |
||||
} |
||||
|
||||
export default function PostPage({ params }: PostPageProps) { |
||||
const post = allPosts.find(({ slug }) => slug === params.slug.join('/')); |
||||
|
||||
if (!post) { |
||||
notFound(); |
||||
} |
||||
|
||||
return ( |
||||
<article className="h-full px-8"> |
||||
<PostIntro title={post.title} date={post.date} tags={post.tags} /> |
||||
<MDXContent code={post.body.code} /> |
||||
{ |
||||
//<Comments />
|
||||
} |
||||
</article> |
||||
); |
||||
} |
@ -0,0 +1,42 @@ |
||||
import { type Metadata } from 'next/types'; |
||||
import { allPosts } from 'contentlayer/generated'; |
||||
import { compareDesc } from 'date-fns'; |
||||
|
||||
import { blogConfig } from '@/config'; |
||||
import { PostPaginator } from '@/components/post-paginator'; |
||||
|
||||
const { url, title, description } = blogConfig.pages.posts; |
||||
|
||||
const ogImage = { |
||||
url: `${blogConfig.url}/og?title=${title}`, |
||||
}; |
||||
|
||||
export const metadata: Metadata = { |
||||
title, |
||||
description, |
||||
openGraph: { |
||||
type: 'website', |
||||
url: `${blogConfig.url}${url}`, |
||||
title, |
||||
description, |
||||
images: [ogImage], |
||||
}, |
||||
twitter: { |
||||
title, |
||||
description, |
||||
images: ogImage, |
||||
card: 'summary_large_image', |
||||
}, |
||||
}; |
||||
|
||||
export default function PostsPage() { |
||||
const posts = allPosts.sort((a, b) => |
||||
compareDesc(new Date(a.date), new Date(b.date)), |
||||
); |
||||
|
||||
return ( |
||||
<div className="h-full px-6 pb-12 sm:px-12"> |
||||
<PostPaginator posts={posts} /> |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,7 @@ |
||||
'use client'; |
||||
|
||||
import { Analytics as VercelAnalytics } from '@vercel/analytics/react'; |
||||
|
||||
export function Analytics() { |
||||
return <VercelAnalytics />; |
||||
} |
@ -0,0 +1,45 @@ |
||||
'use client'; |
||||
|
||||
import { blogConfig } from '@/config'; |
||||
import { cn } from '@/lib/utils'; |
||||
|
||||
type BlogTitleProps = { |
||||
className?: string; |
||||
}; |
||||
|
||||
export function BlogTitle({ className }: BlogTitleProps) { |
||||
return ( |
||||
<div |
||||
className={cn( |
||||
'inline-flex w-full items-center justify-center', |
||||
className, |
||||
)} |
||||
> |
||||
{blogConfig.titleParts && ( |
||||
<div className="text-accent dark:text-accent-dark">‹</div> |
||||
)} |
||||
<h1 |
||||
className={cn( |
||||
'mx-0.5 whitespace-nowrap text-center font-semibold drop-shadow-sm', |
||||
'text-slate-800 hover:text-accent', |
||||
'dark:text-rose-50 dark:hover:text-accent-dark', |
||||
)} |
||||
> |
||||
{blogConfig.titleParts ? ( |
||||
<> |
||||
{blogConfig.titleParts[0]} |
||||
<span className="px-px font-light text-accent dark:text-accent-dark"> |
||||
/ |
||||
</span> |
||||
{blogConfig.titleParts[1]} |
||||
</> |
||||
) : ( |
||||
blogConfig.title |
||||
)} |
||||
</h1> |
||||
{blogConfig.titleParts && ( |
||||
<div className="text-accent dark:text-accent-dark">›</div> |
||||
)} |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,27 @@ |
||||
import Link from 'next/link'; |
||||
|
||||
import { cn } from '@/lib/utils'; |
||||
|
||||
type ButtonProps = { |
||||
href: string; |
||||
label: string; |
||||
className?: string; |
||||
icon?: React.ReactNode; |
||||
}; |
||||
|
||||
export function Button({ href, label, className, icon }: ButtonProps) { |
||||
return ( |
||||
<Link href={href} className={className}> |
||||
<button |
||||
className={cn( |
||||
'flex flex-row items-center space-x-2 rounded p-2 font-semibold shadow-xl hover:outline', |
||||
'bg-slate-700 text-slate-200 hover:bg-transparent hover:text-slate-700 hover:outline-slate-700', |
||||
'dark:bg-rose-50 dark:text-slate-800 dark:hover:bg-transparent dark:hover:text-rose-50 dark:hover:outline-rose-50', |
||||
)} |
||||
> |
||||
{icon && <span aria-hidden>{icon}</span>} |
||||
<span className="text-sm">{label}</span> |
||||
</button> |
||||
</Link> |
||||
); |
||||
} |
@ -0,0 +1,63 @@ |
||||
import { |
||||
AlertCircle, |
||||
AlertOctagon, |
||||
AlertTriangle, |
||||
Lightbulb, |
||||
LucideIcon, |
||||
} from 'lucide-react'; |
||||
|
||||
import { cn } from '@/lib/utils'; |
||||
|
||||
type CalloutProps = { |
||||
type: 'update' | 'note' | 'warning' | 'important'; |
||||
children: React.ReactNode; |
||||
}; |
||||
|
||||
const Icons: Record<CalloutProps['type'], LucideIcon> = { |
||||
note: AlertCircle, |
||||
warning: AlertTriangle, |
||||
update: Lightbulb, |
||||
important: AlertOctagon, |
||||
}; |
||||
|
||||
export const Callout = ({ type, children }: CalloutProps) => { |
||||
const Icon = Icons[type]; |
||||
|
||||
return ( |
||||
<div |
||||
className={cn( |
||||
'relative my-6 rounded-md p-2 px-8', |
||||
type === 'update' && |
||||
'bg-slate-100 text-slate-600 dark:bg-slate-600 dark:text-slate-200', |
||||
type === 'note' && |
||||
'bg-cyan-50/50 text-cyan-700 dark:bg-cyan-200/10 dark:text-cyan-100', |
||||
type === 'warning' && |
||||
'bg-amber-50/50 text-amber-700 dark:bg-amber-300/10 dark:text-amber-500', |
||||
type === 'important' && |
||||
'bg-red-50/50 text-rose-700 dark:bg-rose-400/10 dark:text-rose-300', |
||||
)} |
||||
> |
||||
<div |
||||
className={cn( |
||||
'absolute -top-3 left-3 h-6 w-fit rounded-md p-2', |
||||
'flex flex-row items-center justify-center space-x-1', |
||||
type === 'update' && |
||||
'bg-slate-600 text-slate-300 dark:bg-slate-300 dark:text-slate-600', |
||||
type === 'note' && |
||||
'bg-cyan-700 text-cyan-50 dark:bg-cyan-100 dark:text-cyan-900', |
||||
type === 'warning' && |
||||
'bg-amber-700 text-amber-50 dark:bg-amber-500 dark:text-amber-900', |
||||
type === 'important' && |
||||
'bg-rose-700 text-rose-50 dark:bg-rose-300 dark:text-rose-900', |
||||
)} |
||||
> |
||||
<Icon className="h-4 w-4" aria-hidden /> |
||||
<div className="text-sm"> |
||||
{type.charAt(0).toUpperCase()} |
||||
{type.slice(1)} |
||||
</div> |
||||
</div> |
||||
{children} |
||||
</div> |
||||
); |
||||
}; |
@ -0,0 +1,83 @@ |
||||
'use client'; |
||||
|
||||
import { useState } from 'react'; |
||||
import { Check, Copy } from 'lucide-react'; |
||||
|
||||
type CodeBlockProps = { |
||||
children: React.ReactNode; |
||||
}; |
||||
|
||||
export function CodeBlock({ children }: CodeBlockProps) { |
||||
const [showCopy, setShowCopy] = useState(false); |
||||
const [isCopied, setIsCopied] = useState(false); |
||||
|
||||
const copy = async () => { |
||||
await navigator.clipboard.writeText( |
||||
extractText(children as React.ReactElement), |
||||
); |
||||
|
||||
setIsCopied(true); |
||||
setTimeout(() => setIsCopied(false), 2000); |
||||
}; |
||||
|
||||
return ( |
||||
<pre |
||||
className="relative mx-auto max-w-3xl" |
||||
onMouseEnter={() => setShowCopy(true)} |
||||
onMouseLeave={() => setShowCopy(false)} |
||||
onFocus={() => setShowCopy(true)} |
||||
onBlur={() => setShowCopy(false)} |
||||
> |
||||
{showCopy && ( |
||||
<button |
||||
className="absolute right-2 top-2 flex h-7 w-7 items-center justify-center rounded bg-white dark:bg-slate-800" |
||||
onClick={copy} |
||||
disabled={isCopied} |
||||
> |
||||
{isCopied ? ( |
||||
<Check |
||||
className="h-6 w-6 animate-pulse text-accent dark:text-accent-dark" |
||||
aria-label="Copied" |
||||
/> |
||||
) : ( |
||||
<Copy |
||||
className="h-6 w-6 text-slate-300 hover:text-accent dark:text-slate-600 dark:hover:text-accent-dark" |
||||
aria-label="Copy code" |
||||
/> |
||||
)} |
||||
</button> |
||||
)} |
||||
{children} |
||||
</pre> |
||||
); |
||||
} |
||||
|
||||
/** |
||||
* Extracts the text from a ReactElement |
||||
*/ |
||||
const extractText = (element: React.ReactElement | string): string => { |
||||
// If the element is a string, return it
|
||||
if (typeof element === 'string') { |
||||
return element; |
||||
} |
||||
|
||||
// If the element is a ReactElement, check if it has children
|
||||
// If the children is a single string, return it
|
||||
if (typeof element.props.children === 'string') { |
||||
return element.props.children; |
||||
} |
||||
|
||||
// If the children is an array, map over it and extract the text
|
||||
if (Array.isArray(element.props.children)) { |
||||
return (element.props.children as (React.ReactElement | string)[]) |
||||
.map((child) => extractText(child)) |
||||
.join(''); |
||||
} |
||||
|
||||
// If the children is an object (ReactElement), extract the text from it recursively
|
||||
if (typeof element.props.children === 'object') { |
||||
return extractText(element.props.children); |
||||
} |
||||
|
||||
return ''; |
||||
}; |
@ -0,0 +1,27 @@ |
||||
'use client'; |
||||
|
||||
import Giscus from '@giscus/react'; |
||||
|
||||
import { useThemeStore } from '@/stores/theme-store'; |
||||
import { blogConfig } from '@/config'; |
||||
|
||||
export function Comments() { |
||||
const isDark = useThemeStore((state) => state.isDark); |
||||
|
||||
const theme = isDark |
||||
? blogConfig.giscus.theme?.dark || 'dark_dimmed' |
||||
: blogConfig.giscus.theme?.light || 'light'; |
||||
|
||||
return ( |
||||
<section className="mx-auto max-w-2xl"> |
||||
<Giscus |
||||
lang="ru" |
||||
loading="lazy" |
||||
reactionsEnabled="1" |
||||
inputPosition="bottom" |
||||
{...blogConfig.giscus} |
||||
theme={theme} |
||||
/> |
||||
</section> |
||||
); |
||||
} |
@ -0,0 +1,18 @@ |
||||
'use client'; |
||||
|
||||
import { useThemeStore } from '@/stores/theme-store'; |
||||
import { cn } from '@/lib/utils'; |
||||
|
||||
type FontStyleProviderProps = { |
||||
children: React.ReactNode; |
||||
}; |
||||
|
||||
export function FontStyleProvider({ children }: FontStyleProviderProps) { |
||||
const isSerif = useThemeStore((state) => state.isSerif); |
||||
|
||||
return ( |
||||
<div className={cn('contents', isSerif ? 'font-serif' : 'font-sans')}> |
||||
{children} |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,153 @@ |
||||
'use client'; |
||||
|
||||
import { |
||||
AtSign, |
||||
Copyright, |
||||
Github, |
||||
Linkedin, |
||||
Pizza, |
||||
Twitter, |
||||
} from 'lucide-react'; |
||||
|
||||
import { Icon24LogoVkOutline } from '@vkontakte/icons'; |
||||
import { Icon24LocationOutline } from '@vkontakte/icons'; |
||||
|
||||
import { blogConfig } from '@/config'; |
||||
import { Tooltip } from '@/components/tooltip'; |
||||
|
||||
export function Footer() { |
||||
const { footerLinks } = blogConfig; |
||||
|
||||
return ( |
||||
<footer className="relative flex h-full w-full flex-col items-center justify-center space-y-4"> |
||||
<div className="flex flex-row flex-wrap justify-center gap-4 max-xs:px-16"> |
||||
{footerLinks?.github && ( |
||||
<a |
||||
href={footerLinks.github} |
||||
target="_blank" |
||||
rel="noopener noreferrer" |
||||
> |
||||
<Github |
||||
className="icon-base" |
||||
data-tooltip-content="Мой GitHub" |
||||
data-tooltip-id="footer-tooltip" |
||||
aria-label="Мой GitHub " |
||||
/> |
||||
</a> |
||||
)} |
||||
{footerLinks?.twitter && ( |
||||
<a |
||||
href={footerLinks.twitter} |
||||
target="_blank" |
||||
rel="noopener noreferrer" |
||||
> |
||||
<Icon24LogoVkOutline |
||||
className="icon-base" |
||||
data-tooltip-content="Мой VK Профиль" |
||||
data-tooltip-id="footer-tooltip" |
||||
aria-label="Мой VK Профиль" |
||||
/> |
||||
</a> |
||||
)} |
||||
{footerLinks?.linkedin && ( |
||||
<a |
||||
href={footerLinks.linkedin} |
||||
target="_blank" |
||||
rel="noopener noreferrer" |
||||
> |
||||
<Icon24LocationOutline |
||||
className="icon-base" |
||||
data-tooltip-content="Мой Telegramm" |
||||
data-tooltip-id="footer-tooltip" |
||||
aria-label="Мой Telegramm" |
||||
/> |
||||
</a> |
||||
)} |
||||
{footerLinks?.email && ( |
||||
<a href={`mailto:${footerLinks.email}`}> |
||||
<AtSign |
||||
className="icon-base" |
||||
data-tooltip-content="Мой Email" |
||||
data-tooltip-id="footer-tooltip" |
||||
aria-label="Мой Email" |
||||
/> |
||||
</a> |
||||
)} |
||||
{footerLinks?.storybook && ( |
||||
<a |
||||
href={footerLinks.storybook} |
||||
target="_blank" |
||||
rel="noopener noreferrer" |
||||
> |
||||
<svg |
||||
xmlns="http://www.w3.org/2000/svg" |
||||
width="24px" |
||||
height="24px" |
||||
viewBox="-4 -4 40 40" |
||||
role="img" |
||||
fill="none" |
||||
stroke="currentColor" |
||||
strokeWidth="2.6" |
||||
className="icon-base" |
||||
data-tooltip-content="Storybook" |
||||
data-tooltip-id="footer-tooltip" |
||||
aria-label="Storybook" |
||||
> |
||||
<path d="M21.786 0.318l-0.161 3.615c-0.005 0.203 0.229 0.328 0.391 0.203l1.411-1.068 1.198 0.932c0.156 0.104 0.365 0 0.375-0.188l-0.135-3.677 1.776-0.135c0.922-0.063 1.708 0.672 1.708 1.599v28.802c0 0.917-0.766 1.646-1.682 1.599l-21.469-0.958c-0.833-0.036-1.505-0.708-1.531-1.547l-1-26.401c-0.052-0.885 0.62-1.646 1.505-1.693l17.599-1.109zM17.693 12.401c0 0.625 4.214 0.318 4.786-0.109 0-4.266-2.292-6.521-6.479-6.521-4.198 0-6.531 2.297-6.531 5.724 0 5.932 8 6.036 8 9.276 0 0.938-0.427 1.469-1.401 1.469-1.281 0-1.802-0.651-1.734-2.88 0-0.479-4.865-0.641-5.026 0-0.359 5.375 2.974 6.932 6.797 6.932 3.724 0 6.63-1.984 6.63-5.573 0-6.359-8.135-6.188-8.135-9.333 0-1.292 0.964-1.464 1.505-1.464 0.604 0 1.667 0.094 1.589 2.49z" /> |
||||
</svg> |
||||
</a> |
||||
)} |
||||
{footerLinks?.buyMeAPizza && ( |
||||
<a |
||||
href={footerLinks.buyMeAPizza} |
||||
target="_blank" |
||||
rel="noopener noreferrer" |
||||
> |
||||
<Pizza |
||||
id="pizza" |
||||
className="icon-base" |
||||
data-tooltip-content="Buy me a pizza" |
||||
data-tooltip-id="footer-tooltip" |
||||
aria-label="Buy me a pizza" |
||||
/> |
||||
</a> |
||||
)} |
||||
<Tooltip id="footer-tooltip" /> |
||||
</div> |
||||
|
||||
<div className="flex h-6 flex-row items-center justify-center space-x-1 text-slate-600 dark:text-slate-300"> |
||||
<Copyright className="h-4 w-4" aria-label="Copyright" /> |
||||
<span className="text-xs xs:text-sm">2023 · {blogConfig.author}</span> |
||||
</div> |
||||
|
||||
<button |
||||
className="absolute bottom-4 left-8 h-full w-fit" |
||||
onClick={() => { |
||||
document.body.scrollTop = 0; |
||||
document.documentElement.scrollTop = 0; |
||||
}} |
||||
> |
||||
<svg |
||||
id="scroll-to-top" |
||||
xmlns="http://www.w3.org/2000/svg" |
||||
width="24" |
||||
height="48" |
||||
viewBox="0 0 24 48" |
||||
fill="none" |
||||
stroke="currentColor" |
||||
strokeWidth="2" |
||||
strokeLinecap="round" |
||||
strokeLinejoin="round" |
||||
className="icon-base h-12" |
||||
viewTarget="0 0 24 48" |
||||
aria-label="Scroll to top" |
||||
data-tooltip-content="Scroll to top" |
||||
> |
||||
<line x1="12" y1="38" x2="12" y2="5"></line> |
||||
<polyline points="5 12 12 5 19 12"></polyline> |
||||
</svg> |
||||
<Tooltip anchorSelect="#scroll-to-top" place="right" /> |
||||
</button> |
||||
</footer> |
||||
); |
||||
} |
@ -0,0 +1,48 @@ |
||||
'use client'; |
||||
|
||||
import { useEffect, useRef, useState } from 'react'; |
||||
import { useSelectedLayoutSegments } from 'next/navigation'; |
||||
|
||||
import { blogConfig } from '@/config'; |
||||
import { NavigationBar } from '@/components/navigation-bar'; |
||||
import { Toolbar } from '@/components/toolbar'; |
||||
import { cn } from '@/lib/utils'; |
||||
|
||||
export function Header() { |
||||
const [scrollTop, setScrollTop] = useState(0); |
||||
const headerRef = useRef<HTMLDivElement>(null); |
||||
const layoutSegment = useSelectedLayoutSegments(); |
||||
const isPostPage = |
||||
layoutSegment[0] === blogConfig.pages.posts.url.substring(1) && |
||||
!!layoutSegment[1]; |
||||
|
||||
useEffect(() => { |
||||
// sync scroll position with state
|
||||
setScrollTop(document.documentElement.scrollTop); |
||||
|
||||
// update state on scroll
|
||||
const handleScroll = () => { |
||||
setScrollTop(document.documentElement.scrollTop); |
||||
}; |
||||
document.addEventListener('scroll', handleScroll); |
||||
|
||||
return () => document.removeEventListener('scroll', handleScroll); |
||||
}, []); |
||||
|
||||
return ( |
||||
<header |
||||
ref={headerRef} |
||||
className={cn( |
||||
headerRef.current && scrollTop > headerRef.current.clientHeight |
||||
? 'border-b border-b-slate-300 bg-slate-500/20 py-2 dark:border-b-slate-600' |
||||
: 'bg-transparent py-8', |
||||
'flex flex-row items-center justify-between px-4 xs:px-8', |
||||
'transition-[padding,background-color] duration-300 ease-in-out', |
||||
'text-slate-700 backdrop-blur dark:text-rose-50', |
||||
)} |
||||
> |
||||
<NavigationBar className="flex-grow mix-blend-color-dodge max-xs:mr-2" /> |
||||
<Toolbar fontControls={isPostPage} className="ml-auto" /> |
||||
</header> |
||||
); |
||||
} |
@ -0,0 +1,123 @@ |
||||
'use client'; |
||||
|
||||
import { useReducer } from 'react'; |
||||
import GraphemeSplitter from 'grapheme-splitter'; |
||||
import { Pause, Play } from 'lucide-react'; |
||||
import Typist from 'react-typist-component'; |
||||
import Balancer from 'react-wrap-balancer'; |
||||
|
||||
import { blogConfig } from '@/config'; |
||||
import { cn } from '@/lib/utils'; |
||||
|
||||
type TypingState = { |
||||
titleDone: boolean; |
||||
subtitleDone: boolean; |
||||
isPaused: boolean; |
||||
}; |
||||
|
||||
type TypingAction = |
||||
| { type: 'togglePause' } |
||||
| { type: 'setDone'; payload: 'title' | 'subtitle' }; |
||||
|
||||
const reducer = (state: TypingState, action: TypingAction) => { |
||||
switch (action.type) { |
||||
case 'togglePause': |
||||
return { |
||||
...state, |
||||
isPaused: !state.isPaused, |
||||
}; |
||||
case 'setDone': |
||||
return { |
||||
...state, |
||||
[`${action.payload}Done`]: true, |
||||
}; |
||||
} |
||||
}; |
||||
|
||||
const splitter = (str: string) => new GraphemeSplitter().splitGraphemes(str); |
||||
|
||||
export function HeroSection() { |
||||
const [{ titleDone, subtitleDone, isPaused }, dispatch] = useReducer( |
||||
reducer, |
||||
{ |
||||
titleDone: false, |
||||
subtitleDone: false, |
||||
isPaused: false, |
||||
}, |
||||
); |
||||
|
||||
return ( |
||||
<section |
||||
className={cn( |
||||
'flex flex-col items-center justify-center space-y-2', |
||||
'relative h-40 w-full rounded-md px-4 shadow-lg', |
||||
'bg-slate-300 dark:bg-slate-800/50', |
||||
)} |
||||
> |
||||
<Typist |
||||
typingDelay={100} |
||||
splitter={splitter} |
||||
pause={isPaused} |
||||
onTypingDone={() => dispatch({ type: 'setDone', payload: 'title' })} |
||||
> |
||||
<h1 className="block w-full text-center text-3xl font-bold text-slate-800 dark:text-rose-50 xs:text-4xl sm:text-5xl"> |
||||
<Balancer> |
||||
Добро пожаловать в мой блог |
||||
<span className="ml-2 inline-block origin-[70%_70%] animate-wave"> |
||||
👋 |
||||
</span> |
||||
</Balancer> |
||||
</h1> |
||||
</Typist> |
||||
<p className="text-center text-lg text-slate-800 dark:text-rose-50 xs:text-2xl"> |
||||
{titleDone && ( |
||||
<Typist |
||||
typingDelay={100} |
||||
startDelay={1000} |
||||
pause={isPaused} |
||||
onTypingDone={() => { |
||||
dispatch({ type: 'setDone', payload: 'subtitle' }); |
||||
}} |
||||
> |
||||
Я пишу про {' '} |
||||
</Typist> |
||||
)} |
||||
{subtitleDone && ( |
||||
<Typist typingDelay={100} backspaceDelay={75} pause={isPaused} loop> |
||||
{blogConfig.topics.map((topic) => ( |
||||
<span key={topic} className="font-semibold"> |
||||
{topic} |
||||
<Typist.Delay ms={1000} /> |
||||
<Typist.Backspace count={topic.length} /> |
||||
</span> |
||||
))} |
||||
</Typist> |
||||
)} |
||||
</p> |
||||
<button |
||||
className="absolute right-3 top-1" |
||||
onClick={() => dispatch({ type: 'togglePause' })} |
||||
> |
||||
{isPaused ? ( |
||||
<Play |
||||
className={cn( |
||||
'h-4 w-4', |
||||
'text-slate-400/50 hover:text-accent', |
||||
'dark:text-rose-50/20 dark:hover:text-accent-dark', |
||||
)} |
||||
aria-label="Запустить анимацию" |
||||
/> |
||||
) : ( |
||||
<Pause |
||||
className={cn( |
||||
'h-4 w-4', |
||||
'text-slate-400/50 hover:text-accent', |
||||
'dark:text-rose-50/20 dark:hover:text-accent-dark', |
||||
)} |
||||
aria-label="Остановить анимацию" |
||||
/> |
||||
)} |
||||
</button> |
||||
</section> |
||||
); |
||||
} |
@ -0,0 +1,86 @@ |
||||
import Image from 'next/image'; |
||||
import Link from 'next/link'; |
||||
import Balancer from 'react-wrap-balancer'; |
||||
|
||||
import { Callout } from '@/components/callout'; |
||||
import { CodeBlock } from '@/components/code-block'; |
||||
import { TableOfContents } from '@/components/table-of-contents'; |
||||
import { cn } from '@/lib/utils'; |
||||
|
||||
/** |
||||
* Use <Link> for internal links and <a> for external links and anchors |
||||
* and open external links in a new tab |
||||
*/ |
||||
function a({ href, children }: React.HTMLProps<HTMLAnchorElement>) { |
||||
if (href && href.startsWith('/')) { |
||||
return <Link href={href}>{children}</Link>; |
||||
} |
||||
|
||||
if (href && href.startsWith('#')) { |
||||
return <a href={href}>{children}</a>; |
||||
} |
||||
|
||||
return ( |
||||
<a href={href} target="_blank" rel="noopener noreferrer"> |
||||
{children} |
||||
</a> |
||||
); |
||||
} |
||||
|
||||
/** |
||||
* Use div instead of p elements since p elements have restrictions on what |
||||
* elements can be nested inside them |
||||
*/ |
||||
function p(props: React.HTMLProps<HTMLParagraphElement>) { |
||||
return <div className={cn('my-4', props.className)} {...props} />; |
||||
} |
||||
|
||||
/** |
||||
* Image component that uses next/image, with optional caption and width/height |
||||
* Example usage: \!\[alt text {{ w: 600, h: 300, cap: "caption text" }}](/path/to/image) |
||||
*/ |
||||
function img({ src, alt }: React.HTMLProps<HTMLImageElement>) { |
||||
const _alt = (alt?.split('{')[0].trim() ?? alt) || ''; |
||||
const props = alt?.split('{')[1]; |
||||
const width = parseInt(props?.match(/w:\s*(\d+)/)?.[1] ?? '700'); |
||||
const height = parseInt(props?.match(/h:\s*(\d+)/)?.[1] ?? '400'); |
||||
const caption = props?.match(/cap:\s*"(.*?)"/)?.[1]; |
||||
|
||||
return ( |
||||
<figure |
||||
className="mx-auto mb-6 mt-3 flex h-fit w-fit flex-col rounded bg-slate-300/20 dark:bg-rose-50/25" |
||||
aria-label={_alt} |
||||
> |
||||
<Image |
||||
src={src || ''} |
||||
alt={_alt} |
||||
width={width} |
||||
height={height} |
||||
className={cn('rounded', caption && 'rounded-b-none')} |
||||
/> |
||||
{caption && ( |
||||
<figcaption |
||||
className={cn( |
||||
'm-0 rounded-b-[3px] px-6 py-1 text-center', |
||||
'bg-slate-300/50 text-slate-700', |
||||
'dark:bg-rose-50/5 dark:text-rose-50', |
||||
)} |
||||
style={{ |
||||
maxWidth: width, |
||||
}} |
||||
> |
||||
<Balancer>{caption}</Balancer> |
||||
</figcaption> |
||||
)} |
||||
</figure> |
||||
); |
||||
} |
||||
|
||||
/** |
||||
* Code block component with copy button |
||||
*/ |
||||
function pre({ children }: React.HTMLProps<HTMLPreElement>) { |
||||
return <CodeBlock>{children}</CodeBlock>; |
||||
} |
||||
|
||||
export const MDXComponents = { a, p, img, pre, TableOfContents, Callout }; |
@ -0,0 +1,20 @@ |
||||
'use client'; |
||||
|
||||
import { useMDXComponent } from 'next-contentlayer/hooks'; |
||||
|
||||
import { MDXComponents } from '@/components/mdx-components'; |
||||
import { MDXStyles } from '@/components/mdx-styles'; |
||||
|
||||
type MDXContentProps = { |
||||
code: string; |
||||
}; |
||||
|
||||
export function MDXContent({ code }: MDXContentProps) { |
||||
const Component = useMDXComponent(code); |
||||
|
||||
return ( |
||||
<MDXStyles> |
||||
<Component components={MDXComponents} /> |
||||
</MDXStyles> |
||||
); |
||||
} |
@ -0,0 +1,29 @@ |
||||
'use client'; |
||||
|
||||
import '@/styles/markdown.css'; |
||||
import { useThemeStore } from '@/stores/theme-store'; |
||||
import { cn } from '@/lib/utils'; |
||||
|
||||
type MDXStylesProps = { |
||||
children: React.ReactNode; |
||||
}; |
||||
|
||||
export function MDXStyles({ children }: MDXStylesProps) { |
||||
const fontSize = useThemeStore((state) => state.fontSize); |
||||
|
||||
return ( |
||||
<section |
||||
className={cn( |
||||
'prose-' + fontSize, |
||||
'prose prose-slate max-w-none py-8 dark:prose-invert dark:text-rose-50', |
||||
'prose-headings:drop-shadow-sm dark:prose-headings:text-rose-50', |
||||
'prose-ul:my-4 prose-li:my-0 prose-li:marker:text-slate-600 dark:prose-li:marker:text-slate-400', |
||||
'prose-a:text-accent prose-a:no-underline hover:prose-a:underline dark:prose-a:text-accent-dark', |
||||
'prose-blockquote:border-l-slate-800 dark:prose-blockquote:border-l-slate-300 dark:prose-blockquote:text-rose-50', |
||||
'prose-hr:border-slate-700 dark:prose-hr:border-slate-300', |
||||
)} |
||||
> |
||||
{children} |
||||
</section> |
||||
); |
||||
} |
@ -0,0 +1,39 @@ |
||||
import Link from 'next/link'; |
||||
|
||||
import { BlogTitle } from '@/components/blog-title'; |
||||
import { cn } from '@/lib/utils'; |
||||
import { allPages } from '../.contentlayer/generated'; |
||||
|
||||
type NavigationBarProps = { |
||||
className?: string; |
||||
}; |
||||
|
||||
export function NavigationBar({ className }: NavigationBarProps) { |
||||
return ( |
||||
<nav |
||||
className={cn( |
||||
'flex h-8 flex-row items-center space-x-2 max-xs:text-sm sm:space-x-4', |
||||
className, |
||||
)} |
||||
> |
||||
<Link href="/"> |
||||
<BlogTitle /> |
||||
</Link> |
||||
<Link |
||||
href="/posts" |
||||
className="font-semibold hover:text-accent dark:hover:text-accent-dark" |
||||
> |
||||
Статьи |
||||
</Link> |
||||
{allPages.map((page) => ( |
||||
<Link |
||||
href={page.url} |
||||
key={page._id} |
||||
className="font-semibold hover:text-accent dark:hover:text-accent-dark" |
||||
> |
||||
{page.title} |
||||
</Link> |
||||
))} |
||||
</nav> |
||||
); |
||||
} |
@ -0,0 +1,88 @@ |
||||
import { ArrowLeft, ArrowRight } from 'lucide-react'; |
||||
|
||||
import { cn } from '@/lib/utils'; |
||||
|
||||
type PageControlsProps = { |
||||
currentPage: number; |
||||
lastPage: number; |
||||
setCurrentPage: (page: number) => void; |
||||
bottom?: boolean; |
||||
}; |
||||
|
||||
export function PageControls({ |
||||
currentPage, |
||||
lastPage, |
||||
bottom = false, |
||||
setCurrentPage, |
||||
}: PageControlsProps) { |
||||
return ( |
||||
<div className="grid w-full grid-cols-[6rem,1fr,6rem] items-center justify-between"> |
||||
<button |
||||
onClick={() => { |
||||
setCurrentPage(currentPage - 1); |
||||
if (bottom) { |
||||
document.body.scrollTop = 0; |
||||
document.documentElement.scrollTop = 0; |
||||
} |
||||
}} |
||||
disabled={currentPage === 1} |
||||
className={cn( |
||||
'flex h-6 flex-row items-center space-x-1 justify-self-start', |
||||
bottom ? 'place-self-start' : 'place-self-end', |
||||
'text-slate-600 enabled:hover:text-accent disabled:text-slate-400', |
||||
'dark:text-slate-300 dark:enabled:hover:text-accent-dark dark:disabled:text-slate-500', |
||||
)} |
||||
> |
||||
<ArrowLeft aria-label="Previous page" /> |
||||
<span>Прошлые</span> |
||||
</button> |
||||
<div className="flex flex-row flex-wrap justify-center space-x-2 px-2 text-2xl"> |
||||
{Array.from({ length: currentPage }, (_, i) => ( |
||||
<span |
||||
key={i} |
||||
className={cn( |
||||
currentPage === i + 1 |
||||
? 'leading-3' |
||||
: currentPage === i + 2 |
||||
? 'leading-4' |
||||
: 'leading-5', |
||||
'text-slate-800 dark:text-slate-300', |
||||
)} |
||||
> |
||||
• |
||||
</span> |
||||
))} |
||||
{Array.from({ length: lastPage - currentPage }, (_, i) => ( |
||||
<span |
||||
key={i} |
||||
className={cn( |
||||
i === 0 ? 'leading-4' : 'leading-5', |
||||
'text-slate-400 dark:text-slate-500', |
||||
)} |
||||
> |
||||
• |
||||
</span> |
||||
))} |
||||
</div> |
||||
<button |
||||
onClick={() => { |
||||
setCurrentPage(currentPage + 1); |
||||
if (bottom) { |
||||
document.body.scrollTop = 0; |
||||
document.documentElement.scrollTop = 0; |
||||
} |
||||
}} |
||||
disabled={currentPage === lastPage} |
||||
className={cn( |
||||
'flex h-6 flex-row items-center justify-end space-x-1 justify-self-end', |
||||
bottom ? 'place-self-start' : 'place-self-end', |
||||
'text-slate-600 enabled:hover:text-accent disabled:text-slate-400', |
||||
'dark:text-slate-300 dark:enabled:hover:text-accent-dark dark:disabled:text-slate-500', |
||||
)} |
||||
> |
||||
<span className="h-full">Новые</span> |
||||
<ArrowRight aria-label="Next page" /> |
||||
</button> |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,73 @@ |
||||
'use client'; |
||||
|
||||
import Link from 'next/link'; |
||||
import { type Post } from 'contentlayer/generated'; |
||||
import { Calendar } from 'lucide-react'; |
||||
import Balancer from 'react-wrap-balancer'; |
||||
|
||||
import { PostTags } from '@/components/post-tags'; |
||||
import { formatDateTime } from '@/lib/datetime'; |
||||
import { cn } from '@/lib/utils'; |
||||
|
||||
type PostCardProps = { |
||||
post: Post; |
||||
}; |
||||
|
||||
export function PostCard({ post }: PostCardProps) { |
||||
const publishedDate = formatDateTime(post.date); |
||||
|
||||
return ( |
||||
<Link |
||||
href={post.url} |
||||
className={cn( |
||||
'group relative flex h-fit w-full', |
||||
'transition-transform duration-300 ease-in-out hover:scale-[1.02]', |
||||
)} |
||||
aria-label={post.title} |
||||
> |
||||
<article |
||||
className={cn( |
||||
'flex h-fit w-full flex-col space-y-4 rounded', |
||||
'relative z-10 m-0.5 py-3 pl-10 pr-6 shadow-lg hover:shadow-xl', |
||||
'bg-slate-100/95 dark:bg-slate-600/90', |
||||
)} |
||||
> |
||||
<div className="flex flex-col space-y-2"> |
||||
<h2 className="text-2xl font-bold leading-normal text-slate-800 dark:text-rose-50 sm:text-3xl"> |
||||
<Balancer> |
||||
{post.title} |
||||
{publishedDate.isFresh && ( |
||||
<> |
||||
{' '} |
||||
<sup className="text-base font-semibold text-accent dark:text-accent-dark"> |
||||
Новый |
||||
</sup> |
||||
</> |
||||
)} |
||||
</Balancer> |
||||
</h2> |
||||
<p className="text-slate-700 dark:text-rose-50"> |
||||
<Balancer>{post.excerpt}</Balancer> |
||||
</p> |
||||
<p className="inline-flex items-center space-x-1 text-slate-600/90 dark:text-rose-50/80"> |
||||
<Calendar className="h-4 w-4 self-baseline" aria-hidden /> |
||||
<span className="text-sm"> |
||||
Публикация {publishedDate.asString}{' '} |
||||
<span className="opacity-95 max-xs:hidden"> |
||||
· {publishedDate.asRelativeTimeString} |
||||
</span> |
||||
</span> |
||||
</p> |
||||
</div> |
||||
<PostTags tags={post.tags} className="text-sm sm:text-xs" /> |
||||
</article> |
||||
<div |
||||
className={cn( |
||||
'absolute inset-0 z-20 my-auto h-[calc(100%_-_0.25rem)] w-4 rounded-l', |
||||
'group-hover:animate-border group-focus:animate-border-fast', |
||||
'bg-slate-700 dark:bg-rose-50', |
||||
)} |
||||
/> |
||||
</Link> |
||||
); |
||||
} |
@ -0,0 +1,65 @@ |
||||
'use client'; |
||||
|
||||
import Balancer from 'react-wrap-balancer'; |
||||
|
||||
import { useThemeStore } from '@/stores/theme-store'; |
||||
import { PostTags } from '@/components/post-tags'; |
||||
import { formatDateTime } from '@/lib/datetime'; |
||||
import { cn } from '@/lib/utils'; |
||||
|
||||
type PostIntroProps = { |
||||
title: string; |
||||
date: string; |
||||
tags: string[]; |
||||
}; |
||||
|
||||
export function PostIntro({ title, date, tags }: PostIntroProps) { |
||||
const fontSize = useThemeStore((state) => state.fontSize); |
||||
const publishedDate = formatDateTime(date); |
||||
|
||||
return ( |
||||
<section className="flex flex-col space-y-4 sm:p-3"> |
||||
<h1 |
||||
className={cn( |
||||
'font-bold text-slate-800 drop-shadow-sm dark:text-rose-50', |
||||
fontSize === 'sm' && 'text-xl sm:text-2xl md:text-3xl', |
||||
fontSize === 'base' && 'text-2xl sm:text-3xl md:text-4xl', |
||||
fontSize === 'lg' && 'text-3xl sm:text-4xl md:text-5xl', |
||||
fontSize === 'xl' && 'text-4xl sm:text-5xl md:text-6xl', |
||||
fontSize === '2xl' && 'text-5xl sm:text-6xl md:text-7xl', |
||||
)} |
||||
> |
||||
<Balancer>{title}</Balancer> |
||||
</h1> |
||||
<div |
||||
className={cn( |
||||
'text-slate-700 dark:text-rose-50', |
||||
fontSize === 'sm' && 'text-xs sm:text-sm', |
||||
fontSize === 'base' && 'text-sm sm:text-base', |
||||
fontSize === 'lg' && 'text-base sm:text-lg', |
||||
fontSize === 'xl' && 'text-lg sm:text-xl', |
||||
fontSize === '2xl' && 'text-xl sm:text-2xl', |
||||
)} |
||||
> |
||||
<span>Published </span> |
||||
<time dateTime={publishedDate.asISOString}> |
||||
{publishedDate.asString}{' '} |
||||
</time> |
||||
<div className="inline-block text-slate-600 dark:text-rose-50/60"> |
||||
{' '} |
||||
· {publishedDate.asRelativeTimeString} |
||||
</div> |
||||
</div> |
||||
<PostTags |
||||
tags={tags} |
||||
className={cn( |
||||
fontSize === 'sm' && 'text-xs', |
||||
fontSize === 'base' && 'text-xs', |
||||
fontSize === 'lg' && 'text-sm', |
||||
fontSize === 'xl' && 'text-base', |
||||
fontSize === '2xl' && 'text-lg', |
||||
)} |
||||
/> |
||||
</section> |
||||
); |
||||
} |
@ -0,0 +1,40 @@ |
||||
'use client'; |
||||
|
||||
import { useState } from 'react'; |
||||
import { type Post } from 'contentlayer/generated'; |
||||
|
||||
import { PageControls } from '@/components/page-controls'; |
||||
import { PostCard } from '@/components/post-card'; |
||||
|
||||
type PostPaginatorProps = { |
||||
posts: Post[]; |
||||
postsPerPage?: number; |
||||
}; |
||||
|
||||
export function PostPaginator({ posts, postsPerPage = 5 }: PostPaginatorProps) { |
||||
const [currentPage, setCurrentPage] = useState(1); |
||||
const lastPage = Math.ceil(posts.length / postsPerPage); |
||||
|
||||
return ( |
||||
<section className="flex h-full w-full flex-col space-y-4"> |
||||
<PageControls |
||||
setCurrentPage={setCurrentPage} |
||||
currentPage={currentPage} |
||||
lastPage={lastPage} |
||||
/> |
||||
<div className="flex h-full w-full flex-col space-y-4"> |
||||
{posts |
||||
.slice((currentPage - 1) * postsPerPage, currentPage * postsPerPage) |
||||
.map((post) => ( |
||||
<PostCard key={post._id} post={post} /> |
||||
))} |
||||
</div> |
||||
<PageControls |
||||
setCurrentPage={setCurrentPage} |
||||
currentPage={currentPage} |
||||
lastPage={lastPage} |
||||
bottom |
||||
/> |
||||
</section> |
||||
); |
||||
} |
@ -0,0 +1,25 @@ |
||||
import { cn } from '@/lib/utils'; |
||||
|
||||
interface PostTagsProps { |
||||
tags: string[]; |
||||
className?: string; |
||||
} |
||||
|
||||
export function PostTags({ tags, className }: PostTagsProps) { |
||||
return ( |
||||
<div className={cn('flex flex-row flex-wrap gap-1', className)}> |
||||
{tags.map((tag) => ( |
||||
<span |
||||
key={tag} |
||||
className={cn( |
||||
'w-fit whitespace-nowrap rounded px-2 py-1', |
||||
'bg-slate-700 text-slate-200', |
||||
'dark:bg-rose-50 dark:text-slate-700', |
||||
)} |
||||
> |
||||
{tag} |
||||
</span> |
||||
))} |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,69 @@ |
||||
'use client'; |
||||
|
||||
import { X } from 'lucide-react'; |
||||
|
||||
import { useSearchStore } from '@/stores/search-store'; |
||||
import { cn } from '@/lib/utils'; |
||||
|
||||
type SearchInputProps = { |
||||
hasResults: boolean; |
||||
}; |
||||
|
||||
export function SearchInput({ hasResults }: SearchInputProps) { |
||||
const query = useSearchStore((state) => state.query); |
||||
const setQuery = useSearchStore((state) => state.setQuery); |
||||
|
||||
return ( |
||||
<> |
||||
<input |
||||
type="text" |
||||
autoFocus |
||||
value={query} |
||||
onChange={(e) => setQuery(e.target.value)} |
||||
placeholder={ |
||||
placeholders[Math.floor(Math.random() * placeholders.length)] |
||||
} |
||||
className={cn( |
||||
'w-full rounded border px-2 placeholder:opacity-50', |
||||
hasResults ? 'sm:text-2xl' : 'sm:text-4xl', |
||||
'border-slate-400 bg-slate-100 text-slate-700', |
||||
'dark:border-slate-500 dark:bg-slate-700 dark:text-rose-50', |
||||
)} |
||||
aria-label="Search Posts" |
||||
/> |
||||
{query && ( |
||||
<button |
||||
onClick={() => setQuery('')} |
||||
className={cn( |
||||
'absolute right-14 top-[1.1rem]', |
||||
hasResults ? 'sm:top-5' : 'sm:top-7', |
||||
)} |
||||
> |
||||
<X |
||||
className="icon-base text-slate-500 dark:text-slate-400" |
||||
aria-label="Clear" |
||||
/> |
||||
</button> |
||||
)} |
||||
</> |
||||
); |
||||
} |
||||
|
||||
const placeholders = [ |
||||
'What are you looking for?', |
||||
'Something need doing?', |
||||
'Looking for something specific?', |
||||
'Looking for something special?', |
||||
'Ah, I have just the thing for you.', |
||||
'What brings you here?', |
||||
'Whatcha lookin for?', |
||||
"You need somethin'?", |
||||
'I got what you need!', |
||||
'Yeah, what do you want?', |
||||
'What do you require?', |
||||
'I have exactly what you need.', |
||||
'What can I get for ya today?', |
||||
'May you find what you seek.', |
||||
"I hope you'll find something useful!", |
||||
'Feel free to browse.', |
||||
]; |
@ -0,0 +1,76 @@ |
||||
'use client'; |
||||
|
||||
import Link from 'next/link'; |
||||
import { type Post } from 'contentlayer/generated'; |
||||
import Balancer from 'react-wrap-balancer'; |
||||
|
||||
import { useSearchStore } from '@/stores/search-store'; |
||||
import { formatDateTime } from '@/lib/datetime'; |
||||
import { cn } from '@/lib/utils'; |
||||
|
||||
type SearchResultsProps = { |
||||
query: string; |
||||
results: Post[]; |
||||
}; |
||||
|
||||
export function SearchResults({ query, results }: SearchResultsProps) { |
||||
const toggleSearch = useSearchStore((state) => state.toggleSearch); |
||||
|
||||
return ( |
||||
<ul className="flex flex-col overflow-scroll"> |
||||
{results.map((post) => { |
||||
const publishedDate = formatDateTime(post.date); |
||||
|
||||
return ( |
||||
<li |
||||
key={post.slug} |
||||
className={cn( |
||||
'rounded p-2 transition-none sm:px-8', |
||||
'even:bg-slate-400/30 hover:bg-slate-500/50', |
||||
'dark:even:bg-slate-700/60 dark:hover:bg-slate-400/40', |
||||
)} |
||||
> |
||||
<Link |
||||
href={post.url} |
||||
onClick={toggleSearch} |
||||
className="flex h-fit flex-col" |
||||
> |
||||
<span className="font-semibold text-slate-800 dark:text-rose-50 sm:text-xl"> |
||||
<Balancer>{highlightSearchQuery(query, post.title)}</Balancer> |
||||
</span> |
||||
<span className="text-sm text-slate-700 dark:text-rose-50 sm:text-base"> |
||||
{highlightSearchQuery(query, post.excerpt)} |
||||
</span> |
||||
<span className="text-sm text-slate-600 dark:text-slate-300"> |
||||
{publishedDate.asString} · {publishedDate.asRelativeTimeString} |
||||
</span> |
||||
</Link> |
||||
</li> |
||||
); |
||||
})} |
||||
</ul> |
||||
); |
||||
} |
||||
|
||||
/** |
||||
* Highlights the search query in the text by wrapping it in a span with the |
||||
* font-extrabold class. |
||||
* @param query The search query |
||||
* @param text The text to highlight |
||||
* @returns The text with the query highlighted |
||||
*/ |
||||
function highlightSearchQuery(query: string, text: string) { |
||||
const sanitizedQuery = query.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); |
||||
return text.split(new RegExp(`(${sanitizedQuery})`, 'gi')).map((part, i) => ( |
||||
<span |
||||
key={i} |
||||
className={ |
||||
part.toLowerCase() === query.toLowerCase() |
||||
? 'font-extrabold' |
||||
: undefined |
||||
} |
||||
> |
||||
{part} |
||||
</span> |
||||
)); |
||||
} |
@ -0,0 +1,42 @@ |
||||
'use client'; |
||||
|
||||
import { useMemo } from 'react'; |
||||
import { type Post } from 'contentlayer/generated'; |
||||
|
||||
import { useSearchStore } from '@/stores/search-store'; |
||||
import { getTagsWithCount } from '@/lib/search'; |
||||
import { cn } from '@/lib/utils'; |
||||
|
||||
type SearchTagsProps = { |
||||
posts: Post[]; |
||||
className?: string; |
||||
}; |
||||
|
||||
export function SearchTags({ posts, className }: SearchTagsProps) { |
||||
const tagsWithCounts = useMemo(() => getTagsWithCount(posts), [posts]); |
||||
const setQuery = useSearchStore((state) => state.setQuery); |
||||
|
||||
return ( |
||||
<div |
||||
className={cn( |
||||
'flex h-fit flex-row flex-wrap items-center justify-center space-x-4 space-y-1 text-sm', |
||||
className, |
||||
)} |
||||
> |
||||
{tagsWithCounts.map(([tag, count]) => ( |
||||
<button |
||||
key={tag} |
||||
onClick={() => setQuery(tag)} |
||||
className="flex w-fit flex-row items-baseline space-x-0.5" |
||||
> |
||||
<span className="text-slate-600 hover:text-accent dark:text-slate-300 dark:hover:text-accent-dark"> |
||||
#{tag} |
||||
</span> |
||||
<span className="font-mono text-xs text-slate-600 dark:text-slate-300"> |
||||
({count}) |
||||
</span> |
||||
</button> |
||||
))} |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,84 @@ |
||||
'use client'; |
||||
|
||||
import { useEffect, useMemo } from 'react'; |
||||
import { type Post } from 'contentlayer/generated'; |
||||
import { ChevronUp } from 'lucide-react'; |
||||
import { shallow } from 'zustand/shallow'; |
||||
|
||||
import { useSearchStore } from '@/stores/search-store'; |
||||
import { SearchInput } from '@/components/search-input'; |
||||
import { SearchResults } from '@/components/search-results'; |
||||
import { SearchTags } from '@/components/search-tags'; |
||||
import { searchPosts } from '@/lib/search'; |
||||
import { cn } from '@/lib/utils'; |
||||
|
||||
type SearchProps = { |
||||
posts: Post[]; |
||||
}; |
||||
|
||||
export function Search({ posts }: SearchProps) { |
||||
const { query, isSearching, toggleSearch } = useSearchStore( |
||||
(state) => ({ |
||||
query: state.query, |
||||
isSearching: state.isSearching, |
||||
toggleSearch: state.toggleSearch, |
||||
}), |
||||
shallow, |
||||
); |
||||
|
||||
const results = useMemo(() => searchPosts(query, posts), [query, posts]); |
||||
|
||||
useEffect(() => { |
||||
// toggle search on cmd+k or ctrl+k
|
||||
const handleKeyDown = (e: KeyboardEvent) => { |
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') { |
||||
e.preventDefault(); |
||||
toggleSearch(); |
||||
} |
||||
}; |
||||
document.addEventListener('keydown', handleKeyDown); |
||||
|
||||
return () => document.removeEventListener('keydown', handleKeyDown); |
||||
}, [toggleSearch]); |
||||
|
||||
if (!isSearching) return null; |
||||
|
||||
return ( |
||||
<section |
||||
className={cn( |
||||
'fixed left-1/2 top-1/2 z-50 h-fit max-h-[80vh] w-5/6 max-w-3xl -translate-x-1/2 -translate-y-1/2', |
||||
'flex flex-col rounded-md border-2 p-4 backdrop-blur-md', |
||||
'border-slate-400 bg-slate-200/40', |
||||
'dark:border-slate-500 dark:bg-slate-600/80', |
||||
)} |
||||
> |
||||
<div className="mb-2 flex h-fit flex-row items-center"> |
||||
<SearchInput hasResults={results.length > 0} /> |
||||
<button onClick={toggleSearch}> |
||||
<ChevronUp |
||||
className="icon-base ml-2 text-slate-400 dark:text-slate-400" |
||||
aria-label="Close Search" |
||||
/> |
||||
</button> |
||||
</div> |
||||
|
||||
<SearchResults query={query} results={results} /> |
||||
|
||||
{results.length > 0 && ( |
||||
<hr className="my-2 border-slate-400 dark:border-slate-500 max-xs:hidden" /> |
||||
)} |
||||
|
||||
<SearchTags |
||||
posts={posts} |
||||
className={cn( |
||||
'my-2', |
||||
results.length > 0 ? 'max-xs:hidden sm:text-base' : 'sm:text-lg', |
||||
)} |
||||
/> |
||||
|
||||
<div className="absolute bottom-1 left-2 text-xs text-slate-600 dark:text-slate-200 max-sm:hidden"> |
||||
Toggle with ⌘+K or Ctrl+K |
||||
</div> |
||||
</section> |
||||
); |
||||
} |
@ -0,0 +1,25 @@ |
||||
import type { Meta, StoryObj } from '@storybook/react'; |
||||
|
||||
import { BlogTitle } from '@/components/blog-title'; |
||||
import { Center } from './decorators'; |
||||
|
||||
const meta: Meta<typeof BlogTitle> = { |
||||
title: 'Blog Title', |
||||
component: BlogTitle, |
||||
decorators: [Center], |
||||
}; |
||||
|
||||
export default meta; |
||||
type Story = StoryObj<typeof BlogTitle>; |
||||
|
||||
export const Big: Story = { |
||||
args: { |
||||
className: 'text-6xl', |
||||
}, |
||||
}; |
||||
|
||||
export const Small: Story = { |
||||
args: { |
||||
className: 'text-lg', |
||||
}, |
||||
}; |
@ -0,0 +1,30 @@ |
||||
import type { Meta, StoryObj } from '@storybook/react'; |
||||
import { FileText } from 'lucide-react'; |
||||
|
||||
import { Button } from '@/components/button'; |
||||
import { Center } from './decorators'; |
||||
|
||||
const meta: Meta<typeof Button> = { |
||||
title: 'Button', |
||||
component: Button, |
||||
decorators: [Center], |
||||
args: { |
||||
href: '#', |
||||
}, |
||||
}; |
||||
|
||||
export default meta; |
||||
type Story = StoryObj<typeof Button>; |
||||
|
||||
export const WithIcon: Story = { |
||||
args: { |
||||
label: 'Все статьи', |
||||
icon: <FileText className="h-4 w-4" />, |
||||
}, |
||||
}; |
||||
|
||||
export const WithoutIcon: Story = { |
||||
args: { |
||||
label: 'Click Me', |
||||
}, |
||||
}; |
@ -0,0 +1,69 @@ |
||||
import type { Meta, StoryObj } from '@storybook/react'; |
||||
|
||||
import { Callout } from '@/components/callout'; |
||||
import { Markdown, Padding } from './decorators'; |
||||
|
||||
const meta: Meta<typeof Callout> = { |
||||
title: 'Callout', |
||||
component: Callout, |
||||
decorators: [Markdown, Padding], |
||||
}; |
||||
|
||||
export default meta; |
||||
type Story = StoryObj<typeof Callout>; |
||||
|
||||
export const Update: Story = { |
||||
args: { |
||||
type: 'update', |
||||
children: ( |
||||
<div className="my-4"> |
||||
This is an update callout. Lorem ipsum dolor sit amet consectetur |
||||
adipisicing elit. Reiciendis quas quasi adipisci ex laudantium neque |
||||
officiis veritatis eligendi tempore modi dignissimos sed, saepe facilis |
||||
totam, natus odit, incidunt perferendis accusamus. |
||||
</div> |
||||
), |
||||
}, |
||||
}; |
||||
|
||||
export const Note: Story = { |
||||
args: { |
||||
type: 'note', |
||||
children: ( |
||||
<div className="my-4"> |
||||
This is a note callout. Lorem ipsum dolor sit amet consectetur |
||||
adipisicing elit. Reiciendis quas quasi adipisci ex laudantium neque |
||||
officiis veritatis eligendi tempore modi dignissimos sed, saepe facilis |
||||
totam, natus odit, incidunt perferendis accusamus. |
||||
</div> |
||||
), |
||||
}, |
||||
}; |
||||
|
||||
export const Warning: Story = { |
||||
args: { |
||||
type: 'warning', |
||||
children: ( |
||||
<div className="my-4"> |
||||
This is a warning callout. Lorem ipsum dolor sit amet consectetur |
||||
adipisicing elit. Reiciendis quas quasi adipisci ex laudantium neque |
||||
officiis veritatis eligendi tempore modi dignissimos sed, saepe facilis |
||||
totam, natus odit, incidunt perferendis accusamus. |
||||
</div> |
||||
), |
||||
}, |
||||
}; |
||||
|
||||
export const Important: Story = { |
||||
args: { |
||||
type: 'important', |
||||
children: ( |
||||
<div className="my-4"> |
||||
This is an important callout. Lorem ipsum dolor sit amet consectetur |
||||
adipisicing elit. Reiciendis quas quasi adipisci ex laudantium neque |
||||
officiis veritatis eligendi tempore modi dignissimos sed, saepe facilis |
||||
totam, natus odit, incidunt perferendis accusamus. |
||||
</div> |
||||
), |
||||
}, |
||||
}; |
@ -0,0 +1,426 @@ |
||||
import type { Meta, StoryObj } from '@storybook/react'; |
||||
|
||||
import { CodeBlock } from '@/components/code-block'; |
||||
import { Markdown, Padding } from './decorators'; |
||||
|
||||
const meta: Meta<typeof CodeBlock> = { |
||||
title: 'Code Block', |
||||
component: CodeBlock, |
||||
render: (args) => ( |
||||
<CodeBlock {...args}> |
||||
<CodeLight /> |
||||
<CodeDark /> |
||||
</CodeBlock> |
||||
), |
||||
decorators: [ |
||||
(Story) => ( |
||||
<div className="w-full"> |
||||
<Story /> |
||||
</div> |
||||
), |
||||
Markdown, |
||||
Padding, |
||||
], |
||||
}; |
||||
|
||||
export default meta; |
||||
type Story = StoryObj<typeof CodeBlock>; |
||||
|
||||
export const Normal: Story = {}; |
||||
|
||||
export const WithTitle: Story = { |
||||
decorators: [ |
||||
(Story) => ( |
||||
<> |
||||
<div data-rehype-pretty-code-title="">/src/components/parallax.tsx</div> |
||||
<Story /> |
||||
</> |
||||
), |
||||
], |
||||
}; |
||||
|
||||
/** Generated by rehype-pretty-code */ |
||||
const CodeLight = () => ( |
||||
<code data-language="tsx" data-theme="light"> |
||||
<span className="line"> |
||||
<span style={{ color: '#D73A49' }}>export</span> |
||||
<span style={{ color: '#24292E' }}> </span> |
||||
<span style={{ color: '#D73A49' }}>default</span> |
||||
<span style={{ color: '#24292E' }}> </span> |
||||
<span style={{ color: '#D73A49' }}>function</span> |
||||
<span style={{ color: '#24292E' }}> </span> |
||||
<span style={{ color: '#6F42C1' }}>Parallax</span> |
||||
<span style={{ color: '#24292E' }}>() {'{'}</span> |
||||
</span> |
||||
<span className="line"> |
||||
<span style={{ color: '#24292E' }}></span> |
||||
<span style={{ color: '#D73A49' }}>const</span> |
||||
<span style={{ color: '#24292E' }}> </span> |
||||
<span style={{ color: '#005CC5' }}>ref</span> |
||||
<span style={{ color: '#24292E' }}> </span> |
||||
<span style={{ color: '#D73A49' }}>=</span> |
||||
<span style={{ color: '#24292E' }}> </span> |
||||
<span style={{ color: '#6F42C1' }}>useRef</span> |
||||
<span style={{ color: '#24292E' }}><</span> |
||||
<span style={{ color: '#6F42C1' }}>HTMLDivElement</span> |
||||
<span style={{ color: '#24292E' }}>>(</span> |
||||
<span style={{ color: '#005CC5' }}>null</span> |
||||
<span style={{ color: '#24292E' }}>);</span> |
||||
</span> |
||||
<span className="line"> </span> |
||||
<span className="line"> |
||||
<span style={{ color: '#24292E' }}></span> |
||||
<span style={{ color: '#D73A49' }}>const</span> |
||||
<span style={{ color: '#24292E' }}> {'{'} </span> |
||||
<span style={{ color: '#005CC5' }}>scrollYProgress</span> |
||||
<span style={{ color: '#24292E' }}> {'}'} </span> |
||||
<span style={{ color: '#D73A49' }}>=</span> |
||||
<span style={{ color: '#24292E' }}> </span> |
||||
<span style={{ color: '#6F42C1' }}>useScroll</span> |
||||
<span style={{ color: '#24292E' }}> |
||||
({'{'} target: ref {'}'}); |
||||
</span> |
||||
</span> |
||||
<span className="line"> </span> |
||||
<span className="line"> |
||||
<span style={{ color: '#24292E' }}></span> |
||||
<span style={{ color: '#D73A49' }}>const</span> |
||||
<span style={{ color: '#24292E' }}> </span> |
||||
<span style={{ color: '#005CC5' }}>yYellow</span> |
||||
<span style={{ color: '#24292E' }}> </span> |
||||
<span style={{ color: '#D73A49' }}>=</span> |
||||
<span style={{ color: '#24292E' }}> </span> |
||||
<span style={{ color: '#6F42C1' }}>useParallax</span> |
||||
<span style={{ color: '#24292E' }}>(scrollYProgress, </span> |
||||
<span style={{ color: '#005CC5' }}>300</span> |
||||
<span style={{ color: '#24292E' }}>);</span> |
||||
</span> |
||||
<span className="line"> |
||||
<span style={{ color: '#24292E' }}></span> |
||||
<span style={{ color: '#D73A49' }}>const</span> |
||||
<span style={{ color: '#24292E' }}> </span> |
||||
<span style={{ color: '#005CC5' }}>yGreen</span> |
||||
<span style={{ color: '#24292E' }}> </span> |
||||
<span style={{ color: '#D73A49' }}>=</span> |
||||
<span style={{ color: '#24292E' }}> </span> |
||||
<span style={{ color: '#6F42C1' }}>useParallax</span> |
||||
<span style={{ color: '#24292E' }}>(scrollYProgress, </span> |
||||
<span style={{ color: '#005CC5' }}>600</span> |
||||
<span style={{ color: '#24292E' }}>);</span> |
||||
</span> |
||||
<span className="line"> </span> |
||||
<span className="line"> |
||||
<span style={{ color: '#24292E' }}></span> |
||||
<span style={{ color: '#D73A49' }}>return</span> |
||||
<span style={{ color: '#24292E' }}> (</span> |
||||
</span> |
||||
<span className="line"> |
||||
<span style={{ color: '#24292E' }}> <</span> |
||||
<span style={{ color: '#005CC5' }}>motion.div</span> |
||||
<span style={{ color: '#24292E' }}> </span> |
||||
<span style={{ color: '#6F42C1' }}>ref</span> |
||||
<span style={{ color: '#D73A49' }}>=</span> |
||||
<span style={{ color: '#24292E' }}> |
||||
{'{'}ref{'}'}{' '} |
||||
</span> |
||||
<span style={{ color: '#6F42C1' }}>className</span> |
||||
<span style={{ color: '#D73A49' }}>=</span> |
||||
<span style={{ color: '#032F62' }}> |
||||
"flex h-1/2 w-2/3 flex-row gap-2" |
||||
</span> |
||||
<span style={{ color: '#24292E' }}>></span> |
||||
</span> |
||||
<span className="line"> |
||||
<span style={{ color: '#24292E' }}> <</span> |
||||
<span style={{ color: '#005CC5' }}>motion.div</span> |
||||
<span style={{ color: '#24292E' }}> </span> |
||||
<span style={{ color: '#6F42C1' }}>className</span> |
||||
<span style={{ color: '#D73A49' }}>=</span> |
||||
<span style={{ color: '#032F62' }}>"w-1/3 bg-rose-600"</span> |
||||
<span style={{ color: '#24292E' }}> /></span> |
||||
</span> |
||||
<span className="line"> |
||||
<span style={{ color: '#24292E' }}> <</span> |
||||
<span style={{ color: '#005CC5' }}>motion.div</span> |
||||
</span> |
||||
<span className="line"> |
||||
<span style={{ color: '#24292E' }}> </span> |
||||
<span style={{ color: '#6F42C1' }}>className</span> |
||||
<span style={{ color: '#D73A49' }}>=</span> |
||||
<span style={{ color: '#032F62' }}>"w-1/3 bg-amber-600"</span> |
||||
</span> |
||||
<span className="line"> |
||||
<span style={{ color: '#24292E' }}> </span> |
||||
<span style={{ color: '#6F42C1' }}>initial</span> |
||||
<span style={{ color: '#D73A49' }}>=</span> |
||||
<span style={{ color: '#24292E' }}> |
||||
{'{'} |
||||
{'{'} y:{' '} |
||||
</span> |
||||
<span style={{ color: '#005CC5' }}>0</span> |
||||
<span style={{ color: '#24292E' }}> |
||||
{' '} |
||||
{'}'} |
||||
{'}'} |
||||
</span> |
||||
</span> |
||||
<span className="line"> |
||||
<span style={{ color: '#24292E' }}> </span> |
||||
<span style={{ color: '#6F42C1' }}>style</span> |
||||
<span style={{ color: '#D73A49' }}>=</span> |
||||
<span style={{ color: '#24292E' }}> |
||||
{'{'} |
||||
{'{'} y: yYellow {'}'} |
||||
{'}'} |
||||
</span> |
||||
</span> |
||||
<span className="line"> |
||||
<span style={{ color: '#24292E' }}> /></span> |
||||
</span> |
||||
<span className="line"> |
||||
<span style={{ color: '#24292E' }}> <</span> |
||||
<span style={{ color: '#005CC5' }}>motion.div</span> |
||||
</span> |
||||
<span className="line"> |
||||
<span style={{ color: '#24292E' }}> </span> |
||||
<span style={{ color: '#6F42C1' }}>className</span> |
||||
<span style={{ color: '#D73A49' }}>=</span> |
||||
<span style={{ color: '#032F62' }}>"w-1/3 bg-emerald-600"</span> |
||||
</span> |
||||
<span className="line"> |
||||
<span style={{ color: '#24292E' }}> </span> |
||||
<span style={{ color: '#6F42C1' }}>initial</span> |
||||
<span style={{ color: '#D73A49' }}>=</span> |
||||
<span style={{ color: '#24292E' }}> |
||||
{'{'} |
||||
{'{'} y:{' '} |
||||
</span> |
||||
<span style={{ color: '#005CC5' }}>0</span> |
||||
<span style={{ color: '#24292E' }}> |
||||
{' '} |
||||
{'}'} |
||||
{'}'} |
||||
</span> |
||||
</span> |
||||
<span className="line"> |
||||
<span style={{ color: '#24292E' }}> </span> |
||||
<span style={{ color: '#6F42C1' }}>style</span> |
||||
<span style={{ color: '#D73A49' }}>=</span> |
||||
<span style={{ color: '#24292E' }}> |
||||
{'{'} |
||||
{'{'} y: yGreen {'}'} |
||||
{'}'} |
||||
</span> |
||||
</span> |
||||
<span className="line"> |
||||
<span style={{ color: '#24292E' }}> /></span> |
||||
</span> |
||||
<span className="line"> |
||||
<span style={{ color: '#24292E' }}> </</span> |
||||
<span style={{ color: '#005CC5' }}>motion.div</span> |
||||
<span style={{ color: '#24292E' }}>></span> |
||||
</span> |
||||
<span className="line"> |
||||
<span style={{ color: '#24292E' }}> );</span> |
||||
</span> |
||||
<span className="line"> |
||||
<span style={{ color: '#24292E' }}>{'}'}</span> |
||||
</span> |
||||
</code> |
||||
); |
||||
|
||||
/** Generated by rehype-pretty-code */ |
||||
const CodeDark = () => ( |
||||
<code data-language="tsx" data-theme="dark"> |
||||
<span className="line"> |
||||
<span style={{ color: 'rgb(249, 117, 131)' }}>export</span> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}> </span> |
||||
<span style={{ color: 'rgb(249, 117, 131)' }}>default</span> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}> </span> |
||||
<span style={{ color: 'rgb(249, 117, 131)' }}>function</span> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}> </span> |
||||
<span style={{ color: 'rgb(179, 146, 240)' }}>Parallax</span> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}>() {'{'}</span> |
||||
</span> |
||||
<span className="line"> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}> </span> |
||||
<span style={{ color: 'rgb(249, 117, 131)' }}>const</span> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}> </span> |
||||
<span style={{ color: 'rgb(121, 184, 255)' }}>ref</span> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}> </span> |
||||
<span style={{ color: 'rgb(249, 117, 131)' }}>=</span> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}> </span> |
||||
<span style={{ color: 'rgb(179, 146, 240)' }}>useRef</span> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}><</span> |
||||
<span style={{ color: 'rgb(179, 146, 240)' }}>HTMLDivElement</span> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}>>(</span> |
||||
<span style={{ color: 'rgb(121, 184, 255)' }}>null</span> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}>);</span> |
||||
</span> |
||||
<span className="line"> </span> |
||||
<span className="line"> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}></span> |
||||
<span style={{ color: 'rgb(249, 117, 131)' }}>const</span> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}> {'{'} </span> |
||||
<span style={{ color: 'rgb(121, 184, 255)' }}>scrollYProgress</span> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}> {'}'} </span> |
||||
<span style={{ color: 'rgb(249, 117, 131)' }}>=</span> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}> </span> |
||||
<span style={{ color: 'rgb(179, 146, 240)' }}>useScroll</span> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}> |
||||
({'{'} target: ref {'}'}); |
||||
</span> |
||||
</span> |
||||
<span className="line"> </span> |
||||
<span className="line"> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}></span> |
||||
<span style={{ color: 'rgb(249, 117, 131)' }}>const</span> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}> </span> |
||||
<span style={{ color: 'rgb(121, 184, 255)' }}>yYellow</span> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}> </span> |
||||
<span style={{ color: 'rgb(249, 117, 131)' }}>=</span> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}> </span> |
||||
<span style={{ color: 'rgb(179, 146, 240)' }}>useParallax</span> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}>(scrollYProgress, </span> |
||||
<span style={{ color: 'rgb(121, 184, 255)' }}>300</span> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}>);</span> |
||||
</span> |
||||
<span className="line"> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}></span> |
||||
<span style={{ color: 'rgb(249, 117, 131)' }}>const</span> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}> </span> |
||||
<span style={{ color: 'rgb(121, 184, 255)' }}>yGreen</span> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}> </span> |
||||
<span style={{ color: 'rgb(249, 117, 131)' }}>=</span> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}> </span> |
||||
<span style={{ color: 'rgb(179, 146, 240)' }}>useParallax</span> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}>(scrollYProgress, </span> |
||||
<span style={{ color: 'rgb(121, 184, 255)' }}>600</span> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}>);</span> |
||||
</span> |
||||
<span className="line"> </span> |
||||
<span className="line"> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}></span> |
||||
<span style={{ color: 'rgb(249, 117, 131)' }}>return</span> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}> (</span> |
||||
</span> |
||||
<span className="line"> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}> <</span> |
||||
<span style={{ color: 'rgb(121, 184, 255)' }}>motion.div</span> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}> </span> |
||||
<span style={{ color: 'rgb(179, 146, 240)' }}>ref</span> |
||||
<span style={{ color: 'rgb(249, 117, 131)' }}>=</span> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}> |
||||
{'{'}ref{'}'}{' '} |
||||
</span> |
||||
<span style={{ color: 'rgb(179, 146, 240)' }}>className</span> |
||||
<span style={{ color: 'rgb(249, 117, 131)' }}>=</span> |
||||
<span style={{ color: 'rgb(158, 203, 255)' }}> |
||||
"flex h-1/2 w-2/3 flex-row gap-2" |
||||
</span> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}>></span> |
||||
</span> |
||||
<span className="line"> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}> <</span> |
||||
<span style={{ color: 'rgb(121, 184, 255)' }}>motion.div</span> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}> </span> |
||||
<span style={{ color: 'rgb(179, 146, 240)' }}>className</span> |
||||
<span style={{ color: 'rgb(249, 117, 131)' }}>=</span> |
||||
<span style={{ color: 'rgb(158, 203, 255)' }}> |
||||
"w-1/3 bg-rose-600" |
||||
</span> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}> /></span> |
||||
</span> |
||||
<span className="line"> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}> <</span> |
||||
<span style={{ color: 'rgb(121, 184, 255)' }}>motion.div</span> |
||||
</span> |
||||
<span className="line"> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}> </span> |
||||
<span style={{ color: 'rgb(179, 146, 240)' }}>className</span> |
||||
<span style={{ color: 'rgb(249, 117, 131)' }}>=</span> |
||||
<span style={{ color: 'rgb(158, 203, 255)' }}> |
||||
"w-1/3 bg-amber-600" |
||||
</span> |
||||
</span> |
||||
<span className="line"> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}> </span> |
||||
<span style={{ color: 'rgb(179, 146, 240)' }}>initial</span> |
||||
<span style={{ color: 'rgb(249, 117, 131)' }}>=</span> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}> |
||||
{'{'} |
||||
{'{'} y:{' '} |
||||
</span> |
||||
<span style={{ color: 'rgb(121, 184, 255)' }}>0</span> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}> |
||||
{' '} |
||||
{'}'} |
||||
{'}'} |
||||
</span> |
||||
</span> |
||||
<span className="line"> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}> </span> |
||||
<span style={{ color: 'rgb(179, 146, 240)' }}>style</span> |
||||
<span style={{ color: 'rgb(249, 117, 131)' }}>=</span> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}> |
||||
{'{'} |
||||
{'{'} y: yYellow {'}'} |
||||
{'}'} |
||||
</span> |
||||
</span> |
||||
<span className="line"> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}> /></span> |
||||
</span> |
||||
<span className="line"> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}> <</span> |
||||
<span style={{ color: 'rgb(121, 184, 255)' }}>motion.div</span> |
||||
</span> |
||||
<span className="line"> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}> </span> |
||||
<span style={{ color: 'rgb(179, 146, 240)' }}>className</span> |
||||
<span style={{ color: 'rgb(249, 117, 131)' }}>=</span> |
||||
<span style={{ color: 'rgb(158, 203, 255)' }}> |
||||
"w-1/3 bg-emerald-600" |
||||
</span> |
||||
</span> |
||||
<span className="line"> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}> </span> |
||||
<span style={{ color: 'rgb(179, 146, 240)' }}>initial</span> |
||||
<span style={{ color: 'rgb(249, 117, 131)' }}>=</span> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}> |
||||
{'{'} |
||||
{'{'} y:{' '} |
||||
</span> |
||||
<span style={{ color: 'rgb(121, 184, 255)' }}>0</span> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}> |
||||
{' '} |
||||
{'}'} |
||||
{'}'} |
||||
</span> |
||||
</span> |
||||
<span className="line"> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}> </span> |
||||
<span style={{ color: 'rgb(179, 146, 240)' }}>style</span> |
||||
<span style={{ color: 'rgb(249, 117, 131)' }}>=</span> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}> |
||||
{'{'} |
||||
{'{'} y: yGreen {'}'} |
||||
{'}'} |
||||
</span> |
||||
</span> |
||||
<span className="line"> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}> /></span> |
||||
</span> |
||||
<span className="line"> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}> </</span> |
||||
<span style={{ color: 'rgb(121, 184, 255)' }}>motion.div</span> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}>></span> |
||||
</span> |
||||
<span className="line"> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}> );</span> |
||||
</span> |
||||
<span className="line"> |
||||
<span style={{ color: 'rgb(225, 228, 232)' }}>{'}'}</span> |
||||
</span> |
||||
</code> |
||||
); |
@ -0,0 +1,7 @@ |
||||
import { type StoryFn } from '@storybook/react'; |
||||
|
||||
export const Center = (Story: StoryFn) => ( |
||||
<div className="flex h-full w-full items-center justify-center"> |
||||
<Story /> |
||||
</div> |
||||
); |
@ -0,0 +1,3 @@ |
||||
export * from './center'; |
||||
export * from './padding'; |
||||
export * from './markdown'; |
@ -0,0 +1,11 @@ |
||||
import { type StoryFn } from '@storybook/react'; |
||||
|
||||
import { MDXStyles } from '@/components/mdx-styles'; |
||||
|
||||
export const Markdown = (Story: StoryFn) => { |
||||
return ( |
||||
<MDXStyles> |
||||
<Story /> |
||||
</MDXStyles> |
||||
); |
||||
}; |
@ -0,0 +1,7 @@ |
||||
import { type StoryFn } from '@storybook/react'; |
||||
|
||||
export const Padding = (Story: StoryFn) => ( |
||||
<div className="h-full p-6 sm:p-12"> |
||||
<Story /> |
||||
</div> |
||||
); |
@ -0,0 +1,22 @@ |
||||
import type { Meta, StoryObj } from '@storybook/react'; |
||||
|
||||
import { Footer } from '@/components/footer'; |
||||
|
||||
const meta: Meta<typeof Footer> = { |
||||
title: 'Footer', |
||||
component: Footer, |
||||
decorators: [ |
||||
(Story) => ( |
||||
<div className="flex h-screen flex-col"> |
||||
<div className="mt-auto h-32"> |
||||
<Story /> |
||||
</div> |
||||
</div> |
||||
), |
||||
], |
||||
}; |
||||
|
||||
export default meta; |
||||
type Story = StoryObj<typeof Footer>; |
||||
|
||||
export const Normal: Story = {}; |
@ -0,0 +1,43 @@ |
||||
import type { Meta, StoryObj } from '@storybook/react'; |
||||
|
||||
import { Header } from '@/components/header'; |
||||
|
||||
const meta: Meta<typeof Header> = { |
||||
title: 'Header', |
||||
component: Header, |
||||
decorators: [ |
||||
(Story) => ( |
||||
<div className="relative min-h-[200vh]"> |
||||
<div className="sticky top-0 h-24 w-full"> |
||||
<Story /> |
||||
</div> |
||||
<div className="flex h-[calc(100vh-6rem)] flex-col items-center justify-center text-3xl text-slate-300 dark:text-slate-600"> |
||||
↓ Scroll Down ↓ |
||||
</div> |
||||
</div> |
||||
), |
||||
], |
||||
}; |
||||
|
||||
export default meta; |
||||
type Story = StoryObj<typeof Header>; |
||||
|
||||
export const PostPage: Story = { |
||||
parameters: { |
||||
nextjs: { |
||||
navigation: { |
||||
segments: ['posts', 'examples', 'example-post'], |
||||
}, |
||||
}, |
||||
}, |
||||
}; |
||||
|
||||
export const Normal: Story = { |
||||
parameters: { |
||||
nextjs: { |
||||
navigation: { |
||||
segments: [''], |
||||
}, |
||||
}, |
||||
}, |
||||
}; |
@ -0,0 +1,15 @@ |
||||
import type { Meta, StoryObj } from '@storybook/react'; |
||||
|
||||
import { HeroSection } from '@/components/hero-section'; |
||||
import { Padding } from './decorators'; |
||||
|
||||
const meta: Meta<typeof HeroSection> = { |
||||
title: 'Hero Section', |
||||
component: HeroSection, |
||||
decorators: [Padding], |
||||
}; |
||||
|
||||
export default meta; |
||||
type Story = StoryObj<typeof HeroSection>; |
||||
|
||||
export const Normal: Story = {}; |
@ -0,0 +1,40 @@ |
||||
import type { Post } from 'contentlayer/generated'; |
||||
import type { Meta, StoryObj } from '@storybook/react'; |
||||
|
||||
import { PostCard } from '@/components/post-card'; |
||||
import { Center, Padding } from './decorators'; |
||||
|
||||
const meta: Meta<typeof PostCard> = { |
||||
title: 'Post Card', |
||||
component: PostCard, |
||||
decorators: [Center, Padding], |
||||
}; |
||||
|
||||
export default meta; |
||||
type Story = StoryObj<typeof PostCard>; |
||||
|
||||
export const Normal: Story = { |
||||
args: { |
||||
post: { |
||||
title: 'Example Post', |
||||
excerpt: 'This is an example post.', |
||||
date: '2022-01-01', |
||||
tags: ['example', 'post', 'tags'], |
||||
url: '/posts/example-post', |
||||
slug: 'posts/example-post', |
||||
} as Post, |
||||
}, |
||||
}; |
||||
|
||||
export const FreshPost: Story = { |
||||
args: { |
||||
post: { |
||||
title: 'Example Post', |
||||
excerpt: 'This is an example post.', |
||||
date: new Date().toISOString(), |
||||
tags: ['example', 'post', 'tags'], |
||||
url: '/posts/example-post', |
||||
slug: 'posts/example-post', |
||||
} as Post, |
||||
}, |
||||
}; |
@ -0,0 +1,21 @@ |
||||
import type { Meta, StoryObj } from '@storybook/react'; |
||||
|
||||
import { PostIntro } from '@/components/post-intro'; |
||||
import { Padding } from './decorators'; |
||||
|
||||
const meta: Meta<typeof PostIntro> = { |
||||
title: 'Post Intro', |
||||
component: PostIntro, |
||||
decorators: [Padding], |
||||
}; |
||||
|
||||
export default meta; |
||||
type Story = StoryObj<typeof PostIntro>; |
||||
|
||||
export const Normal: Story = { |
||||
args: { |
||||
title: 'Example Post', |
||||
date: '2022-01-01', |
||||
tags: ['example', 'post', 'storybook'], |
||||
}, |
||||
}; |
@ -0,0 +1,69 @@ |
||||
import type { Post } from 'contentlayer/generated'; |
||||
import type { Meta, StoryObj } from '@storybook/react'; |
||||
|
||||
import { PostPaginator } from '@/components/post-paginator'; |
||||
import { Padding } from './decorators'; |
||||
|
||||
const posts = Array.from({ length: 100 }, (_, index) => ({ |
||||
title: `Post ${index + 1}`, |
||||
excerpt: `This is post ${index + 1}`, |
||||
date: '2022-01-01', |
||||
tags: ['example', 'post', 'tags'], |
||||
url: `/posts/post-${index + 1}`, |
||||
slug: `posts/post-${index + 1}`, |
||||
})) as Post[]; |
||||
|
||||
const meta: Meta<typeof PostPaginator> = { |
||||
title: 'Post Paginator', |
||||
component: PostPaginator, |
||||
decorators: [Padding], |
||||
args: { |
||||
postsPerPage: 5, |
||||
}, |
||||
}; |
||||
|
||||
export default meta; |
||||
type Story = StoryObj<typeof PostPaginator>; |
||||
|
||||
export const TenPages: Story = { |
||||
args: { |
||||
posts: posts.slice(0, 50), |
||||
}, |
||||
}; |
||||
|
||||
export const ThreePages: Story = { |
||||
args: { |
||||
posts: posts.slice(0, 15), |
||||
}, |
||||
}; |
||||
|
||||
export const TwoPages: Story = { |
||||
args: { |
||||
posts: posts.slice(0, 10), |
||||
}, |
||||
}; |
||||
|
||||
export const OnePage: Story = { |
||||
args: { |
||||
posts: posts.slice(0, 5), |
||||
}, |
||||
}; |
||||
|
||||
export const TwentyPages: Story = { |
||||
args: { |
||||
posts, |
||||
}, |
||||
}; |
||||
|
||||
export const OnePost: Story = { |
||||
args: { |
||||
posts: posts.slice(0, 1), |
||||
}, |
||||
}; |
||||
|
||||
export const TenPerPage: Story = { |
||||
args: { |
||||
posts, |
||||
postsPerPage: 10, |
||||
}, |
||||
}; |
@ -0,0 +1,28 @@ |
||||
import type { Meta, StoryObj } from '@storybook/react'; |
||||
|
||||
import { PostTags } from '@/components/post-tags'; |
||||
import { Center, Padding } from './decorators'; |
||||
|
||||
const meta: Meta<typeof PostTags> = { |
||||
title: 'Post Tags', |
||||
component: PostTags, |
||||
decorators: [Center, Padding], |
||||
}; |
||||
|
||||
export default meta; |
||||
type Story = StoryObj<typeof PostTags>; |
||||
|
||||
export const Normal: Story = { |
||||
args: { |
||||
tags: [ |
||||
'example', |
||||
'post', |
||||
'tags', |
||||
'test', |
||||
'storybook', |
||||
'nextjs', |
||||
'react', |
||||
'typescript', |
||||
], |
||||
}, |
||||
}; |
@ -0,0 +1,35 @@ |
||||
import type { Post } from 'contentlayer/generated'; |
||||
import type { Meta, StoryObj } from '@storybook/react'; |
||||
|
||||
import { useSearchStore } from '@/stores/search-store'; |
||||
import { Search } from '@/components/search'; |
||||
|
||||
const posts = Array.from({ length: 50 }, (_, index) => ({ |
||||
title: `Example Post ${index + 1}`, |
||||
excerpt: 'This is an example post.', |
||||
date: '2022-01-01', |
||||
tags: ['example', 'post', 'test', 'storybook', `tag${(index + 1) % 10}`], |
||||
url: `/posts/post-${index + 1}`, |
||||
slug: `posts/post-${index + 1}`, |
||||
body: { |
||||
raw: `Post ${index + 1} body`, |
||||
}, |
||||
})) as Post[]; |
||||
|
||||
const meta: Meta<typeof Search> = { |
||||
title: 'Search', |
||||
component: Search, |
||||
decorators: [ |
||||
(Story) => { |
||||
useSearchStore((state) => state.toggleSearch)(); |
||||
return <Story />; |
||||
}, |
||||
], |
||||
}; |
||||
|
||||
export default meta; |
||||
type Story = StoryObj<typeof Search>; |
||||
|
||||
export const Normal: Story = { |
||||
args: { posts }, |
||||
}; |
@ -0,0 +1,39 @@ |
||||
import type { Meta, StoryObj } from '@storybook/react'; |
||||
|
||||
import { TableOfContents } from '@/components/table-of-contents'; |
||||
import { Markdown, Padding } from './decorators'; |
||||
|
||||
const meta: Meta<typeof TableOfContents> = { |
||||
title: 'Table of Contents', |
||||
component: TableOfContents, |
||||
decorators: [Markdown, Padding], |
||||
args: { |
||||
children: ( |
||||
<ul> |
||||
<li> |
||||
<a href="#heading-1"> |
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit |
||||
</a> |
||||
</li> |
||||
<li> |
||||
<a href="#heading-2"> |
||||
Quos ex voluptates debitis accusamus laborum fugit aut |
||||
</a> |
||||
</li> |
||||
<li> |
||||
<a href="#heading-3">Quo suscipit aspernatur dicta beatae?</a> |
||||
</li> |
||||
<li> |
||||
<a href="#heading-4"> |
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit |
||||
</a> |
||||
</li> |
||||
</ul> |
||||
), |
||||
}, |
||||
}; |
||||
|
||||
export default meta; |
||||
type Story = StoryObj<typeof TableOfContents>; |
||||
|
||||
export const Normal: Story = {}; |
@ -0,0 +1,42 @@ |
||||
import type { Meta, StoryObj } from '@storybook/react'; |
||||
import { Github } from 'lucide-react'; |
||||
|
||||
import { Tooltip } from '@/components/tooltip'; |
||||
import { Center } from './decorators'; |
||||
|
||||
const meta: Meta<typeof Tooltip> = { |
||||
title: 'Tooltip', |
||||
component: Tooltip, |
||||
render: (args) => ( |
||||
<> |
||||
<Github |
||||
id="github" |
||||
className="icon-base" |
||||
data-tooltip-content={args.content} |
||||
data-tooltip-id="tooltip" |
||||
aria-label={args.content} |
||||
/> |
||||
<Tooltip id="tooltip" {...args} /> |
||||
</> |
||||
), |
||||
decorators: [Center], |
||||
args: { |
||||
content: 'My GitHub profile', |
||||
}, |
||||
argTypes: { |
||||
place: { |
||||
control: false, |
||||
}, |
||||
}, |
||||
}; |
||||
|
||||
export default meta; |
||||
type Story = StoryObj<typeof Tooltip>; |
||||
|
||||
export const Top: Story = { args: { place: 'top' } }; |
||||
|
||||
export const Bottom: Story = { args: { place: 'bottom' } }; |
||||
|
||||
export const Left: Story = { args: { place: 'left' } }; |
||||
|
||||
export const Right: Story = { args: { place: 'right' } }; |
@ -0,0 +1,37 @@ |
||||
import { useState } from 'react'; |
||||
import { Bookmark, ChevronDown } from 'lucide-react'; |
||||
|
||||
import { cn } from '@/lib/utils'; |
||||
|
||||
type TableOfContentsProps = { |
||||
children: React.ReactNode; |
||||
}; |
||||
|
||||
export function TableOfContents({ children }: TableOfContentsProps) { |
||||
const [isOpen, setIsOpen] = useState(false); |
||||
|
||||
return ( |
||||
<section className="mt-8 flex w-full flex-col rounded bg-slate-300/50 dark:bg-slate-600/50 sm:w-fit"> |
||||
<button |
||||
className={cn( |
||||
'flex flex-row items-center rounded p-2 font-bold', |
||||
isOpen && 'rounded-b-none', |
||||
'bg-slate-300 text-slate-700', |
||||
'dark:bg-slate-600 dark:text-slate-200', |
||||
)} |
||||
onClick={() => setIsOpen((prev) => !prev)} |
||||
aria-label="Toggle Table of Contents" |
||||
> |
||||
<Bookmark className="mr-1 h-5 w-5" /> |
||||
<span className="mr-6">Содержание</span> |
||||
<ChevronDown |
||||
className={cn( |
||||
'ml-auto h-6 w-6 transition-transform duration-300 ease-in-out', |
||||
isOpen && 'rotate-180', |
||||
)} |
||||
/> |
||||
</button> |
||||
{isOpen && <div className="p-2 pr-6">{children}</div>} |
||||
</section> |
||||
); |
||||
} |
@ -0,0 +1,139 @@ |
||||
'use client'; |
||||
|
||||
import { MinusSquare, Moon, PlusSquare, Search, Sun, Type } from 'lucide-react'; |
||||
import { shallow } from 'zustand/shallow'; |
||||
|
||||
import { useSearchStore } from '@/stores/search-store'; |
||||
import { useThemeStore } from '@/stores/theme-store'; |
||||
import { Tooltip } from '@/components/tooltip'; |
||||
import { cn } from '@/lib/utils'; |
||||
|
||||
type ToolbarProps = { |
||||
fontControls: boolean; |
||||
className?: string; |
||||
}; |
||||
|
||||
export function Toolbar({ fontControls, className }: ToolbarProps) { |
||||
const toggleSearch = useSearchStore((state) => state.toggleSearch); |
||||
const isSearching = useSearchStore((state) => state.isSearching); |
||||
|
||||
const { |
||||
isDark, |
||||
isSerif, |
||||
isFontSizeMin, |
||||
isFontSizeMax, |
||||
toggleDark, |
||||
toggleSerif, |
||||
increaseFontSize, |
||||
decreaseFontSize, |
||||
} = useThemeStore( |
||||
(state) => ({ |
||||
isDark: state.isDark, |
||||
isSerif: state.isSerif, |
||||
isFontSizeMin: state.isFontSizeMin, |
||||
isFontSizeMax: state.isFontSizeMax, |
||||
toggleDark: state.toggleDark, |
||||
toggleSerif: state.toggleSerif, |
||||
increaseFontSize: state.increaseFontSize, |
||||
decreaseFontSize: state.decreaseFontSize, |
||||
}), |
||||
shallow, |
||||
); |
||||
|
||||
const toggleDarkAndApply = () => { |
||||
toggleDark(); |
||||
document.querySelector('html')?.classList.toggle('dark'); |
||||
}; |
||||
|
||||
return ( |
||||
<div |
||||
className={cn( |
||||
'flex h-8 w-fit flex-row items-center justify-end space-x-1', |
||||
className, |
||||
)} |
||||
> |
||||
<button onClick={toggleSearch}> |
||||
<Search |
||||
id="search" |
||||
className={cn( |
||||
'icon-base h-5 w-5 mix-blend-color-dodge xs:h-6 xs:w-6', |
||||
isSearching && 'text-accent/60 dark:text-accent-dark/80', |
||||
)} |
||||
aria-label="Search Posts" |
||||
data-tooltip-content="Search Posts" |
||||
data-tooltip-id="toolbar-tooltip" |
||||
/> |
||||
</button> |
||||
|
||||
{fontControls && ( |
||||
<> |
||||
<button |
||||
onClick={decreaseFontSize} |
||||
disabled={isFontSizeMin} |
||||
className="group" |
||||
> |
||||
<MinusSquare |
||||
id="decrease-font-size" |
||||
className={cn( |
||||
'icon-base h-5 w-5 mix-blend-color-dodge xs:h-6 xs:w-6', |
||||
'group-disabled:text-accent/60 dark:group-disabled:text-accent-dark/80', |
||||
)} |
||||
aria-label="Decrease font size" |
||||
data-tooltip-content="Decrease font size" |
||||
data-tooltip-id="toolbar-tooltip" |
||||
/> |
||||
</button> |
||||
<button |
||||
onClick={increaseFontSize} |
||||
disabled={isFontSizeMax} |
||||
className="group" |
||||
> |
||||
<PlusSquare |
||||
id="increase-font-size" |
||||
className={cn( |
||||
'icon-base h-5 w-5 mix-blend-color-dodge xs:h-6 xs:w-6', |
||||
'group-disabled:text-accent/60 dark:group-disabled:text-accent-dark/80', |
||||
)} |
||||
aria-label="Increase font size" |
||||
data-tooltip-content="Increase font size" |
||||
data-tooltip-id="toolbar-tooltip" |
||||
/> |
||||
</button> |
||||
</> |
||||
)} |
||||
|
||||
<button onClick={toggleSerif}> |
||||
<Type |
||||
id="serif" |
||||
className={cn( |
||||
'icon-base ml-auto h-5 w-5 mix-blend-color-dodge xs:h-6 xs:w-6', |
||||
isSerif && 'text-accent/60 dark:text-accent-dark/80', |
||||
)} |
||||
data-tooltip-content="Toggle serif font" |
||||
data-tooltip-id="toolbar-tooltip" |
||||
aria-label="Toggle serif font" |
||||
/> |
||||
</button> |
||||
<button |
||||
onClick={toggleDarkAndApply} |
||||
id="theme-toggle" |
||||
data-tooltip-content={`Switch to ${isDark ? 'light' : 'dark'} mode`} |
||||
data-tooltip-id="toolbar-tooltip" |
||||
> |
||||
{isDark ? ( |
||||
<Moon |
||||
className="icon-base h-5 w-5 mix-blend-color-dodge xs:h-6 xs:w-6" |
||||
aria-label="Switch to light mode" |
||||
/> |
||||
) : ( |
||||
<Sun |
||||
className="icon-base h-5 w-5 mix-blend-color-dodge xs:h-6 xs:w-6" |
||||
aria-label="Switch to dark mode" |
||||
/> |
||||
)} |
||||
</button> |
||||
|
||||
<Tooltip id="toolbar-tooltip" /> |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,20 @@ |
||||
import { |
||||
Tooltip as ReactTooltip, |
||||
type ITooltip as TooltipProps, |
||||
} from 'react-tooltip'; |
||||
|
||||
import { cn } from '@/lib/utils'; |
||||
|
||||
export function Tooltip(props: TooltipProps) { |
||||
return ( |
||||
<ReactTooltip |
||||
className={cn( |
||||
'hidden text-sm xs:block', |
||||
'!bg-slate-700 !text-slate-200', |
||||
'dark:!bg-slate-200 dark:!text-slate-700', |
||||
props.className, |
||||
)} |
||||
{...props} |
||||
/> |
||||
); |
||||
} |
@ -0,0 +1,67 @@ |
||||
// @ts-check
|
||||
|
||||
/** |
||||
* The blog's configuration. Start here after cloning the repo. |
||||
* Hovering over the properties (in an editor like VSCode) will provide additional information about them. |
||||
*/ |
||||
|
||||
/** @type {import('./types').BlogConfig} */ |
||||
const blogConfig = { |
||||
url: 'https://edu.krasnikov.pro', |
||||
title: '‹edu/блог›', |
||||
titleParts: ['edu', 'blog'], |
||||
author: 'Красников Павел', |
||||
pages: { |
||||
home: { |
||||
title: 'Блог Красникова Павла', |
||||
description: |
||||
"Меня зовут Павел, и я преподаватель пишу про робототехнику, программирование, информатику..", |
||||
}, |
||||
posts: { |
||||
url: '/posts', |
||||
title: 'Posts', |
||||
description: |
||||
"Все мои записи в блоге. Я пишу о робототехнике и других темах, которые меня интересуют", |
||||
}, |
||||
}, |
||||
theme: { |
||||
accentColor: { |
||||
light: '#be123c', |
||||
dark: '#fda4af', |
||||
}, |
||||
codeBlockTheme: { |
||||
light: 'github-light', |
||||
dark: 'github-dark', |
||||
}, |
||||
}, |
||||
giscus: { |
||||
repo: 'kfirfitousi/blg', |
||||
repoId: 'R_kgDOI', |
||||
category: 'Comments', |
||||
categoryId: 'DIC_kwIcM7JM4CTdK0', |
||||
mapping: 'title', |
||||
theme: { |
||||
light: 'light', |
||||
dark: 'dark_dimmed', |
||||
}, |
||||
}, |
||||
footerLinks: { |
||||
twitter: 'https://vk.com/krasnikov_pro', |
||||
github: 'https://git.krasnikov.pro', |
||||
linkedin: 'https://www.linkedin.com/in/kfirp', |
||||
email: 'crapsh@gmail.com', |
||||
storybook: '', |
||||
buyMeAPizza: '', |
||||
}, |
||||
topics: [ |
||||
'Робототехнику', |
||||
'Программирование', |
||||
'Информатику', |
||||
'Python', |
||||
'LEGO EV3', |
||||
'VEX IQ', |
||||
'mBlock', |
||||
], |
||||
}; |
||||
|
||||
module.exports = { blogConfig }; |
@ -0,0 +1,135 @@ |
||||
import type { GiscusProps, Theme as GiscusTheme } from '@giscus/react'; |
||||
import type { Theme as ShikiTheme } from 'shiki'; |
||||
|
||||
/** |
||||
* This type represents the configuration of the blog. |
||||
* It should not be edited, unless you wish to add additional configurations. |
||||
*/ |
||||
export type BlogConfig = { |
||||
/** |
||||
* The URL of the blog. |
||||
* You can use localhost while developing, but make sure to change it to the actual URL when deploying. |
||||
* Should not include a trailing slash. |
||||
* @example 'https://blog.kfirfitousi.com' |
||||
* @example 'http://localhost:3000' |
||||
*/ |
||||
url: string; |
||||
/** |
||||
* The title of the blog. Visible in the header and the OG image. |
||||
* Also used as the prefix for the title of each page. |
||||
*/ |
||||
title: string; |
||||
/** |
||||
* If you want to keep the styling of the title (i.e ‹xxx/yyy›), |
||||
* pass the two parts separately. |
||||
* @example ['kfir', 'blog'] |
||||
*/ |
||||
titleParts?: [string, string]; |
||||
/** |
||||
* The name of the blog's author. Visible in the footer. |
||||
*/ |
||||
author: string; |
||||
/** |
||||
* The two main pages of the blog, the home page and the posts page. |
||||
* To add a markdown page (e.g. about, contact, etc.), |
||||
* simply add a new file to the `content/pages` directory. |
||||
*/ |
||||
pages: { |
||||
home: { |
||||
/** |
||||
* The title of the home page (the part after the pipe). |
||||
* When omitted, only the blog's name is used (without the pipe). |
||||
* @example 'Home' --> '‹blog/name› | Home' |
||||
* @example undefined --> '‹blog/name›' |
||||
*/ |
||||
title?: string; |
||||
/** |
||||
* The description of the home page. |
||||
*/ |
||||
description: string; |
||||
}; |
||||
posts: { |
||||
/** |
||||
* The URL of the posts page. |
||||
* If you want to use a different URL, |
||||
* make sure to rename the `app/posts` directory to match. |
||||
*/ |
||||
url: `/${string}`; |
||||
/** |
||||
* The title of the posts page (the part after the pipe). |
||||
*/ |
||||
title: string; |
||||
/** |
||||
* The description of the posts page. |
||||
*/ |
||||
description: string; |
||||
}; |
||||
}; |
||||
/** |
||||
* Customize the blog's theme. |
||||
*/ |
||||
theme?: { |
||||
/** |
||||
* The accent color to use. |
||||
*/ |
||||
accentColor?: { |
||||
/** |
||||
* @default #be123c // rose-700
|
||||
*/ |
||||
light?: `#${string}`; |
||||
/** |
||||
* @default #fda4af // rose-300
|
||||
*/ |
||||
dark?: `#${string}`; |
||||
}; |
||||
/** |
||||
* The themes to use for the code blocks. |
||||
* Must be a valid {@link ShikiTheme}. |
||||
*/ |
||||
codeBlockTheme?: { |
||||
/** |
||||
* @default 'github-light' |
||||
*/ |
||||
light: ShikiTheme; |
||||
/** |
||||
* @default 'github-dark' |
||||
*/ |
||||
dark: ShikiTheme; |
||||
}; |
||||
}; |
||||
/** |
||||
* Giscus comment sections configuration. |
||||
* @see https://giscus.app
|
||||
*/ |
||||
giscus: Omit<GiscusProps, 'theme'> & { |
||||
/** |
||||
* The themes to use in the Giscus comment sections. |
||||
* Must be a valid {@link GiscusTheme}. |
||||
*/ |
||||
theme?: { |
||||
/** |
||||
* @default 'light' |
||||
*/ |
||||
light?: GiscusTheme; |
||||
/** |
||||
* @default 'dark_dimmed' |
||||
*/ |
||||
dark?: GiscusTheme; |
||||
}; |
||||
}; |
||||
/** |
||||
* Footer links. |
||||
*/ |
||||
footerLinks?: { |
||||
twitter?: string; |
||||
github?: string; |
||||
linkedin?: string; |
||||
email?: string; |
||||
storybook?: string; |
||||
buyMeAPizza?: string; |
||||
}; |
||||
/** |
||||
* Topics to show in the hero section typing animation. |
||||
*/ |
||||
topics: string[]; |
||||
}; |
@ -0,0 +1,26 @@ |
||||
--- |
||||
title: Обо мне |
||||
description: I'm a Full Stack Developer experienced with TypeScript, React and Next.js. |
||||
--- |
||||
|
||||
```ts title="Pavel.ts" |
||||
type Pavle = Наставник & Учитель & Тренер; |
||||
|
||||
const pavel: pavel = { |
||||
name: 'Павел Красников', |
||||
location: 'Краснодар', |
||||
occupation: { |
||||
title: 'Преподователь робототехники, информатики', |
||||
institution: 'МАОУ СОШ 103 г.Краснодар', |
||||
}, |
||||
languages: ['TypeScript', 'JavaScript', 'Python', 'C++/C'], |
||||
technologies: { |
||||
frontEnd: ['React', 'Svelte'], |
||||
backEnd: ['Node.js', 'Express.js'], |
||||
fullStack: ['Next.js', 'SvelteKit'], |
||||
stateManagement: ['Zustand', 'React-Query'], |
||||
testing: ['Vitest', 'Jest', 'Playwright', 'Storybook'], |
||||
css: ['TailwindCSS'], |
||||
}, |
||||
}; |
||||
``` |
@ -0,0 +1,651 @@ |
||||
--- |
||||
title: Олимпиада по робототехники школьный этап 10 - 11 класc. Пробный вариант. |
||||
excerpt: Пробный вариант по за 10 - 11 класс 2023-24 год. |
||||
date: '2023-09-06' |
||||
tags: |
||||
- Робототехника |
||||
- Олимпиада |
||||
--- |
||||
|
||||
Перед Вами пробный варинат олимпиадных задний по профилю технология в правление робототехника. К каждому вопросу в олимпиаде идет 5 разных вопросов по схожей теме. |
||||
|
||||
<TableOfContents> |
||||
|
||||
- [Вопрос № 1](#вопрос--1) |
||||
- [Вопрос № 2](#вопрос--2) |
||||
- [Вопрос № 3](#вопрос--3) |
||||
- [Вопрос № 4](#вопрос--4) |
||||
- [Вопрос № 5](#вопрос--5) |
||||
- [Вопрос № 6](#вопрос--6) |
||||
- [Вопрос № 7](#вопрос--7) |
||||
- [Вопрос № 8](#вопрос--8) |
||||
- [Вопрос № 9](#вопрос--9) |
||||
- [Вопрос № 10](#вопрос--10) |
||||
- [Вопрос № 12](#вопрос--12) |
||||
- [Вопрос № 13](#вопрос--13) |
||||
- [Вопрос № 14](#вопрос--14) |
||||
- [Вопрос № 15](#вопрос--15) |
||||
|
||||
</TableOfContents> |
||||
## Вопрос № 1 |
||||
1.1 Какой тип дисплея наиболее распространен в современных смартфонах? |
||||
- а. OLED |
||||
- б. CRT |
||||
- в. Plasma |
||||
- г. LCD |
||||
|
||||
1.2 Какая компания разработала первый персональный компьютер? |
||||
- a. IBM |
||||
- b. Microsoft |
||||
- c. Apple |
||||
- d. Intel |
||||
|
||||
1.3 Какой вид памяти используется в большинстве USB-накопителей? |
||||
- a RAM |
||||
- b. ROM |
||||
- c. Flash |
||||
- d. SRAM |
||||
|
||||
1.4. Какие виды беспроводных связей поддерживает современный смартфон? |
||||
- a. 3G, 4G, 5G |
||||
- b. AM, FM, SW |
||||
- c. USB, HDMI, Ethernet |
||||
- d. VHS, Betamax, DVD |
||||
|
||||
1.5. Какой тип аккумулятора наиболее распространен в портативных ноутбуках и смартфонах? |
||||
- a. Литий-ионный |
||||
- b.. Никель-кадмиевый |
||||
- c. Свинцово-кислотный |
||||
- d. Алкалиновый |
||||
|
||||
## Вопрос № 2 |
||||
2.1 Это устройство используется для замера температуры внутри печи. Какое устройство это? |
||||
- a. Термометр |
||||
- b. Телефон |
||||
- c. Телевизор |
||||
- d. Фотоаппарат |
||||
|
||||
2.2 Это устройство обеспечивает связь между компьютерами внутри одного офиса. Какое устройство это? |
||||
- a. Микроволновка |
||||
- b. Маршрутизатор |
||||
- c. Смартфон |
||||
- d. Магнитофон |
||||
|
||||
2.3 Это устройство используется для управления высотой полета воздушных шаров. Какое устройство это? |
||||
- a. Палка для селфи |
||||
- b. Радио |
||||
- c. Шарикоподшипник |
||||
- d. Баллон с гелием |
||||
|
||||
2.4 Это устройство применяется для измерения скорости движения автомобилей на дорогах. Какое устройство это? |
||||
- a. Микроволновая печь |
||||
- b. Велосипед |
||||
- c. Радар |
||||
- d. Гитара |
||||
|
||||
2.5. Это устройство позволяет записывать звук и воспроизводить его. Какое устройство это? |
||||
- a. Фотоаппарат |
||||
- b. Магнитофон |
||||
- c. Смартфон |
||||
- d. Велосипед |
||||
|
||||
## Вопрос № 3 |
||||
3.1 Какое из следующих утверждений верно относительно равнопеременного движения роботов А и Б? |
||||
- a. Робот А всегда будет двигаться быстрее. |
||||
- b. Робот Б всегда будет двигаться быстрее. |
||||
- c. Роботы А и Б будут двигаться с одинаковой скоростью. |
||||
- d. Скорость движения роботов будет зависеть от их амплитуды. |
||||
|
||||
3.2 Если фазы равнопеременного движения роботов А и Б различаются на 180 градусов, как изменится их относительное положение со временем? |
||||
- a. Робот А и Б всегда будут находиться в одной точке. |
||||
- b. Робот А и Б будут двигаться в противоположных направлениях. |
||||
- c. Робот А и Б будут двигаться в одном направлении. |
||||
- d. Относительное положение роботов не изменится. |
||||
|
||||
3.3 Если амплитуда движения робота А увеличивается со временем, как это повлияет на его относительное положение по сравнению с роботом Б? |
||||
- a. Робот А будет приближаться к роботу Б. |
||||
- b. Робот А будет удаляться от робота Б. |
||||
- c. Относительное положение роботов не изменится. |
||||
- d. Ответ зависит от разницы в фазах движения. |
||||
|
||||
3.4 Если фаза движения робота А опережает фазу робота Б на 90 градусов, как это повлияет на их относительное положение? |
||||
- a. Робот А всегда будет опережать робота Б на 90 градусов. |
||||
- b. Робот Б всегда будет опережать робота А на 90 градусов. |
||||
- c. Роботы А и Б будут находиться в одной точке. |
||||
- d. Ответ зависит от амплитуд движения роботов. |
||||
|
||||
3.5 Если оба робота имеют одинаковую амплитуду и фазу движения, как изменится их относительное положение со временем? |
||||
- a. Робот А всегда будет опережать робота Б. |
||||
- b. Робот Б всегда будет опережать робота А. |
||||
- c. Роботы А и Б будут двигаться с одинаковой скоростью в одном направлении. |
||||
- d. Относительное положение роботов не изменится. |
||||
|
||||
## Вопрос № 4 |
||||
4.1 Какой источник энергии становится все более популярным в мировой энергетике? |
||||
- a. Уголь |
||||
- b. Нефть |
||||
- c. Ветряная энергия |
||||
- d. Гидроэнергия |
||||
|
||||
4.2 Какое технологическое средство способствует увеличению эффективности производства энергии? |
||||
- a. Угольная печь |
||||
- b. Усовершенствованный трансформатор |
||||
- c. Солнечные панели |
||||
- d. Паровая машина |
||||
|
||||
4.3 Какая стратегия по сокращению выбросов парниковых газов стала важной для развития мировой энергетики? |
||||
- a. Увеличение использования угля |
||||
- b. Расширение производства нефти |
||||
- c. Переход на альтернативные источники энергии |
||||
- d. Усовершенствование производства газа |
||||
|
||||
4.4 Какая область энергетики представляет собой наибольший потенциал для роста в ближайшие десятилетия? |
||||
- a. Энергия ветра |
||||
- b. Энергия урана |
||||
- c. Энергия нефти |
||||
- d. Энергия угля |
||||
|
||||
4.5 Какие меры мировое сообщество предпринимает для увеличения энергетической эффективности? |
||||
- a. Субсидирование нефтяных компаний |
||||
- b. Внедрение стандартов энергосбережения |
||||
- c. Увеличение добычи природного газа |
||||
- d. Усовершенствование транспортных средств с низким расходом топлива |
||||
|
||||
## Вопрос № 5 |
||||
5.1 Какие преимущества приносит наука в развитие технологий и производства? |
||||
- a. Улучшение качества продукции |
||||
- b. Снижение затрат на производство |
||||
- c. Увеличение прибыли компаний |
||||
- d. Увеличение числа рабочих мест |
||||
|
||||
5.2 Какие области науки наиболее активно влияют на развитие технологий? |
||||
- a. Биология и медицина |
||||
- b. Физика и химия |
||||
- c. Гуманитарные науки |
||||
- d. Социальные науки |
||||
|
||||
5.3. Какие факторы способствуют успешному внедрению научных исследований в производство? |
||||
- a. Государственная поддержка научных проектов |
||||
- b. Инновационная культура в организации |
||||
- c. Высокий уровень конкуренции на рынке |
||||
- d. Отсутствие патентных прав на научные разработки |
||||
|
||||
5.4. Какие вызовы могут возникнуть при интеграции науки и производства? |
||||
- a. Этические дилеммы |
||||
- b. Увеличение экологического воздействия |
||||
- c. Сокращение прибыли компаний |
||||
- d. Отсутствие интереса со стороны научных исследователей |
||||
|
||||
5.5 Какие перспективы видите в будущем для сотрудничества науки и производства? |
||||
- a. Более эффективное использование ресурсов |
||||
- b. Развитие более экологичных технологий |
||||
- c. Увеличение зависимости от иностранных технологий |
||||
- d. Сокращение роли науки в производстве |
||||
|
||||
## Вопрос № 6 |
||||
6.1 Робот движется равномерно по окружности диаметром 30 см. Скорость робота составляет 10 см/сек. Сколько времени ему потребуется для одного полного оборота вокруг окружности? Выберите ближайший вариант. |
||||
- a. 12 секунд |
||||
- b. 15 секунд |
||||
- c. 18 секунд |
||||
- d. 20 секунд |
||||
|
||||
6.2 Робот движется равномерно по окружности диаметром 15 см. Скорость робота составляет 20 см/сек. Сколько времени ему потребуется для одного полного оборота вокруг окружности? Выберите ближайший вариант. |
||||
- a. 5 секунд |
||||
- b. 7.5 секунд |
||||
- c. 10 секунд |
||||
- d. 12 секунд |
||||
|
||||
6.3 Робот движется равномерно по окружности диаметром 25 см. Скорость робота составляет 18 см/сек. Сколько времени ему потребуется для одного полного оборота вокруг окружности? Выберите ближайший вариант. |
||||
- a. 8 секунд |
||||
- b. 10 секунд |
||||
- c. 12 секунд |
||||
- d. 15 секунд |
||||
|
||||
6.4. Робот движется равномерно по окружности диаметром 40 см. Скорость робота составляет 12 см/сек. Сколько времени ему потребуется для одного полного оборота вокруг окружности? Выберите ближайший вариант. |
||||
- a. 10 секунд |
||||
- b. 15 секунд |
||||
- c. 20 секунд |
||||
- d. 25 секунд |
||||
|
||||
6.5. Робот движется равномерно по окружности диаметром 18 см. Скорость робота составляет 25 см/сек. Сколько времени ему потребуется для одного полного оборота вокруг окружности? Выберите ближайший вариант. |
||||
- a. 4.5 секунд |
||||
- b. 5 секунд |
||||
- c. 6 секунд |
||||
- d. 7.2 секунды |
||||
|
||||
## Вопрос № 7 |
||||
7.1 Андрей собрал редуктор с ведущей шестерёнкой на 12 зубьев и ведомой на 60 зубьев. Между ними находится две паразитные шестерёнки: одна на 30 зубов и другая на 15 зубов. Если ведущая ось вращается со скоростью 40 оборотов в секунду, то какая будет скорость ведомой оси? |
||||
|
||||
7.2 Юлия создала механизм с ведущей шестерёнкой на 10 зубьев и ведомой на 50 зубьев. Между ними установлена паразитная шестерёнка на 20 зубов. Если скорость ведущей оси составляет 60 оборотов в минуту, то какова будет скорость ведомой оси? |
||||
|
||||
7.3 Петр собрал редуктор, в котором ведущая шестерёнка имеет 16 зубьев, а ведомая - 64 зуба. Между ними находится паразитная шестерёнка на 32 зуба. Если скорость ведущей оси составляет 80 оборотов в минуту, то какая будет скорость ведомой оси? |
||||
|
||||
7.4 Олег собирает редуктор с ведущей шестерёнкой на 18 зубьев и ведомой на 72 зуба. Между ними устанавливается паразитная шестерёнка на 36 зубов. Если ведущая ось вращается со скоростью 90 оборотов в минуту, то какова будет скорость ведомой оси? |
||||
|
||||
7.5 Мария создала механизм с ведущей шестерёнкой на 14 зубьев и ведомой на 70 зубов. Между ними находится паразитная шестерёнка на 28 зубов. Если скорость ведущей оси составляет 56 оборотов в минуту, то какая будет скорость ведомой оси? |
||||
|
||||
|
||||
## Вопрос № 8 |
||||
8.1 Какова временная сложность алгоритма сортировки "Слиянием" для массива из N элементов? |
||||
- a. O(N) |
||||
- b. O(N*log(N)) |
||||
- c. O(N^2) |
||||
- d. O(log(N)) |
||||
|
||||
8.2 Какова пространственная сложность алгоритма быстрой сортировки (Quick Sort. для массива из N элементов)? |
||||
- a. O(N) |
||||
- b. O(log(N)) |
||||
- c. O(N*log(N)) |
||||
- d. O(1) |
||||
|
||||
8.3 Какова временная сложность линейного поиска в массиве из N элементов? |
||||
- a. O(N) |
||||
- b. O(log(N)) |
||||
- c. O(N*log(N)) |
||||
- d. O(1. |
||||
|
||||
8.4 Какова пространственная сложность алгоритма обратного хода (Backtracking. при решении задачи коммивояжера для N городов)? |
||||
- a. O(N) |
||||
- b. O(N) |
||||
- c. O(log(N)) |
||||
- d. O(1) |
||||
|
||||
8.5 Какова временная сложность алгоритма поиска наименьшего общего предка (LCA. в бинарном дереве с N узлами)? |
||||
- a. O(N) |
||||
- b. O(log(N)) |
||||
- c. O(N*log(N))) |
||||
- d. O(1) |
||||
|
||||
## Вопрос № 9 |
||||
9.1 Какая оптимизация может снизить количество рекурсивных вызовов в быстрой сортировке? |
||||
- a. Использование хвостовой рекурсии |
||||
- b. Увеличение максимальной глубины рекурсии |
||||
- c. Применение сортировки слиянием вместо быстрой сортировки |
||||
- d. Уменьшение размера входного массива |
||||
|
||||
9.2 Какая структура данных может помочь улучшить производительность быстрой сортировки? |
||||
- a. Стек |
||||
- b. Очередь |
||||
- c. Связанный список |
||||
- d. Двоичное дерево |
||||
|
||||
9.3 Какие случаи данных лучше всего подходят для быстрой сортировки? |
||||
- a. Массивы с уникальными значениями |
||||
- b. Массивы с большим количеством повторяющихся элементов |
||||
- c. Массивы, уже отсортированные по возрастанию |
||||
- d. Массивы, отсортированные в обратном порядке |
||||
|
||||
9.4 Какая оптимизация может уменьшить использование дополнительной памяти в быстрой сортировке? |
||||
- a. Использование рекурсии вместо итерации |
||||
- b. Выделение большего объема памяти заранее |
||||
- c. Использование встроенных функций сортировки |
||||
- d. Минимизация использования дополнительных структур данных |
||||
|
||||
9.5 Какой выбор опорного элемента может повысить эффективность быстрой сортировки? |
||||
- a. Случайный выбор элемента |
||||
- b. Всегда выбирать первый элемент массива |
||||
- c. Всегда выбирать средний элемент массива |
||||
- d. Всегда выбирать последний элемент массива |
||||
|
||||
## Вопрос № 10 |
||||
10.1 Дистанционное управление роботом: Робот двигался по лабиринту в течение 30 минут с постоянной скоростью 2 м/с. Определите, сколько метров прошел робот. |
||||
10.2 Исследование роботом под водой: Робот-подводник преодолел 5 километров и сделал 200 оборотов винта. Какое расстояние он преодолел за один оборот винта? |
||||
10.3 Скорость движения робота-марсохода: Робот-марсоход проехал 10 километров за 2 часа. Определите его скорость в м/с. |
||||
10.4 Программирование движения робота: Робот двигался со скоростью 1 м/с и должен был пройти расстояние 500 метров. Определите, сколько времени затратил робот на выполнение задачи. |
||||
10.5 Скорость манипулятора робота: Робот-манипулятор перемещал свой манипулятор с одной точки в другую со скоростью 0,5 м/с. Определите, сколько времени потребовалось роботу для выполнения этой задачи. |
||||
|
||||
|
||||
## Вопрос 12 |
||||
12.1 Андрей собрал редуктор с ведущей шестерёнкой на 12 зубьев и ведомой на 60 зубьев. Между ними находится две паразитные шестерёнки: одна на 30 зубов и другая на 15 зубов. Если ведущая ось вращается со скоростью 40 оборотов в секунду, то какая будет скорость ведомой оси? |
||||
|
||||
12.2 Юлия создала механизм с ведущей шестерёнкой на 10 зубьев и ведомой на 50 зубьев. Между ними установлена паразитная шестерёнка на 20 зубов. Если скорость ведущей оси составляет 60 оборотов в минуту, то какова будет скорость ведомой оси? |
||||
|
||||
12.3 Петр собрал редуктор, в котором ведущая шестерёнка имеет 16 зубьев, а ведомая - 64 зуба. Между ними находится паразитная шестерёнка на 32 зуба. Если скорость ведущей оси составляет 80 оборотов в минуту, то какая будет скорость ведомой оси? |
||||
|
||||
12.4 Олег собирает редуктор с ведущей шестерёнкой на 18 зубьев и ведомой на 72 зуба. Между ними устанавливается паразитная шестерёнка на 36 зубов. Если ведущая ось вращается со скоростью 90 оборотов в минуту, то какова будет скорость ведомой оси? |
||||
|
||||
12.5 Мария создала механизм с ведущей шестерёнкой на 14 зубьев и ведомой на 70 зубов. Между ними находится паразитная шестерёнка на 28 зубов. Если скорость ведущей оси составляет 56 оборотов в минуту, то какая будет скорость ведомой оси? |
||||
|
||||
## Вопрос 13 |
||||
13.1 Два автомобиля движутся навстречу друг другу по одной и той же дороге. Первый автомобиль стартует из пункта А, а второй из пункта Б, находящегося на 60 километров дальше по дороге. Первый автомобиль движется со скоростью 80 км/ч, а второй - со скоростью 60 км/ч. Через какое время они встретятся? |
||||
|
||||
13.2 Два пловца стартуют с противоположных концов бассейна одновременно. Первый пловец может проплывать 2 м/с, а второй - 1.5 м/с. Бассейн имеет длину 50 метров. Через какое время они встретятся впервые? |
||||
|
||||
13.3 Два поезда отправляются из разных городов навстречу друг другу. Первый поезд отправляется со скоростью 100 км/ч, а второй - со скоростью 120 км/ч. Расстояние между городами составляет 600 километров. В каком месте находятся поезда, когда они встречаются? |
||||
|
||||
13.4 Два часовых начинают свою смену одновременно. Первый часовой работает 4 часа, а второй - 6 часов. Сколько времени прошло, когда они снова встретятся? |
||||
|
||||
13.5 Два спутника обращаются вокруг Земли на разных высотах. Первый спутник находится на высоте 500 километров, а второй - на высоте 800 километров. Один оборот первого спутника занимает 90 минут, а второго - 120 минут. Через сколько времени спутники будут находиться над одной и той же точкой Земли впервые? |
||||
|
||||
## Вопрос 14 |
||||
14.1 Робот находится в верхнем левом углу сетки 3x3 и должен добраться до нижнего правого угла. Сколько существует различных путей для робота, перемещающегося только вниз и вправо, чтобы достичь цели? |
||||
|
||||
14.2 Робот находится в верхнем левом углу сетки 4x4 и должен добраться до нижнего правого угла. Сколько существует различных путей для робота, перемещающегося только вниз и вправо, чтобы достичь цели? |
||||
|
||||
14.3 Робот находится в верхнем левом углу сетки 6x6 и должен добраться до нижнего правого угла. Сколько существует различных путей для робота, перемещающегося только вниз и вправо, чтобы достичь цели? |
||||
|
||||
14.4 Робот находится в верхнем левом углу сетки 2x2 и должен добраться до нижнего правого угла. Сколько существует различных путей для робота, перемещающегося только вниз и вправо, чтобы достичь цели? |
||||
|
||||
14.5 Робот находится в верхнем левом углу сетки 7x7 и должен добраться до нижнего правого угла. Сколько существует различных путей для робота, перемещающегося только вниз и вправо, чтобы достичь цели? |
||||
|
||||
## Вопрос 15 |
||||
15.1 Робот-марафонец начинает движение из точки A и двигается по пятиугольному маршруту, состоящему из 5 сторон по 200 метров каждая. Если диаметр колеса робота составляет 40 см, сколько оборотов каждого колеса робота он сделает, чтобы вернуться в точку A? |
||||
|
||||
15.2 Робот-сборщик прямоугольных блоков двигается по пятиугольному маршруту, где каждая сторона имеет длину 150 мм. Робот собирает блоки, стоящие на каждой стороне маршрута. Если радиус колеса робота составляет 75 мм, сколько оборотов каждого колеса робота он сделает, чтобы собрать все блоки и вернуться в исходную точку? |
||||
|
||||
15.3. Робот-исследователь двигается по пятиугольной траектории для сбора данных о местности. Расстояние между центрами колес составляет 20 см, и каждая сторона пятиугольника имеет длину 250 метров. Если радиус колеса робота равен 60 мм, сколько оборотов каждого колеса робота он сделает во время исследования? |
||||
|
||||
15.4 Робот-газонокосилка движется по пятиугольной траектории, чтобы подстричь траву на газоне. Каждая сторона маршрута имеет длину 180 метров. Если диаметр колеса робота составляет 50 мм, сколько оборотов каждого колеса робота он сделает, чтобы подстричь всю траву и вернуться в начальную точку? |
||||
|
||||
15.5 Робот-курьер доставляет посылки по пятиугольной траектории. Каждая сторона маршрута имеет длину 120 метров. Если радиус колеса робота составляет 45 мм, сколько оборотов каждого колеса робота он сделает, чтобы доставить все посылки и вернуться в отправную точку? |
||||
|
||||
# Ответы на вопросы |
||||
|
||||
## Вопрос № 1 |
||||
1.1 Какой тип дисплея наиболее распространен в современных смартфонах? |
||||
- Ответ: a) OLED |
||||
|
||||
1.2 Какая компания разработала первый персональный компьютер? |
||||
- Ответ: a) IBM |
||||
|
||||
1.3 Какой вид памяти используется в большинстве USB-накопителей? |
||||
- Ответ: c) Flash |
||||
|
||||
1.4 Какие виды беспроводных связей поддерживает современный смартфон? |
||||
- Ответ: a) 3G, 4G, 5G |
||||
|
||||
1.5 Какой тип аккумулятора наиболее распространен в портативных ноутбуках и смартфонах? |
||||
- Ответ: a) Литий-ионный |
||||
|
||||
## Вопрос № 2 |
||||
|
||||
2.1 Это устройство используется для замера температуры внутри печи. Какое устройство это? |
||||
- Ответ: a. Термометр |
||||
|
||||
2.2 Это устройство обеспечивает связь между компьютерами внутри одного офиса. Какое устройство это? |
||||
- Ответ: b. Маршрутизатор |
||||
|
||||
2.3 Это устройство используется для управления высотой полета воздушных шаров. Какое устройство это? |
||||
- Ответ: b. Радио |
||||
|
||||
2.4 Это устройство применяется для измерения скорости движения автомобилей на дорогах. Какое устройство это? |
||||
- Ответ: c. Радар |
||||
|
||||
2.5 Это устройство позволяет записывать звук и воспроизводить его. Какое устройство это? |
||||
- Ответ: b. Магнитофон |
||||
|
||||
## Вопрос № 3 |
||||
Для решения этих задач, давайте разберемся с основными концепциями равнопеременного движения и его влиянием на относительное положение роботов. |
||||
|
||||
3.1 Какое из следующих утверждений верно относительно равнопеременного движения роботов А и Б? |
||||
- Правильный ответ: c) Роботы А и Б будут двигаться с одинаковой скоростью. |
||||
Роботы, начинающие равнопеременное движение одновременно, но с разными фазами и имеющие разные амплитуды, будут двигаться с одинаковой скоростью. Однако их положение в пространстве будет меняться из-за разных фаз и амплитуд, но они будут двигаться с одинаковой скоростью. |
||||
|
||||
3.2 Второй вопрос: Если фазы равнопеременного движения роботов А и Б различаются на 180 градусов, как изменится их относительное положение со временем? |
||||
- Правильный ответ: b) Робот А и Б будут двигаться в противоположных направлениях. |
||||
Разница в фазах на 180 градусов означает, что один робот движется в одном направлении, а другой - в противоположном направлении. Они будут двигаться в противоположных направлениях. |
||||
|
||||
3.3 Третий вопрос: Если амплитуда движения робота А увеличивается со временем, как это повлияет на его относительное положение по сравнению с роботом Б? |
||||
- Правильный ответ: a) Робот А будет приближаться к роботу Б. |
||||
Увеличение амплитуды движения робота А означает, что его максимальное удаление от исходной точки будет больше. Это приведет к тому, что он будет приближаться к роботу Б, который имеет постоянную амплитуду. |
||||
|
||||
3.4 Четвертый вопрос: Если фаза движения робота А опережает фазу робота Б на 90 градусов, как это повлияет на их относительное положение? |
||||
- Правильный ответ: c) Роботы А и Б будут находиться в одной точке. |
||||
Разница в фазах на 90 градусов означает, что роботы будут находиться в разных точках на кривой движения, но их суммарное движение будет таким, что они будут находиться в одной точке одновременно. |
||||
|
||||
3.5 Пятый вопрос: Если оба робота имеют одинаковую амплитуду и фазу движения, как изменится их относительное положение со временем? |
||||
- Правильный ответ: d) Относительное положение роботов не изменится. |
||||
Если оба робота имеют одинаковую амплитуду и фазу движения, то их относительное положение будет оставаться неизменным со временем, так как они будут двигаться синхронно и оставаться на одинаковом расстоянии друг от друга. |
||||
|
||||
## Вопрос № 4 |
||||
4.1 Какой источник энергии становится все более популярным в мировой энергетике? |
||||
- Ответ: c) Ветряная энергия |
||||
|
||||
4.2 Какое технологическое средство способствует увеличению эффективности производства энергии? |
||||
- Ответ: c) Солнечные панели |
||||
|
||||
4.3 Какая стратегия по сокращению выбросов парниковых газов стала важной для развития мировой энергетики? |
||||
- Ответ: c) Переход на альтернативные источники энергии |
||||
|
||||
4.4 Какая область энергетики представляет собой наибольший потенциал для роста в ближайшие десятилетия? |
||||
- Ответ: a) Энергия ветра |
||||
|
||||
4.5 Какие меры мировое сообщество предпринимает для увеличения энергетической эффективности? |
||||
- Ответ: b) Внедрение стандартов энергосбережения |
||||
|
||||
## Вопрос №5 |
||||
5.1 Какие преимущества приносит наука в развитие технологий и производства? |
||||
- Ответ: Все перечисленные варианты. Наука способствует улучшению качества продукции, снижению затрат, увеличению прибыли компаний и созданию новых рабочих мест. |
||||
|
||||
5.2 Какие области науки наиболее активно влияют на развитие технологий? |
||||
- Ответ: a) Биология и медицина, b) Физика и химия. Эти области науки играют ключевую роль в развитии технологий. |
||||
|
||||
5.3 Какие факторы способствуют успешному внедрению научных исследований в производство? |
||||
- Ответ: a) Государственная поддержка научных проектов и b) Инновационная культура в организации. Эти факторы способствуют успешному внедрению научных исследований в производство. |
||||
|
||||
5.4 Какие вызовы могут возникнуть при интеграции науки и производства? |
||||
- Ответ: a) Этические дилеммы и b) Увеличение экологического воздействия. При интеграции науки и производства могут возникать этические и экологические проблемы. |
||||
|
||||
5.5 Какие перспективы видите в будущем для сотрудничества науки и производства? |
||||
- Ответ: a) Более эффективное использование ресурсов и b) Развитие более экологичных технологий. Сотрудничество науки и производства может привести к более эффективному использованию ресурсов и развитию экологичных технологий. |
||||
|
||||
## Вопрос № 6 |
||||
Для решения каждого из этих вопросов мы можем использовать формулу для вычисления времени, которое требуется роботу для одного полного оборота вокруг окружности. Формула для вычисления времени (t) на один оборот в данном случае будет выглядеть так: |
||||
t=2πrv,t=v2πr, |
||||
где: |
||||
- tt - время для одного оборота, |
||||
- ππ (пи) - математическая константа, приближенное значение которой около 3.14159, |
||||
- rr - радиус окружности (половина диаметра), |
||||
- vv - скорость робота. |
||||
Давайте рассмотрим каждый из вопросов: |
||||
|
||||
6.1 Робот движется по окружности диаметром 30 см (r=15 смr=15 см) со скоростью 10 см/сек (v=10 см/секv=10 см/сек): |
||||
t=2⋅3.14159⋅15 см10 см/сек=94.247 см10 см/сек=9.4247 сек.t=10 см/сек2⋅3.14159⋅15 см=10 см/сек94.247 см=9.4247 сек. |
||||
- Ответ: ближайший вариант - 10 секунд (вариант "c"). |
||||
|
||||
6.2 Робот движется по окружности диаметром 15 см (r=7.5 смr=7.5 см) со скоростью 20 см/сек (v=20 см/секv=20 см/сек): |
||||
t=2⋅3.14159⋅7.5 см20 см/сек=47.123 см20 см/сек=2.35615 сек.t=20 см/сек2⋅3.14159⋅7.5 см=20 см/сек47.123 см=2.35615 сек. |
||||
- Ответ: ближайший вариант - 2.5 секунды (вариант "b"). |
||||
|
||||
6.3 Робот движется по окружности диаметром 25 см (r=12.5 смr=12.5 см) со скоростью 18 см/сек (v=18 см/секv=18 см/сек): |
||||
t=2⋅3.14159⋅12.5 см18 см/сек=78.54 см18 см/сек=4.3633 сек.t=18 см/сек2⋅3.14159⋅12.5 см=18 см/сек78.54 см=4.3633 сек. |
||||
- Ответ: ближайший вариант - 4.5 секунды (вариант "a"). |
||||
|
||||
6.4 Робот движется по окружности диаметром 40 см (r=20 смr=20 см) со скоростью 12 см/сек (v=12 см/секv=12 см/сек): |
||||
t=2⋅3.14159⋅20 см12 см/сек=125.663 см12 см/сек=10.472 сек.t=12 см/сек2⋅3.14159⋅20 см=12 см/сек125.663 см=10.472 сек. |
||||
- Ответ: ближайший вариант - 10 секунд (вариант "a"). |
||||
|
||||
6.5 Робот движется по окружности диаметром 18 см (r=9 смr=9 см) со скоростью 25 см/сек (v=25 см/секv=25 см/сек): |
||||
t=2⋅3.14159⋅9 см25 см/сек=56.548 см25 см/сек=2.26192 сек.t=25 см/сек2⋅3.14159⋅9 см=25 см/сек56.548 см=2.26192 сек. |
||||
- Ответ: ближайший вариант - 2.5 секунды (вариант "b"). |
||||
|
||||
## Вопрос № 7 |
||||
Задача 7.1: |
||||
- Ведущая шестерёнка имеет 12 зубьев, ведомая - 60 зубов, и есть две паразитные шестерёнки: одна на 30 зубов, и другая на 15 зубов. |
||||
- Сначала вычислим передаточное отношение редуктора: передаточное отношение = (число зубьев на ведущей шестерёнке) / (число зубьев на ведомой шестерёнке) = 12 / 60 = 1/5. |
||||
- Теперь умножим скорость вращения ведущей оси (40 оборотов в секунду) на передаточное отношение, чтобы найти скорость ведомой оси: скорость ведомой оси = (скорость ведущей оси) * (передаточное отношение) = 40 * (1/5) = 8 оборотов в секунду. |
||||
|
||||
Задача 7.2: |
||||
- Ведущая шестерёнка имеет 10 зубьев, ведомая - 50 зубов, и есть одна паразитная шестерёнка на 20 зубов. |
||||
- Передаточное отношение редуктора = 10 / 50 = 1/5. |
||||
- Скорость ведомой оси = (скорость ведущей оси) * (передаточное отношение) = 60 * (1/5) = 12 оборотов в минуту. |
||||
|
||||
Задача 7.3: |
||||
- Ведущая шестерёнка имеет 16 зубьев, ведомая - 64 зуба, и есть одна паразитная шестерёнка на 32 зуба. |
||||
- Передаточное отношение редуктора = 16 / 64 = 1/4. |
||||
- Скорость ведомой оси = (скорость ведущей оси) * (передаточное отношение) = 80 * (1/4) = 20 оборотов в минуту. |
||||
|
||||
Задача 7.4: |
||||
- Ведущая шестерёнка имеет 18 зубьев, ведомая - 72 зуба, и есть одна паразитная шестерёнка на 36 зубов. |
||||
- Передаточное отношение редуктора = 18 / 72 = 1/4. |
||||
- Скорость ведомой оси = (скорость ведущей оси) * (передаточное отношение) = 90 * (1/4) = 22.5 оборотов в минуту. |
||||
|
||||
Задача 7.5: |
||||
- Ведущая шестерёнка имеет 14 зубьев, ведомая - 70 зубов, и есть одна паразитная шестерёнка на 28 зубов. |
||||
- Передаточное отношение редуктора = 14 / 70 = 1/5. |
||||
- Скорость ведомой оси = (скорость ведущей оси) * (передаточное отношение) = 56 * (1/5) = 11.2 оборотов в минуту. |
||||
|
||||
## Вопрос № 8 |
||||
|
||||
1. Какова временная сложность алгоритма сортировки "Слиянием" для массива из N элементов? |
||||
- Ответ: b) O(N*log(N)) |
||||
|
||||
2. Какова пространственная сложность алгоритма быстрой сортировки (Quick Sort) для массива из N элементов? |
||||
- Ответ: d) O(1) |
||||
|
||||
3. Какова временная сложность линейного поиска в массиве из N элементов? |
||||
- Ответ: a) O(N) |
||||
|
||||
4. Какова пространственная сложность алгоритма обратного хода (Backtracking) при решении задачи коммивояжера для N городов? |
||||
- Ответ: b) O(N!) |
||||
|
||||
5. Какова временная сложность алгоритма поиска наименьшего общего предка (LCA) в бинарном дереве с N узлами? |
||||
- Ответ: b) O(log(N)) |
||||
|
||||
## Вопрос № 9 |
||||
|
||||
9.1 Какая оптимизация может снизить количество рекурсивных вызовов в быстрой сортировке? |
||||
- Ответ: a) Использование хвостовой рекурсии |
||||
|
||||
9.2 Какая структура данных может помочь улучшить производительность быстрой сортировки? |
||||
- Ответ: a) Стек |
||||
|
||||
9.3 Какие случаи данных лучше всего подходят для быстрой сортировки? |
||||
- Ответ: a) Массивы с уникальными значениями |
||||
|
||||
9.4 Какая оптимизация может уменьшить использование дополнительной памяти в быстрой сортировке? |
||||
- Ответ: c) Использование встроенных функций сортировки |
||||
|
||||
9.5 Какой выбор опорного элемента может повысить эффективность быстрой сортировки? |
||||
- Ответ: a) Случайный выбор элемента |
||||
|
||||
## Вопрос № 10 |
||||
10.1 Дистанционное управление роботом: Робот двигался в течение 30 минут с постоянной скоростью 2 м/с. Для определения расстояния, которое он прошел, умножим время на скорость: Расстояние = Время × Скорость = 30 минут × (2 м/с) = 60 метров. Таким образом, робот прошел 60 метров. |
||||
|
||||
10.2 Исследование роботом под водой: Робот-подводник преодолел 5 километров и сделал 200 оборотов винта. Чтобы найти расстояние, которое он преодолел за один оборот винта, разделим общее расстояние на количество оборотов: Расстояние за один оборот винта = 5 километров / 200 = 0,025 километра = 25 метров. |
||||
|
||||
10.3 Скорость движения робота-марсохода: Робот-марсоход проехал 10 километров за 2 часа. Для определения его скорости, разделим расстояние на время: Скорость = Расстояние / Время = 10 километров / 2 часа = 5 километров в час. Для перевода в метры в секунду, умножим на 1000 (1 км = 1000 м) и разделим на 3600 (1 час = 3600 секунд): Скорость = (5 километров * 1000 м/км) / (2 часа * 3600 с/час) ≈ 1,39 м/с. |
||||
|
||||
10.4 Программирование движения робота: Робот двигался со скоростью 1 м/с и должен был пройти расстояние 500 метров. Для определения времени, затраченного на выполнение задачи, разделим расстояние на скорость: Время = Расстояние / Скорость = 500 метров / 1 м/с = 500 секунд = 8 минут и 20 секунд. |
||||
|
||||
10.5 Скорость манипулятора робота: Робот-манипулятор перемещал свой манипулятор с одной точки в другую со скоростью 0,5 м/с. Если нам не дано конкретное расстояние, то мы не можем определить время, затраченное на выполнение задачи, без дополнительной информации о расстоянии, которое нужно преодолеть. |
||||
|
||||
## Вопрос 12 |
||||
|
||||
Задача 12.1: |
||||
- Ведущая шестерёнка имеет 12 зубьев, ведомая - 60 зубов, и есть две паразитные шестерёнки: одна на 30 зубов, и другая на 15 зубов. |
||||
- Сначала вычислим передаточное отношение редуктора: передаточное отношение = (число зубьев на ведущей шестерёнке) / (число зубьев на ведомой шестерёнке) = 12 / 60 = 1/5. |
||||
- Теперь умножим скорость вращения ведущей оси (40 оборотов в секунду) на передаточное отношение, чтобы найти скорость ведомой оси: скорость ведомой оси = (скорость ведущей оси) * (передаточное отношение) = 40 * (1/5) = 8 оборотов в секунду. |
||||
|
||||
Задача 12.2: |
||||
- Ведущая шестерёнка имеет 10 зубьев, ведомая - 50 зубов, и есть одна паразитная шестерёнка на 20 зубов. |
||||
- Передаточное отношение редуктора = 10 / 50 = 1/5. |
||||
- Скорость ведомой оси = (скорость ведущей оси) * (передаточное отношение) = 60 * (1/5) = 12 оборотов в минуту. |
||||
|
||||
Задача 12.3: |
||||
- Ведущая шестерёнка имеет 16 зубьев, ведомая - 64 зуба, и есть одна паразитная шестерёнка на 32 зуба. |
||||
- Передаточное отношение редуктора = 16 / 64 = 1/4. |
||||
- Скорость ведомой оси = (скорость ведущей оси) * (передаточное отношение) = 80 * (1/4) = 20 оборотов в минуту. |
||||
|
||||
Задача 12.4: |
||||
- Ведущая шестерёнка имеет 18 зубьев, ведомая - 72 зуба, и есть одна паразитная шестерёнка на 36 зубов. |
||||
- Передаточное отношение редуктора = 18 / 72 = 1/4. |
||||
- Скорость ведомой оси = (скорость ведущей оси) * (передаточное отношение) = 90 * (1/4) = 22.5 оборотов в минуту. |
||||
|
||||
Задача 12.5: |
||||
- Ведущая шестерёнка имеет 14 зубьев, ведомая - 70 зубов, и есть одна паразитная шестерёнка на 28 зубов. |
||||
- Передаточное отношение редуктора = 14 / 70 = 1/5. |
||||
- Скорость ведомой оси = (скорость ведущей оси) * (передаточное отношение) = 56 * (1/5) = 11.2 оборотов в минуту. |
||||
|
||||
## Вопрос 13. |
||||
13.1 Для определения времени встречи автомобилей можно воспользоваться формулой: |
||||
- Время = Расстояние / (Скорость1 + Скорость2) |
||||
- В данном случае, расстояние между пунктами А и Б составляет 60 километров, скорость первого автомобиля - 80 км/ч, а скорость второго - 60 км/ч. |
||||
- Время = 60 км / (80 км/ч + 60 км/ч) = 60 км / 140 км/ч = 3/7 часа. |
||||
- Переведем время в минуты: (3/7) * 60 = 25.71 минута. |
||||
- Таким образом, автомобили встретятся через примерно 25.71 минуту. |
||||
|
||||
13.2 Для определения времени встречи пловцов в бассейне можно использовать формулу: |
||||
- Время = Расстояние / (Скорость1 + Скорость2) |
||||
- В данном случае, расстояние в бассейне составляет 50 метров, первый пловец плывет со скоростью 2 м/с, а второй - 1.5 м/с. |
||||
- Время = 50 м / (2 м/с + 1.5 м/с) = 50 м / 3.5 м/с = 14.29 секунд. |
||||
- Таким образом, пловцы встретятся через примерно 14.29 секунд. |
||||
|
||||
13.3 Для определения места встречи поездов можно использовать формулу: |
||||
- Место = (Скорость1 * Время) + (Скорость2 * Время) |
||||
- В данном случае, первый поезд движется со скоростью 100 км/ч, второй - 120 км/ч, и расстояние между городами составляет 600 километров. |
||||
- Пусть Место будет расстоянием между городами, а Время - время встречи. |
||||
- 600 км = (100 км/ч * Время) + (120 км/ч * Время) |
||||
- 600 км = (100 + 120) км/ч * Время |
||||
- 600 км = 220 км/ч * Время |
||||
- Время = 600 км / 220 км/ч ≈ 2.73 часа. |
||||
- Теперь можно найти расстояние от начала первого города до места встречи: |
||||
- Место = 100 км/ч * 2.73 часа ≈ 273 километра от начала первого города. |
||||
- Таким образом, поезда встретятся примерно через 2.73 часа, и это произойдет примерно 273 километра от начала первого города. |
||||
|
||||
13.4. Для определения времени встречи часовых можно воспользоваться наименьшим общим кратным их смен: |
||||
- НОК(4, 6) = 12 часов. |
||||
- Часовые встретятся через каждые 12 часов работы, так как это наименьшее общее кратное их смен. |
||||
|
||||
13.5 Для определения времени встречи спутников над одной и той же точкой Земли можно воспользоваться формулой: |
||||
- Время = (Период1 * Период2) / |Период1 - Период2| |
||||
- В данном случае, первый спутник имеет период обращения 90 минут, а второй - 120 минут. |
||||
- Время = (90 мин * 120 мин) / |90 мин - 120 мин| = (10800 мин) / (30 мин) = 360 минут. |
||||
- Таким образом, спутники будут находиться над одной и той же точкой Земли каждые 360 минут, или 6 часов. |
||||
|
||||
## Вопрос № 14 |
||||
|
||||
14.1. Робот находится в верхнем левом углу сетки 3x3 и должен добраться до нижнего правого угла. Существует 6 различных путей для робота. Путь можно представить в виде последовательности шагов "вниз" (D) и "вправо" (R): |
||||
o DDRR |
||||
o DRDR |
||||
o RDRD |
||||
o RRDD |
||||
o DRRD |
||||
o RDDR |
||||
|
||||
14.2 Робот находится в верхнем левом углу сетки 4x4 и должен добраться до нижнего правого угла. Существует 20 различных путей для робота. |
||||
|
||||
14.3 Робот находится в верхнем левом угле сетки 6x6 и должен добраться до нижнего правого угла. Существует 924 различных пути для робота. Эту задачу можно решить с помощью биномиальных коэффициентов, где C(n, k) представляет количество путей для перемещения из верхнего левого угла в нижний правый угол сетки n x n, используя только движения вниз и вправо. В данном случае, C(12, 6) = 924. |
||||
|
||||
14.4 Робот находится в верхнем левом угле сетки 2x2 и должен добраться до нижнего правого угла. Существует 2 различных пути для робота. |
||||
|
||||
14.5 Робот находится в верхнем левом угле сетки 7x7 и должен добраться до нижнего правого угла. Существует 3432 различных пути для робота. Эту задачу также можно решить с помощью биномиальных коэффициентов, где C(14, 7) = 3432. |
||||
|
||||
|
||||
## Вопрос № 15 |
||||
|
||||
15.1 Решение для задачи №1: Для определения количества оборотов, которые каждое колесо робота сделает, чтобы вернуться в точку A, нам нужно сначала найти общее расстояние, которое робот должен проехать. |
||||
Общее расстояние = 5 сторон * 200 метров = 1000 метров. |
||||
Теперь мы можем использовать формулу для расчета количества оборотов: |
||||
n = k / L, |
||||
где k - общее расстояние (1000 м), L - длина одного оборота одного колеса (π * d). |
||||
L = π * 0.4 м (диаметр колеса в метрах) = 1.256 м. |
||||
Теперь можем вычислить количество оборотов: |
||||
n = 1000 м / 1.256 м = 796.18 оборотов. |
||||
Ответ: Каждое колесо робота сделает примерно 796.18 оборотов, чтобы вернуться в точку A. |
||||
|
||||
15.2 Решение для задачи №2: Аналогично, сначала найдем общее расстояние: |
||||
Общее расстояние = 5 сторон * 150 мм = 750 мм = 0.75 метра. |
||||
Теперь используем формулу для расчета количества оборотов: |
||||
L = π * 0.75 м (диаметр колеса в метрах) = 2.356 м. |
||||
n = 0.75 м / 2.356 м = 0.318 оборотов. |
||||
Ответ: Каждое колесо робота сделает примерно 0.318 оборота, чтобы собрать все блоки и вернуться в исходную точку. |
||||
|
||||
15.3. Решение для задачи №3: Снова найдем общее расстояние: |
||||
Общее расстояние = 5 сторон * 250 мм = 1250 мм = 1.25 метра. |
||||
Теперь используем формулу для расчета количества оборотов: |
||||
L = π * 0.6 м (радиус колеса в метрах) = 1.884 м. |
||||
n = 1.25 м / 1.884 м = 0.664 оборотов. |
||||
Ответ: Каждое колесо робота сделает примерно 0.664 оборота во время исследования. |
||||
|
||||
15.4 Решение для задачи №4: Найдем общее расстояние: |
||||
Общее расстояние = 5 сторон * 180 мм = 900 мм = 0.9 метра. |
||||
L = π * 0.5 м (диаметр колеса в метрах) = 1.57 м. |
||||
n = 0.9 м / 1.57 м = 0.573 оборота. |
||||
Ответ: Каждое колесо робота сделает примерно 0.573 оборота, чтобы подстричь всю траву и вернуться в начальную точку. |
||||
|
||||
15.5. Решение для задачи №5: Найдем общее расстояние: |
||||
Общее расстояние = 5 сторон * 120 мм = 600 мм = 0.6 метра. |
||||
L = π * 0.45 м (радиус колеса в метрах) = 1.413 м. |
||||
n = 0.6 м / 1.413 м = 0.425 оборотов. |
||||
Ответ: Каждое колесо робота сделает примерно 0.425 оборота, чтобы доставить все посылки и вернуться в отправную точку. |
@ -0,0 +1,170 @@ |
||||
import { |
||||
defineDocumentType, |
||||
makeSource, |
||||
type ComputedFields, |
||||
} from 'contentlayer/source-files'; |
||||
import { s } from 'hastscript'; |
||||
import rehypeAutolinkHeadings, { |
||||
type Options as AutolinkOptions, |
||||
} from 'rehype-autolink-headings'; |
||||
import rehypePrettyCode, { |
||||
type Options as PrettyCodeOptions, |
||||
} from 'rehype-pretty-code'; |
||||
import rehypeSlug from 'rehype-slug'; |
||||
import remarkGfm from 'remark-gfm'; |
||||
|
||||
import { blogConfig } from './config'; |
||||
|
||||
const computedFields: ComputedFields = { |
||||
slug: { |
||||
type: 'string', |
||||
description: 'The slug of the post, e.g. my-topic/my-post', |
||||
resolve: (doc) => doc._raw.flattenedPath.split('/').slice(1).join('/'), |
||||
}, |
||||
}; |
||||
|
||||
export const Post = defineDocumentType(() => ({ |
||||
name: 'Post', |
||||
filePathPattern: `posts/**/*.mdx`, |
||||
contentType: 'mdx', |
||||
fields: { |
||||
title: { |
||||
type: 'string', |
||||
description: 'The title of the post', |
||||
required: true, |
||||
}, |
||||
date: { |
||||
type: 'date', |
||||
description: 'When the post was published', |
||||
required: true, |
||||
}, |
||||
excerpt: { |
||||
type: 'string', |
||||
description: 'Short summary of the post', |
||||
required: true, |
||||
}, |
||||
tags: { |
||||
type: 'list', |
||||
of: { type: 'string' }, |
||||
description: 'A list of keywords that relate to the post', |
||||
required: true, |
||||
}, |
||||
}, |
||||
computedFields: { |
||||
url: { |
||||
type: 'string', |
||||
description: 'The URL of the post, e.g. /posts/my-post', |
||||
resolve: (post) => `/${post._raw.flattenedPath}`, |
||||
}, |
||||
...computedFields, |
||||
}, |
||||
})); |
||||
|
||||
export const Page = defineDocumentType(() => ({ |
||||
name: 'Page', |
||||
filePathPattern: `pages/**/*.mdx`, |
||||
contentType: 'mdx', |
||||
fields: { |
||||
title: { |
||||
type: 'string', |
||||
description: 'The title of the page', |
||||
required: true, |
||||
}, |
||||
description: { |
||||
type: 'string', |
||||
description: 'The description of the page', |
||||
required: true, |
||||
}, |
||||
}, |
||||
computedFields: { |
||||
url: { |
||||
type: 'string', |
||||
description: 'The URL of the page, e.g. /about', |
||||
resolve: (post) => |
||||
`/${post._raw.flattenedPath.split('/').slice(1).join('/')}`, |
||||
}, |
||||
...computedFields, |
||||
}, |
||||
})); |
||||
|
||||
export default makeSource({ |
||||
contentDirPath: './content', |
||||
documentTypes: [Post, Page], |
||||
mdx: { |
||||
remarkPlugins: [ |
||||
/** |
||||
* Adds support for GitHub Flavored Markdown |
||||
*/ |
||||
remarkGfm, |
||||
], |
||||
rehypePlugins: [ |
||||
/** |
||||
* Adds ids to headings |
||||
*/ |
||||
rehypeSlug, |
||||
[ |
||||
/** |
||||
* Adds auto-linking button after h1, h2, h3 headings |
||||
*/ |
||||
rehypeAutolinkHeadings, |
||||
{ |
||||
behavior: 'append', |
||||
test: ['h1', 'h2', 'h3'], |
||||
content: s( |
||||
'svg', |
||||
{ |
||||
xmlns: 'http://www.w3.org/2000/svg', |
||||
viewBox: '0 0 24 24', |
||||
width: '24', |
||||
height: '24', |
||||
fill: 'none', |
||||
stroke: 'currentColor', |
||||
'stroke-width': '2', |
||||
'stroke-linecap': 'round', |
||||
'stroke-linejoin': 'round', |
||||
'aria-label': 'Anchor link', |
||||
}, |
||||
[ |
||||
s('line', { x1: '4', y1: '9', x2: '20', y2: '9' }), |
||||
s('line', { x1: '4', y1: '15', x2: '20', y2: '15' }), |
||||
s('line', { x1: '10', y1: '3', x2: '8', y2: '21' }), |
||||
s('line', { x1: '16', y1: '3', x2: '14', y2: '21' }), |
||||
], |
||||
), |
||||
} satisfies Partial<AutolinkOptions>, |
||||
], |
||||
[ |
||||
/** |
||||
* Enhances code blocks with syntax highlighting, line numbers, |
||||
* titles, and allows highlighting specific lines and words |
||||
*/ |
||||
rehypePrettyCode, |
||||
{ |
||||
theme: blogConfig.theme?.codeBlockTheme || { |
||||
light: 'github-light', |
||||
dark: 'github-dark', |
||||
}, |
||||
onVisitLine(node) { |
||||
// Prevent lines from collapsing in `display: grid` mode, and
|
||||
// allow empty lines to be copy/pasted
|
||||
if (node.children.length === 0) { |
||||
node.children = [{ type: 'text', value: ' ' }]; |
||||
} |
||||
}, |
||||
onVisitHighlightedLine(node) { |
||||
node.properties.className.push('highlighted'); |
||||
}, |
||||
onVisitHighlightedWord(node) { |
||||
node.properties.className = ['word']; |
||||
}, |
||||
tokensMap: { |
||||
fn: 'entity.name', |
||||
type: 'entity.name', |
||||
prop: 'entity.name', |
||||
const: 'variable.other.constant', |
||||
}, |
||||
} satisfies Partial<PrettyCodeOptions>, |
||||
], |
||||
], |
||||
}, |
||||
}); |
@ -0,0 +1,59 @@ |
||||
import { describe, expect, it } from 'vitest'; |
||||
|
||||
import { formatDateTime } from './datetime'; |
||||
|
||||
describe('formatDateTime', () => { |
||||
it('formats the date to a string', () => { |
||||
expect(formatDateTime('2022-01-01').asString).toBe('January 1, 2022'); |
||||
}); |
||||
|
||||
it('formats the date to relative time', () => { |
||||
const yesterday = new Date(); |
||||
yesterday.setDate(yesterday.getDate() - 1); |
||||
expect(formatDateTime(yesterday.toISOString()).asRelativeTimeString).toBe( |
||||
'yesterday', |
||||
); |
||||
|
||||
const threeWeeksAgo = new Date(); |
||||
threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21); |
||||
expect( |
||||
formatDateTime(threeWeeksAgo.toISOString()).asRelativeTimeString, |
||||
).toBe('3 weeks ago'); |
||||
}); |
||||
|
||||
it('format the date to an ISO string', () => { |
||||
let dateTime = formatDateTime('2022-01-01'); |
||||
expect(dateTime.asISOString).toBe('2022-01-01T00:00:00.000Z'); |
||||
|
||||
expect(formatDateTime('2022-12-31').asISOString).toBe( |
||||
'2022-12-31T00:00:00.000Z', |
||||
); |
||||
}); |
||||
|
||||
it('should return whether date is fresh', () => { |
||||
const yesterday = new Date(); |
||||
yesterday.setDate(yesterday.getDate() - 1); |
||||
expect(formatDateTime(yesterday.toISOString()).isFresh).toBe(true); |
||||
|
||||
const fourDaysAgo = new Date(); |
||||
fourDaysAgo.setDate(fourDaysAgo.getDate() - 4); |
||||
expect(formatDateTime(fourDaysAgo.toISOString()).isFresh).toBe(false); |
||||
|
||||
const threeWeeksAgo = new Date(); |
||||
threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21); |
||||
expect(formatDateTime(threeWeeksAgo.toISOString()).isFresh).toBe(false); |
||||
}); |
||||
|
||||
it('should handle invalid date strings', () => { |
||||
const invalidDateResult = { |
||||
asString: 'Invalid Date', |
||||
asISOString: 'Invalid Date', |
||||
asRelativeTimeString: 'Invalid Date', |
||||
isFresh: false, |
||||
}; |
||||
|
||||
expect(formatDateTime('2022-13-01')).toEqual(invalidDateResult); |
||||
expect(formatDateTime('foo')).toEqual(invalidDateResult); |
||||
expect(formatDateTime('')).toEqual(invalidDateResult); |
||||
}); |
||||
}); |
@ -0,0 +1,107 @@ |
||||
type DateTime = { |
||||
/** The date formatted as a string |
||||
* @example |
||||
* 'January 1, 2022' |
||||
* 'December 31, 2022' |
||||
*/ |
||||
asString: string; |
||||
/** The date formatted as an ISO string |
||||
* @example |
||||
* '2022-01-01T00:00:00.000Z' |
||||
*/ |
||||
asISOString: string; |
||||
/** The date formatted as a relative time string |
||||
* @example |
||||
* '2 days ago' |
||||
* '3 weeks ago' |
||||
*/ |
||||
asRelativeTimeString: string; |
||||
/** A boolean indicating if the date is fresh, i.e. less than 4 days old */ |
||||
isFresh: boolean; |
||||
}; |
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat('en-US', { |
||||
month: 'long', |
||||
day: 'numeric', |
||||
year: 'numeric', |
||||
}); |
||||
|
||||
const relativeTimeFormatter = new Intl.RelativeTimeFormat('en-US', { |
||||
numeric: 'auto', |
||||
}); |
||||
|
||||
/** |
||||
* Formats a date string into a {@link DateTime} object. |
||||
* @example |
||||
* const dateTime = formatDateTime('2022-01-01'); |
||||
* // dateTime = {
|
||||
* // asString: 'January 1, 2022',
|
||||
* // asISOString: '2022-01-01T00:00:00.000Z',
|
||||
* // asRelativeTimeString: '2 days ago',
|
||||
* // isFresh: true
|
||||
* // }
|
||||
*/ |
||||
export function formatDateTime(dateString: string): DateTime { |
||||
const date = new Date(dateString); |
||||
|
||||
if (isNaN(date.getTime())) { |
||||
return { |
||||
asString: 'Invalid Date', |
||||
asISOString: 'Invalid Date', |
||||
asRelativeTimeString: 'Invalid Date', |
||||
isFresh: false, |
||||
}; |
||||
} |
||||
|
||||
const { relativeTime, isFresh } = getRelativeTime(date); |
||||
|
||||
return { |
||||
asString: dateFormatter.format(date), |
||||
asISOString: date.toISOString(), |
||||
asRelativeTimeString: relativeTime, |
||||
isFresh, |
||||
}; |
||||
} |
||||
|
||||
/** |
||||
* Formats a date into a relative time. |
||||
* @returns The relative time string and a boolean indicating if the date is fresh, |
||||
* i.e. less than 4 days old |
||||
* @example |
||||
* const { relativeTime, isFresh } = getRelativeTime(someDate); |
||||
* // relativeTime = '3 weeks ago'
|
||||
* // isFresh = false
|
||||
*/ |
||||
function getRelativeTime(date: Date) { |
||||
const timeDiff = date.getTime() - new Date().getTime(); |
||||
|
||||
const days = Math.ceil(timeDiff / (1000 * 60 * 60 * 24)); |
||||
if (days > -14) { |
||||
return { |
||||
relativeTime: relativeTimeFormatter.format(days, 'day'), |
||||
isFresh: days > -4, |
||||
}; |
||||
} |
||||
|
||||
const weeks = Math.floor(days / 7); |
||||
if (weeks > -8) { |
||||
return { |
||||
relativeTime: relativeTimeFormatter.format(weeks, 'week'), |
||||
isFresh: false, |
||||
}; |
||||
} |
||||
|
||||
const months = Math.floor(days / 30); |
||||
if (months > -12) { |
||||
return { |
||||
relativeTime: relativeTimeFormatter.format(months, 'month'), |
||||
isFresh: false, |
||||
}; |
||||
} |
||||
|
||||
const years = Math.floor(days / 365); |
||||
return { |
||||
relativeTime: relativeTimeFormatter.format(years, 'year'), |
||||
isFresh: false, |
||||
}; |
||||
} |
@ -0,0 +1,36 @@ |
||||
import { type Post } from 'contentlayer/generated'; |
||||
import { describe, expect, it } from 'vitest'; |
||||
|
||||
import { getTagsWithCount, searchPosts } from './search'; |
||||
|
||||
const posts = Array.from({ length: 10 }, (_, index) => ({ |
||||
title: `Post ${index + 1}`, |
||||
tags: ['tag', `tag${index + 1}`], |
||||
excerpt: `Post ${index + 1} excerpt`, |
||||
date: '2022-01-01', |
||||
slug: `post-${index + 1}`, |
||||
url: `post-${index + 1}`, |
||||
body: { |
||||
raw: `Post ${index + 1} body`, |
||||
}, |
||||
})) as Post[]; |
||||
|
||||
describe('searchPosts', () => { |
||||
it('returns the posts that best match the query', () => { |
||||
expect(searchPosts('Post 7', posts)[0].title).toBe('Post 7'); |
||||
expect(searchPosts('tag4', posts)[0].title).toBe('Post 4'); |
||||
}); |
||||
|
||||
it('returns no posts if the query is empty', () => { |
||||
expect(searchPosts('', posts)).toHaveLength(0); |
||||
}); |
||||
}); |
||||
|
||||
describe('getTagsWithCount', () => { |
||||
it('returns the tags with their count', () => { |
||||
const tagsWithCount = getTagsWithCount(posts); |
||||
expect(tagsWithCount).toContainEqual(['tag', 10]); |
||||
expect(tagsWithCount).toContainEqual(['tag1', 1]); |
||||
expect(tagsWithCount).toContainEqual(['tag10', 1]); |
||||
}); |
||||
}); |
@ -0,0 +1,67 @@ |
||||
import { type Post } from 'contentlayer/generated'; |
||||
|
||||
/** |
||||
* Search for a query in a text. |
||||
* @returns A boolean indicating whether the query was found in the text. |
||||
*/ |
||||
function searchHit(query: string, text: string) { |
||||
return text.toLowerCase().includes(query.toLowerCase()); |
||||
} |
||||
|
||||
/** |
||||
* Search for a query in a list of posts. |
||||
* @returns The posts that matched the query in descending order of relevance. |
||||
*/ |
||||
export function searchPosts(query: string, posts: Array<Post>) { |
||||
const postsWithSearchHits = new Map<Post, number>(); |
||||
|
||||
posts.forEach((post) => { |
||||
if (!query) return; |
||||
|
||||
const { |
||||
title, |
||||
excerpt, |
||||
tags, |
||||
body: { raw }, |
||||
} = post; |
||||
|
||||
let searchHits = 0; |
||||
|
||||
if (tags.some((tag) => searchHit(query, tag))) { |
||||
searchHits += 10; // give tag hits heavy weight
|
||||
} |
||||
if (searchHit(query, title)) { |
||||
searchHits += 10; // give title hits heavy weight
|
||||
} |
||||
if (searchHit(query, excerpt)) { |
||||
searchHits += 5; // give excerpt hits lighter weight
|
||||
} |
||||
if (searchHit(query, raw)) { |
||||
searchHits++; // give content hits lightest weight
|
||||
} |
||||
|
||||
if (searchHits > 0) { |
||||
postsWithSearchHits.set(post, searchHits); |
||||
} |
||||
}); |
||||
|
||||
return Array.from(postsWithSearchHits.entries()) |
||||
.sort(([, a], [, b]) => b - a) |
||||
.map(([post]) => post); |
||||
} |
||||
|
||||
/** |
||||
* Get all tags with their count |
||||
* @returns The tags with their count in descending order of count |
||||
*/ |
||||
export function getTagsWithCount(posts: Array<Post>) { |
||||
const tagsWithCount = new Map<string, number>(); |
||||
|
||||
posts.forEach((post) => { |
||||
post.tags.forEach((tag) => { |
||||
tagsWithCount.set(tag, (tagsWithCount.get(tag) ?? 0) + 1); |
||||
}); |
||||
}); |
||||
|
||||
return Array.from(tagsWithCount.entries()).sort(([, a], [, b]) => b - a); |
||||
} |
@ -0,0 +1,7 @@ |
||||
import { clsx, type ClassValue } from 'clsx'; |
||||
import { twMerge } from 'tailwind-merge'; |
||||
|
||||
/** Utility function to merge Tailwind classes with clsx and tailwind-merge */ |
||||
export function cn(...inputs: ClassValue[]) { |
||||
return twMerge(clsx(inputs)); |
||||
} |
@ -0,0 +1,7 @@ |
||||
const { blogConfig } = require('./config/index.js'); |
||||
|
||||
/** @type {import('next-sitemap').IConfig} */ |
||||
module.exports = { |
||||
siteUrl: blogConfig.url, |
||||
generateRobotsTxt: true, |
||||
}; |
@ -0,0 +1,10 @@ |
||||
const { withContentlayer } = require('next-contentlayer'); |
||||
|
||||
/** @type {import('next').NextConfig} */ |
||||
const nextConfig = { |
||||
experimental: { |
||||
appDir: true, |
||||
}, |
||||
}; |
||||
|
||||
module.exports = withContentlayer(nextConfig); |
@ -0,0 +1,86 @@ |
||||
{ |
||||
"name": "blog", |
||||
"version": "1.0.0", |
||||
"scripts": { |
||||
"dev": "next dev", |
||||
"test": "npx vitest", |
||||
"build": "next build", |
||||
"postbuild": "next-sitemap", |
||||
"start": "next start", |
||||
"style:lint": "next lint", |
||||
"style:prettier": "prettier --check '**/*.{js,jsx,ts,tsx}'", |
||||
"style:all": "pnpm run style:lint && pnpm run style:prettier", |
||||
"format": "prettier --write '**/*.{js,jsx,ts,tsx}'", |
||||
"storybook": "storybook dev -p 6006", |
||||
"build-storybook": "npx contentlayer build && storybook build" |
||||
}, |
||||
"dependencies": { |
||||
"@giscus/react": "^2.2.8", |
||||
"@vercel/analytics": "^1.0.0", |
||||
"@vercel/og": "^0.5.3", |
||||
"@vkontakte/icons": "^2.64.0", |
||||
"clsx": "^1.2.1", |
||||
"contentlayer": "^0.3.1", |
||||
"date-fns": "^2.29.3", |
||||
"eslint": "8.39.0", |
||||
"eslint-config-next": "13.3.1", |
||||
"grapheme-splitter": "^1.0.4", |
||||
"hastscript": "^7.2.0", |
||||
"lucide-react": "^0.176.0", |
||||
"next": "13.3.1", |
||||
"next-contentlayer": "^0.3.1", |
||||
"next-sitemap": "^4.0.7", |
||||
"react": "18.2.0", |
||||
"react-dom": "18.2.0", |
||||
"react-tooltip": "5.11.1", |
||||
"react-typist-component": "^1.0.5", |
||||
"react-wrap-balancer": "^0.4.0", |
||||
"rehype-autolink-headings": "^6.1.1", |
||||
"rehype-pretty-code": "^0.9.5", |
||||
"rehype-slug": "^5.1.0", |
||||
"remark-gfm": "^3.0.1", |
||||
"shiki": "^0.14.1", |
||||
"tailwind-merge": "^1.12.0", |
||||
"unified": "^10.1.2", |
||||
"vitest": "^0.30.1", |
||||
"zustand": "^4.3.7" |
||||
}, |
||||
"devDependencies": { |
||||
"@babel/core": "^7.21.4", |
||||
"@ianvs/prettier-plugin-sort-imports": "^3.7.2", |
||||
"@next/env": "^13.3.1", |
||||
"@storybook/addon-essentials": "^7.0.6", |
||||
"@storybook/addon-interactions": "^7.0.6", |
||||
"@storybook/addon-links": "^7.0.6", |
||||
"@storybook/addons": "^7.0.6", |
||||
"@storybook/api": "^7.0.6", |
||||
"@storybook/blocks": "^7.0.6", |
||||
"@storybook/components": "^7.0.6", |
||||
"@storybook/core-events": "^7.0.6", |
||||
"@storybook/nextjs": "^7.0.6", |
||||
"@storybook/react": "^7.0.6", |
||||
"@storybook/testing-library": "^0.1.0", |
||||
"@storybook/theming": "^7.0.6", |
||||
"@storybook/types": "^7.0.6", |
||||
"@tailwindcss/typography": "^0.5.9", |
||||
"@types/node": "18.16.0", |
||||
"@types/react": "18.0.38", |
||||
"@types/react-dom": "18.0.11", |
||||
"autoprefixer": "^10.4.14", |
||||
"css-loader": "^6.7.3", |
||||
"eslint-plugin-storybook": "^0.6.11", |
||||
"eslint-plugin-tailwindcss": "^3.11.0", |
||||
"postcss": "^8.4.23", |
||||
"postcss-loader": "^7.2.4", |
||||
"prettier": "2.8.8", |
||||
"prettier-plugin-tailwindcss": "^0.2.7", |
||||
"prop-types": "^15.8.1", |
||||
"storybook": "^7.0.6", |
||||
"storybook-tailwind-dark-mode": "^1.0.22", |
||||
"style-loader": "^3.3.2", |
||||
"tailwindcss": "^3.3.1", |
||||
"ts-node": "^10.9.1", |
||||
"typescript": "^5.0.4", |
||||
"webpack": "^5.80.0" |
||||
} |
||||
} |
@ -0,0 +1,6 @@ |
||||
module.exports = { |
||||
plugins: { |
||||
tailwindcss: {}, |
||||
autoprefixer: {}, |
||||
}, |
||||
}; |
@ -0,0 +1,38 @@ |
||||
/** @type {import("@ianvs/prettier-plugin-sort-imports").PrettierConfig} */ |
||||
module.exports = { |
||||
plugins: [ |
||||
'@ianvs/prettier-plugin-sort-imports', |
||||
'prettier-plugin-tailwindcss', |
||||
], |
||||
arrowParens: 'always', |
||||
bracketSpacing: true, |
||||
endOfLine: 'lf', |
||||
bracketSameLine: false, |
||||
jsxSingleQuote: false, |
||||
printWidth: 80, |
||||
proseWrap: 'preserve', |
||||
quoteProps: 'as-needed', |
||||
semi: true, |
||||
singleQuote: true, |
||||
tabWidth: 2, |
||||
trailingComma: 'all', |
||||
useTabs: false, |
||||
importOrder: [ |
||||
'^(react/(.*)$)|^(react$)', |
||||
'^(next/(.*)$)|^(next$)', |
||||
'^(contentlayer/generated$)', |
||||
'<THIRD_PARTY_MODULES>', |
||||
'', |
||||
'^@/stores/(.*)$', |
||||
'^@/config$', |
||||
'^@/components/(.*)$', |
||||
'^@/lib/(.*)$', |
||||
'^[./]', |
||||
], |
||||
importOrderSeparation: false, |
||||
importOrderSortSpecifiers: true, |
||||
importOrderBuiltinModulesToTop: true, |
||||
importOrderParserPlugins: ['typescript', 'jsx'], |
||||
importOrderMergeDuplicateImports: true, |
||||
importOrderCombineTypeAndValueImports: true, |
||||
}; |
After Width: | Height: | Size: 1.1 KiB |
After Width: | Height: | Size: 2.2 KiB |
After Width: | Height: | Size: 920 B |
After Width: | Height: | Size: 319 B |
After Width: | Height: | Size: 429 B |
After Width: | Height: | Size: 15 KiB |
After Width: | Height: | Size: 175 KiB |
After Width: | Height: | Size: 148 KiB |
After Width: | Height: | Size: 48 KiB |
After Width: | Height: | Size: 150 KiB |
After Width: | Height: | Size: 54 KiB |
After Width: | Height: | Size: 13 KiB |
After Width: | Height: | Size: 252 KiB |
After Width: | Height: | Size: 50 KiB |
After Width: | Height: | Size: 11 KiB |
After Width: | Height: | Size: 8.7 KiB |
After Width: | Height: | Size: 71 KiB |
After Width: | Height: | Size: 22 KiB |