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"
|
||||
}
|
||||
}
|
||||
}
|
||||