init
11
.env
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Environment variables declared in this file are automatically made available to Prisma.
|
||||||
|
# See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema
|
||||||
|
|
||||||
|
# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
|
||||||
|
# See the documentation for all the connection string options: https://pris.ly/d/connection-strings
|
||||||
|
|
||||||
|
MINIO_ACCESS_KEY=spider
|
||||||
|
MINIO_SECRET_KEY=spiderman!
|
||||||
|
MINIO_ENDPOINT=http://docker.pve:9001/api/v1
|
||||||
|
MINIO_ENABLED=1
|
||||||
|
DATABASE_URL=postgres://postgres:postgres@database.pve/spider
|
||||||
61
.eslintrc.js
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
module.exports = {
|
||||||
|
parser: "@typescript-eslint/parser",
|
||||||
|
parserOptions: {
|
||||||
|
sourceType: "module",
|
||||||
|
},
|
||||||
|
plugins: ["@typescript-eslint/eslint-plugin", "testing-library", "jest"],
|
||||||
|
overrides: [
|
||||||
|
{
|
||||||
|
files: ["**/?(*.)+(spec|test).[jt]s?(x)"],
|
||||||
|
extends: ["plugin:testing-library/react"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
extends: [
|
||||||
|
"plugin:@typescript-eslint/eslint-recommended",
|
||||||
|
"plugin:@typescript-eslint/recommended",
|
||||||
|
"plugin:react/recommended",
|
||||||
|
"plugin:import/recommended",
|
||||||
|
"plugin:import/typescript",
|
||||||
|
"plugin:prettier/recommended",
|
||||||
|
"plugin:storybook/recommended",
|
||||||
|
],
|
||||||
|
root: true,
|
||||||
|
env: {
|
||||||
|
node: true,
|
||||||
|
jest: true,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"prettier/prettier": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
singleQuote: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"@typescript-eslint/explicit-function-return-type": "off",
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
"@typescript-eslint/explicit-module-boundary-types": "off",
|
||||||
|
"@typescript-eslint/quotes": [2, "double", "avoid-escape"],
|
||||||
|
"no-unused-vars": "off",
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
argsIgnorePattern: "^_",
|
||||||
|
destructuredArrayIgnorePattern: "^_",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
quotes: [2, "double", "avoid-escape"],
|
||||||
|
semi: ["error", "always"],
|
||||||
|
"eol-last": ["error", "always"],
|
||||||
|
"react/react-in-jsx-scope": "off",
|
||||||
|
"import/no-unresolved": "off",
|
||||||
|
"import/order": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"newlines-between": "always",
|
||||||
|
alphabetize: {
|
||||||
|
order: "asc",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
38
.gitignore
vendored
Normal file
@@ -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
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# storybook
|
||||||
|
storybook-static
|
||||||
1
.prettierrc.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
module.exports = require('eslint-config-mantine/.prettierrc.js');
|
||||||
11
.storybook/main.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
module.exports = {
|
||||||
|
stories: ['../**/*.story.mdx', '../**/*.story.@(js|jsx|ts|tsx)'],
|
||||||
|
addons: [
|
||||||
|
'storybook-dark-mode',
|
||||||
|
{
|
||||||
|
name: 'storybook-addon-turbo-build',
|
||||||
|
options: { optimizationLevel: 2 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
framework: '@storybook/react',
|
||||||
|
};
|
||||||
21
.storybook/preview.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { useDarkMode } from 'storybook-dark-mode';
|
||||||
|
import { MantineProvider, ColorSchemeProvider } from '@mantine/core';
|
||||||
|
import { NotificationsProvider } from '@mantine/notifications';
|
||||||
|
|
||||||
|
export const parameters = { layout: 'fullscreen' };
|
||||||
|
|
||||||
|
function ThemeWrapper(props: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<ColorSchemeProvider colorScheme="light" toggleColorScheme={() => {}}>
|
||||||
|
<MantineProvider
|
||||||
|
theme={{ colorScheme: useDarkMode() ? 'dark' : 'light' }}
|
||||||
|
withGlobalStyles
|
||||||
|
withNormalizeCSS
|
||||||
|
>
|
||||||
|
<NotificationsProvider>{props.children}</NotificationsProvider>
|
||||||
|
</MantineProvider>
|
||||||
|
</ColorSchemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const decorators = [(renderStory: Function) => <ThemeWrapper>{renderStory()}</ThemeWrapper>];
|
||||||
17
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"typescript.tsdk": "node_modules/typescript/lib",
|
||||||
|
"editor.codeActionsOnSave": [
|
||||||
|
"source.organizeImports",
|
||||||
|
"source.fixAll.eslint"
|
||||||
|
],
|
||||||
|
"[typescriptreact]": {
|
||||||
|
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
|
||||||
|
},
|
||||||
|
"[typescript]": {
|
||||||
|
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
|
||||||
|
},
|
||||||
|
"cSpell.words": [
|
||||||
|
"mantine",
|
||||||
|
"MINIO"
|
||||||
|
]
|
||||||
|
}
|
||||||
42
Dockerfile
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
FROM node:18-alpine AS base
|
||||||
|
|
||||||
|
FROM base AS deps
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
|
||||||
|
RUN yarn config set registry https://nexus.yoshino-s.xyz/repository/npm/
|
||||||
|
RUN \
|
||||||
|
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
|
||||||
|
elif [ -f package-lock.json ]; then npm ci; \
|
||||||
|
elif [ -f pnpm-lock.yaml ]; then yarn global add pnpm && pnpm i --frozen-lockfile; \
|
||||||
|
else echo "Lockfile not found." && exit 1; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN yarn build
|
||||||
|
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV production
|
||||||
|
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENV PORT 3000
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
21
LICENCE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2022 Vitaly Rtischev
|
||||||
|
|
||||||
|
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.
|
||||||
39
README.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Mantine Next Template
|
||||||
|
|
||||||
|
Get started with Mantine + Next with just a few button clicks.
|
||||||
|
Click `Use this template` button at the header of repository or [follow this link](https://github.com/mantinedev/mantine-next-template/generate) and
|
||||||
|
create new repository with `@mantine` packages. Note that you have to be logged in to GitHub to generate template.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
This template comes with several essential features:
|
||||||
|
|
||||||
|
- Server side rendering setup for Mantine
|
||||||
|
- Color scheme is stored in cookie to avoid color scheme mismatch after hydration
|
||||||
|
- Storybook with color scheme toggle
|
||||||
|
- Jest with react testing library
|
||||||
|
- ESLint setup with [eslint-config-mantine](https://github.com/mantinedev/eslint-config-mantine)
|
||||||
|
|
||||||
|
## npm scripts
|
||||||
|
|
||||||
|
### Build and dev scripts
|
||||||
|
|
||||||
|
- `dev` – start dev server
|
||||||
|
- `build` – bundle application for production
|
||||||
|
- `export` – exports static website to `out` folder
|
||||||
|
- `analyze` – analyzes application bundle with [@next/bundle-analyzer](https://www.npmjs.com/package/@next/bundle-analyzer)
|
||||||
|
|
||||||
|
### Testing scripts
|
||||||
|
|
||||||
|
- `typecheck` – checks TypeScript types
|
||||||
|
- `lint` – runs ESLint
|
||||||
|
- `prettier:check` – checks files with Prettier
|
||||||
|
- `jest` – runs jest tests
|
||||||
|
- `jest:watch` – starts jest watch
|
||||||
|
- `test` – runs `jest`, `prettier:check`, `lint` and `typecheck` scripts
|
||||||
|
|
||||||
|
### Other scripts
|
||||||
|
|
||||||
|
- `storybook` – starts storybook dev server
|
||||||
|
- `storybook:build` – build production storybook bundle to `storybook-static`
|
||||||
|
- `prettier:write` – formats all files with Prettier
|
||||||
26
components/ColorSchemeToggle/ColorSchemeToggle.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { ActionIcon, Group, useMantineColorScheme } from "@mantine/core";
|
||||||
|
import { IconMoonStars, IconSun } from "@tabler/icons";
|
||||||
|
|
||||||
|
export function ColorSchemeToggle() {
|
||||||
|
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group position="center" mt="xl">
|
||||||
|
<ActionIcon
|
||||||
|
onClick={() => toggleColorScheme()}
|
||||||
|
size="xl"
|
||||||
|
sx={(theme) => ({
|
||||||
|
backgroundColor:
|
||||||
|
theme.colorScheme === "dark" ? theme.colors.dark[6] : theme.colors.gray[0],
|
||||||
|
color: theme.colorScheme === "dark" ? theme.colors.yellow[4] : theme.colors.blue[6],
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{colorScheme === "dark" ? (
|
||||||
|
<IconSun size={20} stroke={1.5} />
|
||||||
|
) : (
|
||||||
|
<IconMoonStars size={20} stroke={1.5} />
|
||||||
|
)}
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
components/ParagraphCard/ParagraphCard.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { Badge, Card, createStyles, Group, Image, rem, Text, UnstyledButton } from "@mantine/core";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
|
import DayJS from "../../lib/dayjs";
|
||||||
|
const useStyles = createStyles((theme) => ({
|
||||||
|
card: {
|
||||||
|
position: "relative",
|
||||||
|
backgroundColor: theme.colorScheme === "dark" ? theme.colors.dark[7] : theme.white,
|
||||||
|
},
|
||||||
|
|
||||||
|
rating: {
|
||||||
|
position: "absolute",
|
||||||
|
top: theme.spacing.xs,
|
||||||
|
right: rem(12),
|
||||||
|
pointerEvents: "none",
|
||||||
|
},
|
||||||
|
|
||||||
|
title: {
|
||||||
|
display: "block",
|
||||||
|
marginTop: theme.spacing.md,
|
||||||
|
marginBottom: rem(5),
|
||||||
|
},
|
||||||
|
|
||||||
|
action: {
|
||||||
|
backgroundColor: theme.colorScheme === "dark" ? theme.colors.dark[6] : theme.colors.gray[0],
|
||||||
|
...theme.fn.hover({
|
||||||
|
backgroundColor: theme.colorScheme === "dark" ? theme.colors.dark[5] : theme.colors.gray[1],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
footer: {
|
||||||
|
marginTop: theme.spacing.md,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface ArticleCardProps {
|
||||||
|
cover?: string;
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
author: string;
|
||||||
|
time: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ParagraphCard({
|
||||||
|
className,
|
||||||
|
cover,
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
time,
|
||||||
|
author,
|
||||||
|
...others
|
||||||
|
}: ArticleCardProps & Omit<React.ComponentPropsWithoutRef<"div">, keyof ArticleCardProps>) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { classes, cx } = useStyles();
|
||||||
|
const linkProps = { href: `/paragraph/${id}` };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card withBorder radius="md" className={cx(classes.card, className)} {...others}>
|
||||||
|
{cover && (
|
||||||
|
<Card.Section>
|
||||||
|
<a {...linkProps}>
|
||||||
|
<Image src={cover} height={180} />
|
||||||
|
</a>
|
||||||
|
</Card.Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text className={classes.title} fw={500} component="a" {...linkProps}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text fz="sm" color="dimmed" lineClamp={4}>
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Group position="apart" className={classes.footer}>
|
||||||
|
<UnstyledButton
|
||||||
|
onClick={() =>
|
||||||
|
router.push({
|
||||||
|
pathname: "/author/[name]",
|
||||||
|
query: { name: author },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Badge radius="md">
|
||||||
|
<Text fz="sm" inline>
|
||||||
|
{author}
|
||||||
|
</Text>
|
||||||
|
</Badge>
|
||||||
|
</UnstyledButton>
|
||||||
|
|
||||||
|
<Text>{DayJS.to(dayjs(time))}</Text>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
components/ParagraphContent/ParagraphContent.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { createStyles, TypographyStylesProvider } from "@mantine/core";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
const useStyles = createStyles(() => ({
|
||||||
|
paragraph: {
|
||||||
|
lineBreak: "anywhere",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export function ParagraphContent({ content }: { content: string }) {
|
||||||
|
const { classes } = useStyles();
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (ref.current) {
|
||||||
|
ref.current.querySelectorAll("*").forEach((el) => {
|
||||||
|
if (!(el instanceof HTMLElement)) return;
|
||||||
|
console.log(el.tagName, el.style);
|
||||||
|
[
|
||||||
|
"outline",
|
||||||
|
"color",
|
||||||
|
"font-size",
|
||||||
|
"font-family",
|
||||||
|
"background-color",
|
||||||
|
"border-width",
|
||||||
|
"border-style",
|
||||||
|
"border-color",
|
||||||
|
"counter-reset",
|
||||||
|
"max-width",
|
||||||
|
"caret-color",
|
||||||
|
"letter-spacing",
|
||||||
|
"white-space",
|
||||||
|
"text-size-adjust",
|
||||||
|
"box-sizing",
|
||||||
|
"line-height",
|
||||||
|
"overflow-wrap",
|
||||||
|
].forEach((key) => el.style.removeProperty(key));
|
||||||
|
if (
|
||||||
|
el.tagName === "P" &&
|
||||||
|
el.childElementCount === 1 &&
|
||||||
|
(el.children[0].tagName === "BR" ||
|
||||||
|
(el.children[0].tagName === "SPAN" &&
|
||||||
|
el.children[0].childElementCount === 1 &&
|
||||||
|
el.children[0].children[0].tagName === "BR"))
|
||||||
|
) {
|
||||||
|
el.parentElement?.removeChild(el);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [ref]);
|
||||||
|
return (
|
||||||
|
<TypographyStylesProvider>
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={classes.paragraph}
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: content,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TypographyStylesProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
components/ParagraphGrid/ParagraphGrid.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { Button, Container, Grid, Group, Pagination, Title } from "@mantine/core";
|
||||||
|
import { useDocumentTitle } from "@mantine/hooks";
|
||||||
|
import { Paragraph } from "@prisma/client";
|
||||||
|
import { IconArrowBack } from "@tabler/icons";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { ReactNode, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { ParagraphCard } from "../ParagraphCard/ParagraphCard";
|
||||||
|
|
||||||
|
interface ListProps {
|
||||||
|
paragraphs: Omit<Paragraph, "content" | "markdown">[];
|
||||||
|
skip: number;
|
||||||
|
take: number;
|
||||||
|
total: number;
|
||||||
|
title: string;
|
||||||
|
titleAction?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ParagraphGrid({
|
||||||
|
title,
|
||||||
|
paragraphs,
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
|
total,
|
||||||
|
titleAction,
|
||||||
|
}: ListProps) {
|
||||||
|
useDocumentTitle(title);
|
||||||
|
const router = useRouter();
|
||||||
|
const [totalPage, setTotalPage] = useState(1);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTotalPage(Math.ceil(total / take));
|
||||||
|
setPage(skip / take + 1);
|
||||||
|
}, [skip, take]);
|
||||||
|
|
||||||
|
function toPage(page: number) {
|
||||||
|
router.push(router.pathname + `?skip=${(page - 1) * take}&take=${take}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container my="8rem">
|
||||||
|
<Title>
|
||||||
|
{title} Page {page}
|
||||||
|
</Title>
|
||||||
|
<Group position="apart" my="2rem">
|
||||||
|
<Group>{titleAction}</Group>
|
||||||
|
<Button variant="subtle" onClick={() => router.back()} rightIcon={<IconArrowBack />}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
<Grid my="md">
|
||||||
|
{paragraphs.map((paragraph) => {
|
||||||
|
return (
|
||||||
|
<Grid.Col span={4} key={paragraph.id}>
|
||||||
|
<ParagraphCard
|
||||||
|
title={paragraph.title}
|
||||||
|
id={paragraph.id}
|
||||||
|
author={paragraph.author}
|
||||||
|
time={paragraph.time}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Grid>
|
||||||
|
<Group position="center">
|
||||||
|
<Pagination total={totalPage} value={page} onChange={toPage} withControls withEdges />
|
||||||
|
</Group>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
components/Welcome/Welcome.story.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Welcome } from './Welcome';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: 'Welcome',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Usage = () => <Welcome />;
|
||||||
14
components/Welcome/Welcome.styles.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { createStyles } from '@mantine/core';
|
||||||
|
|
||||||
|
export default createStyles((theme) => ({
|
||||||
|
title: {
|
||||||
|
color: theme.colorScheme === 'dark' ? theme.white : theme.black,
|
||||||
|
fontSize: 100,
|
||||||
|
fontWeight: 900,
|
||||||
|
letterSpacing: -2,
|
||||||
|
|
||||||
|
[theme.fn.smallerThan('md')]: {
|
||||||
|
fontSize: 50,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
12
components/Welcome/Welcome.test.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { Welcome } from './Welcome';
|
||||||
|
|
||||||
|
describe('Welcome component', () => {
|
||||||
|
it('has correct Next.js theming section link', () => {
|
||||||
|
render(<Welcome />);
|
||||||
|
expect(screen.getByText('this guide')).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
'https://mantine.dev/guides/next/'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
25
components/Welcome/Welcome.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Title, Text, Anchor } from '@mantine/core';
|
||||||
|
import useStyles from './Welcome.styles';
|
||||||
|
|
||||||
|
export function Welcome() {
|
||||||
|
const { classes } = useStyles();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Title className={classes.title} align="center" mt={100}>
|
||||||
|
Welcome to{' '}
|
||||||
|
<Text inherit variant="gradient" component="span">
|
||||||
|
Mantine
|
||||||
|
</Text>
|
||||||
|
</Title>
|
||||||
|
<Text color="dimmed" align="center" size="lg" sx={{ maxWidth: 580 }} mx="auto" mt="xl">
|
||||||
|
This starter Next.js project includes a minimal setup for server side rendering, if you want
|
||||||
|
to learn more on Mantine + Next.js integration follow{' '}
|
||||||
|
<Anchor href="https://mantine.dev/guides/next/" size="lg">
|
||||||
|
this guide
|
||||||
|
</Anchor>
|
||||||
|
. To get started edit index.tsx file.
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
docker-compose.yml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
version: "3"
|
||||||
|
services:
|
||||||
|
ds-next:
|
||||||
|
build: .
|
||||||
|
container_name: ds-next
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "9090:8080"
|
||||||
|
|
||||||
16
jest.config.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
const nextJest = require('next/jest');
|
||||||
|
|
||||||
|
const createJestConfig = nextJest({
|
||||||
|
dir: './',
|
||||||
|
});
|
||||||
|
|
||||||
|
const customJestConfig = {
|
||||||
|
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
||||||
|
moduleNameMapper: {
|
||||||
|
'^@/components/(.*)$': '<rootDir>/components/$1',
|
||||||
|
'^@/pages/(.*)$': '<rootDir>/pages/$1',
|
||||||
|
},
|
||||||
|
testEnvironment: 'jest-environment-jsdom',
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = createJestConfig(customJestConfig);
|
||||||
1
jest.setup.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import '@testing-library/jest-dom/extend-expect';
|
||||||
6
lib/config.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import "dotenv";
|
||||||
|
|
||||||
|
export const MINIO_ENDPOINT = process.env.MINIO_ENDPOINT ?? "http://localhost:9000";
|
||||||
|
export const MINIO_ACCESS_KEY = process.env.MINIO_ACCESS_KEY ?? "minioadmin";
|
||||||
|
export const MINIO_SECRET_KEY = process.env.MINIO_SECRET_KEY ?? "minioadmin";
|
||||||
|
export const MINIO_ENABLED = !!process.env.MINIO_ENABLED;
|
||||||
8
lib/dayjs.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import dayjs from "dayjs";
|
||||||
|
import relativeTime from "dayjs/plugin/relativeTime";
|
||||||
|
|
||||||
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
|
const DayJS = dayjs();
|
||||||
|
|
||||||
|
export default DayJS;
|
||||||
3
lib/db.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
export const prismaClient = new PrismaClient();
|
||||||
63
lib/minio.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
export type BucketInfo = {
|
||||||
|
creation_date: string;
|
||||||
|
details: {
|
||||||
|
quota: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
name: string;
|
||||||
|
objects: number;
|
||||||
|
rw_access: {
|
||||||
|
read: boolean;
|
||||||
|
write: boolean;
|
||||||
|
};
|
||||||
|
size: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class MinIO {
|
||||||
|
endpoint!: string;
|
||||||
|
token!: string;
|
||||||
|
accessKey!: string;
|
||||||
|
secretKey!: string;
|
||||||
|
expireTime = Date.now();
|
||||||
|
async login(endpoint: string, accessKey: string, secretKey: string) {
|
||||||
|
this.endpoint = endpoint;
|
||||||
|
this.accessKey = accessKey;
|
||||||
|
this.secretKey = secretKey;
|
||||||
|
const resp = await fetch(this.endpoint + "/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
accessKey,
|
||||||
|
secretKey,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (resp.status === 204) {
|
||||||
|
let token = resp.headers.get("set-cookie") ?? "";
|
||||||
|
this.expireTime = Date.now() + 3600 * 1000;
|
||||||
|
token = token.split(";")[0];
|
||||||
|
this.token = decodeURIComponent(token);
|
||||||
|
} else {
|
||||||
|
console.log(await resp.json());
|
||||||
|
throw Error("MinIO Login Failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async ensureLogin() {
|
||||||
|
if (Date.now() >= this.expireTime) {
|
||||||
|
await this.login(this.endpoint, this.accessKey, this.secretKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async bucketInfo(name = "crawl") {
|
||||||
|
await this.ensureLogin();
|
||||||
|
const resp = await (
|
||||||
|
await fetch(this.endpoint + "/buckets", {
|
||||||
|
headers: {
|
||||||
|
cookie: this.token,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).json();
|
||||||
|
return (resp.buckets as BucketInfo[]).find((o) => o.name === name)!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const minIO = new MinIO();
|
||||||
5
next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||||
10
next.config.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
const withBundleAnalyzer = require('@next/bundle-analyzer')({
|
||||||
|
enabled: process.env.ANALYZE === 'true',
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = withBundleAnalyzer({
|
||||||
|
reactStrictMode: false,
|
||||||
|
eslint: {
|
||||||
|
ignoreDuringBuilds: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
82
package.json
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
{
|
||||||
|
"name": "mantine-next-template",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"analyze": "ANALYZE=true next build",
|
||||||
|
"start": "next start",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"export": "next build && next export",
|
||||||
|
"lint": "next lint",
|
||||||
|
"jest": "jest",
|
||||||
|
"jest:watch": "jest --watch",
|
||||||
|
"prettier:check": "prettier --check \"**/*.{ts,tsx}\"",
|
||||||
|
"prettier:write": "prettier --write \"**/*.{ts,tsx}\"",
|
||||||
|
"test": "npm run prettier:check && npm run lint && npm run typecheck && npm run jest",
|
||||||
|
"storybook": "start-storybook -p 7001",
|
||||||
|
"storybook:build": "build-storybook"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@emotion/react": "^11.10.4",
|
||||||
|
"@emotion/server": "^11.10.0",
|
||||||
|
"@mantine/carousel": "6.0.0",
|
||||||
|
"@mantine/core": "6.0.0",
|
||||||
|
"@mantine/dates": "6.0.0",
|
||||||
|
"@mantine/hooks": "6.0.0",
|
||||||
|
"@mantine/next": "6.0.0",
|
||||||
|
"@mantine/notifications": "6.0.0",
|
||||||
|
"@mantine/prism": "6.0.0",
|
||||||
|
"@next/bundle-analyzer": "^13.1.6",
|
||||||
|
"@prisma/client": "4.11.0",
|
||||||
|
"@tabler/icons": "^1.107.0",
|
||||||
|
"@tabler/icons-react": "^2.4.0",
|
||||||
|
"cookies-next": "^2.1.1",
|
||||||
|
"dayjs": "^1.11.7",
|
||||||
|
"dotenv": "^16.0.3",
|
||||||
|
"embla-carousel-react": "^7.0.3",
|
||||||
|
"mongodb": "^5.1.0",
|
||||||
|
"next": "13.1.6",
|
||||||
|
"prisma": "^4.11.0",
|
||||||
|
"react": "18.2.0",
|
||||||
|
"react-dom": "18.2.0",
|
||||||
|
"remark": "^14.0.2",
|
||||||
|
"remark-html": "^15.0.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.17.8",
|
||||||
|
"@next/eslint-plugin-next": "^12.1.4",
|
||||||
|
"@storybook/react": "^6.5.13",
|
||||||
|
"@testing-library/dom": "^8.12.0",
|
||||||
|
"@testing-library/jest-dom": "^5.16.3",
|
||||||
|
"@testing-library/react": "^13.0.0",
|
||||||
|
"@testing-library/user-event": "^14.0.4",
|
||||||
|
"@types/jest": "^27.4.1",
|
||||||
|
"@types/node": "^18.11.4",
|
||||||
|
"@types/react": "18.0.21",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^5.30.0",
|
||||||
|
"@typescript-eslint/parser": "^5.30.0",
|
||||||
|
"babel-loader": "^8.2.4",
|
||||||
|
"eslint": "^8.18.0",
|
||||||
|
"eslint-config-airbnb": "19.0.4",
|
||||||
|
"eslint-config-airbnb-typescript": "^17.0.0",
|
||||||
|
"eslint-config-mantine": "2.0.0",
|
||||||
|
"eslint-config-prettier": "^8.7.0",
|
||||||
|
"eslint-plugin-import": "^2.26.0",
|
||||||
|
"eslint-plugin-jest": "^26.1.1",
|
||||||
|
"eslint-plugin-jsx-a11y": "^6.6.0",
|
||||||
|
"eslint-plugin-prettier": "^4.2.1",
|
||||||
|
"eslint-plugin-react": "^7.30.1",
|
||||||
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
|
"eslint-plugin-storybook": "^0.5.7",
|
||||||
|
"eslint-plugin-testing-library": "^5.2.0",
|
||||||
|
"jest": "^27.5.1",
|
||||||
|
"prettier": "^2.7.1",
|
||||||
|
"storybook-addon-turbo-build": "^1.1.0",
|
||||||
|
"storybook-dark-mode": "^1.1.2",
|
||||||
|
"ts-jest": "^27.1.4",
|
||||||
|
"ts-node": "^10.9.1",
|
||||||
|
"typescript": "4.8.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
42
pages/_app.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { ColorScheme, ColorSchemeProvider, MantineProvider } from "@mantine/core";
|
||||||
|
import { Notifications } from "@mantine/notifications";
|
||||||
|
import { getCookie, setCookie } from "cookies-next";
|
||||||
|
import NextApp, { AppContext, AppProps } from "next/app";
|
||||||
|
import Head from "next/head";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export default function App(props: AppProps & { colorScheme: ColorScheme }) {
|
||||||
|
const { Component, pageProps } = props;
|
||||||
|
const [colorScheme, setColorScheme] = useState<ColorScheme>(props.colorScheme);
|
||||||
|
|
||||||
|
const toggleColorScheme = (value?: ColorScheme) => {
|
||||||
|
const nextColorScheme = value || (colorScheme === "dark" ? "light" : "dark");
|
||||||
|
setColorScheme(nextColorScheme);
|
||||||
|
setCookie("mantine-color-scheme", nextColorScheme, { maxAge: 60 * 60 * 24 * 30 });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>Mantine next example</title>
|
||||||
|
<meta name="viewport" content="minimum-scale=1, initial-scale=1, width=device-width" />
|
||||||
|
<link rel="shortcut icon" href="/favicon.svg" />
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
<ColorSchemeProvider colorScheme={colorScheme} toggleColorScheme={toggleColorScheme}>
|
||||||
|
<MantineProvider theme={{ colorScheme }} withGlobalStyles withNormalizeCSS>
|
||||||
|
<Component {...pageProps} />
|
||||||
|
<Notifications />
|
||||||
|
</MantineProvider>
|
||||||
|
</ColorSchemeProvider>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
App.getInitialProps = async (appContext: AppContext) => {
|
||||||
|
const appProps = await NextApp.getInitialProps(appContext);
|
||||||
|
return {
|
||||||
|
...appProps,
|
||||||
|
colorScheme: getCookie("mantine-color-scheme", appContext.ctx) || "dark",
|
||||||
|
};
|
||||||
|
};
|
||||||
8
pages/_document.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import Document from 'next/document';
|
||||||
|
import { createGetInitialProps } from '@mantine/next';
|
||||||
|
|
||||||
|
const getInitialProps = createGetInitialProps();
|
||||||
|
|
||||||
|
export default class _Document extends Document {
|
||||||
|
static getInitialProps = getInitialProps;
|
||||||
|
}
|
||||||
59
pages/author/[name].tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { Paragraph } from "@prisma/client";
|
||||||
|
import { GetServerSidePropsContext, GetServerSidePropsResult } from "next";
|
||||||
|
|
||||||
|
import ParagraphGrid from "../../components/ParagraphGrid/ParagraphGrid";
|
||||||
|
import { prismaClient } from "../../lib/db";
|
||||||
|
|
||||||
|
interface ListProps {
|
||||||
|
paragraphs: Omit<Paragraph, "content" | "markdown">[];
|
||||||
|
skip: number;
|
||||||
|
take: number;
|
||||||
|
total: number;
|
||||||
|
author: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuthorPage(props: ListProps) {
|
||||||
|
return <ParagraphGrid title={`DS-Next | Author ${props.author}`} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getServerSideProps(
|
||||||
|
ctx: GetServerSidePropsContext
|
||||||
|
): Promise<GetServerSidePropsResult<ListProps>> {
|
||||||
|
const skip = Number(ctx.query.skip ?? 0);
|
||||||
|
const take = Number(ctx.query.take ?? 12);
|
||||||
|
const author = ctx.params?.name;
|
||||||
|
|
||||||
|
if (!author || typeof author !== "string") {
|
||||||
|
return { notFound: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const condition = {
|
||||||
|
author: author,
|
||||||
|
};
|
||||||
|
|
||||||
|
const [total, paragraphs] = await Promise.all([
|
||||||
|
prismaClient.paragraph.count({
|
||||||
|
where: condition,
|
||||||
|
}),
|
||||||
|
await prismaClient.paragraph.findMany({
|
||||||
|
where: condition,
|
||||||
|
skip: Number(skip),
|
||||||
|
take: Number(take),
|
||||||
|
orderBy: {
|
||||||
|
time: "desc",
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
tags: true,
|
||||||
|
time: true,
|
||||||
|
author: true,
|
||||||
|
cover: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
paragraphs.forEach((paragraph) => {
|
||||||
|
paragraph.time = paragraph.time.getTime() as any;
|
||||||
|
});
|
||||||
|
return { props: { author, paragraphs, skip, take, total } };
|
||||||
|
}
|
||||||
55
pages/index.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { Button } from "@mantine/core";
|
||||||
|
import { Paragraph } from "@prisma/client";
|
||||||
|
import { GetServerSidePropsContext, GetServerSidePropsResult } from "next";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
|
import ParagraphGrid from "../components/ParagraphGrid/ParagraphGrid";
|
||||||
|
import { prismaClient } from "../lib/db";
|
||||||
|
|
||||||
|
interface ListProps {
|
||||||
|
paragraphs: Omit<Paragraph, "content" | "markdown">[];
|
||||||
|
skip: number;
|
||||||
|
take: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HomePage(props: ListProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ParagraphGrid
|
||||||
|
title="DS-Next"
|
||||||
|
titleAction={<Button onClick={() => router.push("/statistic")}>Statistic</Button>}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getServerSideProps(
|
||||||
|
ctx: GetServerSidePropsContext
|
||||||
|
): Promise<GetServerSidePropsResult<ListProps>> {
|
||||||
|
const skip = Number(ctx.query.skip ?? 0);
|
||||||
|
const take = Number(ctx.query.take ?? 12);
|
||||||
|
const [total, paragraphs] = await Promise.all([
|
||||||
|
prismaClient.paragraph.count(),
|
||||||
|
await prismaClient.paragraph.findMany({
|
||||||
|
skip: Number(skip),
|
||||||
|
take: Number(take),
|
||||||
|
orderBy: {
|
||||||
|
time: "desc",
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
tags: true,
|
||||||
|
time: true,
|
||||||
|
author: true,
|
||||||
|
cover: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
paragraphs.forEach((paragraph) => {
|
||||||
|
paragraph.time = paragraph.time.getTime() as any;
|
||||||
|
});
|
||||||
|
return { props: { paragraphs, skip, take, total } };
|
||||||
|
}
|
||||||
99
pages/paragraph/[id].tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Container,
|
||||||
|
Group,
|
||||||
|
ScrollArea,
|
||||||
|
Text,
|
||||||
|
Title,
|
||||||
|
UnstyledButton,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { Paragraph } from "@prisma/client";
|
||||||
|
import { IconArrowBack } from "@tabler/icons";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import { GetServerSidePropsContext, GetServerSidePropsResult } from "next";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { remark } from "remark";
|
||||||
|
import html from "remark-html";
|
||||||
|
|
||||||
|
import { ParagraphContent } from "../../components/ParagraphContent/ParagraphContent";
|
||||||
|
import DayJS from "../../lib/dayjs";
|
||||||
|
import { prismaClient } from "../../lib/db";
|
||||||
|
|
||||||
|
export default function ParagraphPage({ paragraph }: { paragraph: Paragraph }) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollArea h="100vh">
|
||||||
|
<Container my="7rem">
|
||||||
|
<Title mb="xl">{paragraph.title}</Title>
|
||||||
|
<Group position="apart">
|
||||||
|
<Group>
|
||||||
|
<Text c="dimmed"> {DayJS.to(dayjs(paragraph.time))}</Text>
|
||||||
|
<UnstyledButton
|
||||||
|
onClick={() =>
|
||||||
|
router.push({
|
||||||
|
pathname: "/author/[name]",
|
||||||
|
query: { name: paragraph.author },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Badge ml="1rem" radius="sm">
|
||||||
|
{paragraph.author}
|
||||||
|
</Badge>
|
||||||
|
</UnstyledButton>
|
||||||
|
</Group>
|
||||||
|
<Button variant="subtle" onClick={() => router.back()} rightIcon={<IconArrowBack />}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
<Group mb="xl">
|
||||||
|
{paragraph.tags.map((tag) => (
|
||||||
|
<UnstyledButton
|
||||||
|
key={tag}
|
||||||
|
onClick={() =>
|
||||||
|
router.push({
|
||||||
|
pathname: "/tag/[name]",
|
||||||
|
query: { name: tag },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Badge fz="xs" variant="dot">
|
||||||
|
{tag}
|
||||||
|
</Badge>
|
||||||
|
</UnstyledButton>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
<ParagraphContent content={paragraph.content} />
|
||||||
|
</Container>
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getServerSideProps({
|
||||||
|
params,
|
||||||
|
}: GetServerSidePropsContext): Promise<GetServerSidePropsResult<{ paragraph: Paragraph }>> {
|
||||||
|
const id = params?.id;
|
||||||
|
|
||||||
|
if (!id || typeof id !== "string") {
|
||||||
|
return {
|
||||||
|
notFound: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const paragraph = await prismaClient.paragraph.findUniqueOrThrow({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
paragraph.time = paragraph.time.toISOString() as any;
|
||||||
|
|
||||||
|
if (paragraph.markdown) {
|
||||||
|
const resp = await remark().use(html).process(paragraph.content);
|
||||||
|
paragraph.content = resp.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
paragraph,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
158
pages/statistic.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { Button, Container, createStyles, Group, rem, Text, Title } from "@mantine/core";
|
||||||
|
import { IconArrowBack } from "@tabler/icons";
|
||||||
|
import { GetServerSidePropsResult } from "next";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
|
import { MINIO_ACCESS_KEY, MINIO_ENABLED, MINIO_ENDPOINT, MINIO_SECRET_KEY } from "../lib/config";
|
||||||
|
import { prismaClient } from "../lib/db";
|
||||||
|
import { minIO } from "../lib/minio";
|
||||||
|
|
||||||
|
function humanFileSize(bytes: number, si = false, dp = 1) {
|
||||||
|
const thresh = si ? 1000 : 1024;
|
||||||
|
|
||||||
|
if (Math.abs(bytes) < thresh) {
|
||||||
|
return bytes + " B";
|
||||||
|
}
|
||||||
|
|
||||||
|
const units = si
|
||||||
|
? ["kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]
|
||||||
|
: ["KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"];
|
||||||
|
let u = -1;
|
||||||
|
const r = 10 ** dp;
|
||||||
|
|
||||||
|
do {
|
||||||
|
bytes /= thresh;
|
||||||
|
++u;
|
||||||
|
} while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1);
|
||||||
|
|
||||||
|
return bytes.toFixed(dp) + " " + units[u];
|
||||||
|
}
|
||||||
|
|
||||||
|
const useStyles = createStyles((theme) => ({
|
||||||
|
root: {
|
||||||
|
display: "flex",
|
||||||
|
backgroundImage: `linear-gradient(-60deg, ${theme.colors[theme.primaryColor][4]} 0%, ${
|
||||||
|
theme.colors[theme.primaryColor][7]
|
||||||
|
} 100%)`,
|
||||||
|
padding: `calc(${theme.spacing.xl} * 1.5)`,
|
||||||
|
borderRadius: theme.radius.md,
|
||||||
|
|
||||||
|
[theme.fn.smallerThan("sm")]: {
|
||||||
|
flexDirection: "column",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
title: {
|
||||||
|
color: theme.white,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: theme.fontSizes.sm,
|
||||||
|
},
|
||||||
|
|
||||||
|
count: {
|
||||||
|
color: theme.white,
|
||||||
|
fontSize: rem(32),
|
||||||
|
lineHeight: 1,
|
||||||
|
fontWeight: 700,
|
||||||
|
marginBottom: theme.spacing.md,
|
||||||
|
fontFamily: `Greycliff CF, ${theme.fontFamily}`,
|
||||||
|
},
|
||||||
|
|
||||||
|
description: {
|
||||||
|
color: theme.colors[theme.primaryColor][0],
|
||||||
|
fontSize: theme.fontSizes.sm,
|
||||||
|
marginTop: rem(5),
|
||||||
|
},
|
||||||
|
|
||||||
|
stat: {
|
||||||
|
flex: 1,
|
||||||
|
|
||||||
|
"& + &": {
|
||||||
|
paddingLeft: theme.spacing.xl,
|
||||||
|
marginLeft: theme.spacing.xl,
|
||||||
|
borderLeft: `${rem(1)} solid ${theme.colors[theme.primaryColor][3]}`,
|
||||||
|
|
||||||
|
[theme.fn.smallerThan("sm")]: {
|
||||||
|
paddingLeft: 0,
|
||||||
|
marginLeft: 0,
|
||||||
|
borderLeft: 0,
|
||||||
|
paddingTop: theme.spacing.xl,
|
||||||
|
marginTop: theme.spacing.xl,
|
||||||
|
borderTop: `${rem(1)} solid ${theme.colors[theme.primaryColor][3]}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface StatsGroupProps {
|
||||||
|
data: { title: string; stats: string; description?: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatisticPage({ data }: StatsGroupProps) {
|
||||||
|
const { classes } = useStyles();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const stats = data.map((stat) => (
|
||||||
|
<div key={stat.title} className={classes.stat}>
|
||||||
|
<Text className={classes.count}>{stat.stats}</Text>
|
||||||
|
<Text className={classes.title}>{stat.title}</Text>
|
||||||
|
<Text className={classes.description}>{stat.description}</Text>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
return (
|
||||||
|
<Container my="7rem">
|
||||||
|
<Title>Statistic</Title>
|
||||||
|
<Group position="apart" my="2rem">
|
||||||
|
<div></div>
|
||||||
|
<Button variant="subtle" onClick={() => router.back()} rightIcon={<IconArrowBack />}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
<div className={classes.root}>{stats}</div>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getServerSideProps(): Promise<GetServerSidePropsResult<StatsGroupProps>> {
|
||||||
|
const [total, { time }] = await Promise.all([
|
||||||
|
prismaClient.paragraph.count(),
|
||||||
|
prismaClient.paragraph.findFirstOrThrow({
|
||||||
|
orderBy: {
|
||||||
|
time: "desc",
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
time: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const data = [
|
||||||
|
{
|
||||||
|
title: "Paragraphs",
|
||||||
|
stats: total.toString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Last Update",
|
||||||
|
stats: time?.toLocaleDateString() ?? "N/A",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (MINIO_ENABLED) {
|
||||||
|
await minIO.login(MINIO_ENDPOINT, MINIO_ACCESS_KEY, MINIO_SECRET_KEY);
|
||||||
|
const info = await minIO.bucketInfo();
|
||||||
|
data.push({
|
||||||
|
title: "Images",
|
||||||
|
stats: info.objects.toString(),
|
||||||
|
});
|
||||||
|
data.push({
|
||||||
|
title: "Images Size",
|
||||||
|
stats: humanFileSize(info.size),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
data,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
61
pages/tag/[name].tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { Paragraph } from "@prisma/client";
|
||||||
|
import { GetServerSidePropsContext, GetServerSidePropsResult } from "next";
|
||||||
|
|
||||||
|
import ParagraphGrid from "../../components/ParagraphGrid/ParagraphGrid";
|
||||||
|
import { prismaClient } from "../../lib/db";
|
||||||
|
|
||||||
|
interface ListProps {
|
||||||
|
paragraphs: Omit<Paragraph, "content" | "markdown">[];
|
||||||
|
skip: number;
|
||||||
|
take: number;
|
||||||
|
total: number;
|
||||||
|
tag: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TagPage(props: ListProps) {
|
||||||
|
return <ParagraphGrid title={`DS-Next | Tag ${props.tag}`} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getServerSideProps(
|
||||||
|
ctx: GetServerSidePropsContext
|
||||||
|
): Promise<GetServerSidePropsResult<ListProps>> {
|
||||||
|
const skip = Number(ctx.query.skip ?? 0);
|
||||||
|
const take = Number(ctx.query.take ?? 12);
|
||||||
|
const tag = ctx.params?.name;
|
||||||
|
|
||||||
|
if (!tag || typeof tag !== "string") {
|
||||||
|
return { notFound: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const condition = {
|
||||||
|
tags: {
|
||||||
|
has: tag,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const [total, paragraphs] = await Promise.all([
|
||||||
|
prismaClient.paragraph.count({
|
||||||
|
where: condition,
|
||||||
|
}),
|
||||||
|
await prismaClient.paragraph.findMany({
|
||||||
|
where: condition,
|
||||||
|
skip: Number(skip),
|
||||||
|
take: Number(take),
|
||||||
|
orderBy: {
|
||||||
|
time: "desc",
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
tags: true,
|
||||||
|
time: true,
|
||||||
|
author: true,
|
||||||
|
cover: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
paragraphs.forEach((paragraph) => {
|
||||||
|
paragraph.time = paragraph.time.getTime() as any;
|
||||||
|
});
|
||||||
|
return { props: { tag, paragraphs, skip, take, total } };
|
||||||
|
}
|
||||||
13
prisma/migrations/20230314084213_dev/migration.sql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "paragraph" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"author" TEXT NOT NULL,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"cover" TEXT,
|
||||||
|
"markdown" BOOLEAN,
|
||||||
|
"tags" TEXT[],
|
||||||
|
"time" DATE NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "paragraph_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
22
prisma/migrations/20230314090553_dev/migration.sql
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the `paragraph` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "paragraph";
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Paragraph" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"author" TEXT NOT NULL,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"cover" TEXT,
|
||||||
|
"markdown" BOOLEAN,
|
||||||
|
"tags" TEXT[],
|
||||||
|
"time" DATE NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Paragraph_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
3
prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (i.e. Git)
|
||||||
|
provider = "postgresql"
|
||||||
19
prisma/schema.prisma
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgres"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Paragraph {
|
||||||
|
id String @id
|
||||||
|
author String
|
||||||
|
content String
|
||||||
|
cover String?
|
||||||
|
markdown Boolean?
|
||||||
|
tags String[]
|
||||||
|
time DateTime @db.Date
|
||||||
|
title String
|
||||||
|
}
|
||||||
1
public/favicon.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500"><g fill="none" fill-rule="evenodd"><rect width="500" height="500" fill="#339AF0" rx="250"/><g fill="#FFF"><path fill-rule="nonzero" d="M202.055 135.706c-6.26 8.373-4.494 20.208 3.944 26.42 29.122 21.45 45.824 54.253 45.824 90.005 0 35.752-16.702 68.559-45.824 90.005-8.436 6.215-10.206 18.043-3.944 26.42 6.26 8.378 18.173 10.13 26.611 3.916a153.835 153.835 0 0024.509-22.54h53.93c10.506 0 19.023-8.455 19.023-18.885 0-10.43-8.517-18.886-19.023-18.886h-29.79c8.196-18.594 12.553-38.923 12.553-60.03s-4.357-41.436-12.552-60.03h29.79c10.505 0 19.022-8.455 19.022-18.885 0-10.43-8.517-18.886-19.023-18.886h-53.93a153.835 153.835 0 00-24.509-22.54c-8.438-6.215-20.351-4.46-26.61 3.916z"/><path d="M171.992 246.492c0-15.572 12.624-28.195 28.196-28.195 15.572 0 28.195 12.623 28.195 28.195 0 15.572-12.623 28.196-28.195 28.196-15.572 0-28.196-12.624-28.196-28.196z"/></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 937 B |
BIN
public/icon/anquanke.ico
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
public/icon/freebuf.ico
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
public/icon/secin.ico
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
public/icon/seebug.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
public/icon/tttang.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
public/icon/wechat.png
Normal file
|
After Width: | Height: | Size: 827 B |
BIN
public/icon/xianzhi.png
Normal file
|
After Width: | Height: | Size: 562 B |
35
tsconfig.json
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es5",
|
||||||
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true
|
||||||
|
},
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
],
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx"
|
||||||
|
],
|
||||||
|
"ts-node": {
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "commonjs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||