update
@@ -1,9 +1,24 @@
|
|||||||
.env
|
# Logs
|
||||||
Dockerfile
|
logs
|
||||||
docker-compose.yml
|
*.log
|
||||||
.dockerignore
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
npm-debug.log
|
dist
|
||||||
README.md
|
dist-ssr
|
||||||
.next
|
*.local
|
||||||
.git
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|||||||
54
.drone.yml
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
kind: pipeline
|
||||||
|
type: docker
|
||||||
|
name: Publish docker next
|
||||||
|
|
||||||
|
clone:
|
||||||
|
depth: 1
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Build
|
||||||
|
image: plugins/docker
|
||||||
|
pull: if-not-exists
|
||||||
|
settings:
|
||||||
|
dockerfile: docker/Dockerfile
|
||||||
|
context: .
|
||||||
|
repo: git.yoshino-s.xyz/ds/ds-ui
|
||||||
|
registry: git.yoshino-s.xyz
|
||||||
|
username: yoshino-s
|
||||||
|
password:
|
||||||
|
from_secret: GITEA_TOKEN
|
||||||
|
tags: next
|
||||||
|
---
|
||||||
|
kind: pipeline
|
||||||
|
type: docker
|
||||||
|
name: Release
|
||||||
|
|
||||||
|
clone:
|
||||||
|
depth: 1
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Publish
|
||||||
|
image: plugins/gitea-release
|
||||||
|
pull: if-not-exists
|
||||||
|
settings:
|
||||||
|
api_key:
|
||||||
|
from_secret: GITEA_TOKEN
|
||||||
|
base_url: https://git.yoshino-s.xyz
|
||||||
|
- name: Publish Tag
|
||||||
|
image: plugins/docker
|
||||||
|
pull: if-not-exists
|
||||||
|
settings:
|
||||||
|
dockerfile: docker/Dockerfile
|
||||||
|
context: .
|
||||||
|
repo: git.yoshino-s.xyz/ds/ds-ui
|
||||||
|
registry: git.yoshino-s.xyz
|
||||||
|
username: yoshino-s
|
||||||
|
password:
|
||||||
|
from_secret: GITEA_TOKEN
|
||||||
|
tags:
|
||||||
|
- ${DRONE_TAG}
|
||||||
|
- latest
|
||||||
|
|
||||||
|
trigger:
|
||||||
|
event:
|
||||||
|
- tag
|
||||||
11
.env
@@ -1,11 +0,0 @@
|
|||||||
# 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=mysql://vault:vault@database.pve/spider
|
|
||||||
1
.eslintignore
Normal file
@@ -0,0 +1 @@
|
|||||||
|
*.js
|
||||||
59
.eslintrc.js
@@ -2,60 +2,35 @@ module.exports = {
|
|||||||
parser: "@typescript-eslint/parser",
|
parser: "@typescript-eslint/parser",
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
sourceType: "module",
|
sourceType: "module",
|
||||||
|
project: "./tsconfig.json"
|
||||||
},
|
},
|
||||||
plugins: ["@typescript-eslint/eslint-plugin", "testing-library", "jest"],
|
plugins: ["@typescript-eslint/eslint-plugin"],
|
||||||
overrides: [
|
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"],
|
||||||
{
|
|
||||||
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,
|
root: true,
|
||||||
env: {
|
env: {
|
||||||
node: true,
|
node: true,
|
||||||
jest: true,
|
jest: true
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
"prettier/prettier": [
|
"prettier/prettier": ["error", {
|
||||||
"error",
|
singleQuote: false
|
||||||
{
|
}],
|
||||||
singleQuote: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"@typescript-eslint/explicit-function-return-type": "off",
|
"@typescript-eslint/explicit-function-return-type": "off",
|
||||||
"@typescript-eslint/no-explicit-any": "off",
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
"@typescript-eslint/explicit-module-boundary-types": "off",
|
"@typescript-eslint/explicit-module-boundary-types": "off",
|
||||||
"@typescript-eslint/quotes": [2, "double", "avoid-escape"],
|
"@typescript-eslint/quotes": [2, "double", "avoid-escape"],
|
||||||
"no-unused-vars": "off",
|
"no-unused-vars": "off",
|
||||||
"@typescript-eslint/no-unused-vars": [
|
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_", "destructuredArrayIgnorePattern": "^_" }],
|
||||||
"error",
|
"quotes": [2, "double", "avoid-escape"],
|
||||||
{
|
"semi": ["error", "always"],
|
||||||
argsIgnorePattern: "^_",
|
|
||||||
destructuredArrayIgnorePattern: "^_",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
quotes: [2, "double", "avoid-escape"],
|
|
||||||
semi: ["error", "always"],
|
|
||||||
"eol-last": ["error", "always"],
|
"eol-last": ["error", "always"],
|
||||||
"react/react-in-jsx-scope": "off",
|
"react/react-in-jsx-scope": "off",
|
||||||
"import/no-unresolved": "off",
|
"import/no-unresolved": "off",
|
||||||
"import/order": [
|
"import/order": ["error", {
|
||||||
"error",
|
"newlines-between": "always",
|
||||||
{
|
"alphabetize": {
|
||||||
"newlines-between": "always",
|
"order": "asc"
|
||||||
alphabetize: {
|
}
|
||||||
order: "asc",
|
}]
|
||||||
},
|
}
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
52
.gitignore
vendored
@@ -1,38 +1,24 @@
|
|||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
# Logs
|
||||||
|
logs
|
||||||
# dependencies
|
*.log
|
||||||
/node_modules
|
|
||||||
/.pnp
|
|
||||||
.pnp.js
|
|
||||||
|
|
||||||
# testing
|
|
||||||
/coverage
|
|
||||||
|
|
||||||
# next.js
|
|
||||||
/.next/
|
|
||||||
/out/
|
|
||||||
|
|
||||||
# production
|
|
||||||
/build
|
|
||||||
|
|
||||||
# misc
|
|
||||||
.DS_Store
|
|
||||||
*.pem
|
|
||||||
|
|
||||||
# debug
|
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
# local env files
|
node_modules
|
||||||
.env.local
|
dist
|
||||||
.env.development.local
|
dist-ssr
|
||||||
.env.test.local
|
*.local
|
||||||
.env.production.local
|
|
||||||
|
|
||||||
# vercel
|
# Editor directories and files
|
||||||
.vercel
|
.vscode/*
|
||||||
*.tsbuildinfo
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
# storybook
|
.DS_Store
|
||||||
storybook-static
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
module.exports = require('eslint-config-mantine/.prettierrc.js');
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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',
|
|
||||||
};
|
|
||||||
20
.storybook/main.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
module.exports = {
|
||||||
|
"stories": [
|
||||||
|
"../src/**/*.stories.mdx",
|
||||||
|
"../src/**/*.stories.@(js|jsx|ts|tsx)"
|
||||||
|
],
|
||||||
|
"addons": [
|
||||||
|
"@storybook/addon-links",
|
||||||
|
"storybook-dark-mode",
|
||||||
|
"@storybook/addon-essentials",
|
||||||
|
"@storybook/addon-interactions",
|
||||||
|
"storybook-addon-react-router-v6",
|
||||||
|
],
|
||||||
|
"framework": "@storybook/react",
|
||||||
|
"core": {
|
||||||
|
"builder": "@storybook/builder-vite"
|
||||||
|
},
|
||||||
|
"features": {
|
||||||
|
"storyStoreV7": true
|
||||||
|
}
|
||||||
|
}
|
||||||
3
.storybook/preview-head.html
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<script>
|
||||||
|
window.global = window;
|
||||||
|
</script>
|
||||||
@@ -1,21 +1,61 @@
|
|||||||
|
import {
|
||||||
|
ActionIcon, Affix, ColorSchemeProvider, createEmotionCache, MantineProvider
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { useHotkeys } from '@mantine/hooks';
|
||||||
|
import React, { useState } from 'react';
|
||||||
import { useDarkMode } from 'storybook-dark-mode';
|
import { useDarkMode } from 'storybook-dark-mode';
|
||||||
import { MantineProvider, ColorSchemeProvider } from '@mantine/core';
|
import rtlPlugin from 'stylis-plugin-rtl';
|
||||||
import { NotificationsProvider } from '@mantine/notifications';
|
|
||||||
|
|
||||||
export const parameters = { layout: 'fullscreen' };
|
export const parameters = {
|
||||||
|
layout: 'fullscreen' ,
|
||||||
|
actions: { argTypesRegex: "^on[A-Z].*" },
|
||||||
|
controls: {
|
||||||
|
matchers: {
|
||||||
|
color: /(background|color)$/i,
|
||||||
|
date: /Date$/,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const rtlCache = createEmotionCache({ key: 'mantine-rtl', stylisPlugins: [rtlPlugin] });
|
||||||
|
|
||||||
|
function ThemeWrapper(props: any) {
|
||||||
|
const [rtl, setRtl] = useState(false);
|
||||||
|
const toggleRtl = () => setRtl((r) => !r);
|
||||||
|
useHotkeys([['mod + L', toggleRtl]]);
|
||||||
|
|
||||||
function ThemeWrapper(props: { children: React.ReactNode }) {
|
|
||||||
return (
|
return (
|
||||||
<ColorSchemeProvider colorScheme="light" toggleColorScheme={() => {}}>
|
<ColorSchemeProvider colorScheme="light" toggleColorScheme={() => {}}>
|
||||||
<MantineProvider
|
<MantineProvider
|
||||||
theme={{ colorScheme: useDarkMode() ? 'dark' : 'light' }}
|
theme={{
|
||||||
|
dir: rtl ? 'rtl' : 'ltr',
|
||||||
|
colorScheme: useDarkMode() ? 'dark' : 'light',
|
||||||
|
headings: { fontFamily: 'Greycliff CF, sans-serif' },
|
||||||
|
}}
|
||||||
|
emotionCache={rtl ? rtlCache : undefined}
|
||||||
withGlobalStyles
|
withGlobalStyles
|
||||||
withNormalizeCSS
|
withNormalizeCSS
|
||||||
>
|
>
|
||||||
<NotificationsProvider>{props.children}</NotificationsProvider>
|
<Affix position={{ right: rtl ? 'unset' : 0, left: rtl ? 0 : 'unset', bottom: 0 }}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={toggleRtl}
|
||||||
|
variant="default"
|
||||||
|
style={{
|
||||||
|
borderBottom: 0,
|
||||||
|
borderRight: 0,
|
||||||
|
borderTopLeftRadius: 4,
|
||||||
|
width: 60,
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
radius={0}
|
||||||
|
size={30}
|
||||||
|
>
|
||||||
|
{rtl ? 'RTL' : 'LTR'}
|
||||||
|
</ActionIcon>
|
||||||
|
</Affix>
|
||||||
|
<div dir={rtl ? 'rtl' : 'ltr'}>{props.children}</div>
|
||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
</ColorSchemeProvider>
|
</ColorSchemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const decorators = [(renderStory: Function) => <ThemeWrapper>{renderStory()}</ThemeWrapper>];
|
export const decorators = [(renderStory: any) => <ThemeWrapper>{renderStory()}</ThemeWrapper>];
|
||||||
6
.vscode/settings.json
vendored
@@ -12,6 +12,10 @@
|
|||||||
},
|
},
|
||||||
"cSpell.words": [
|
"cSpell.words": [
|
||||||
"mantine",
|
"mantine",
|
||||||
"MINIO"
|
"MINIO",
|
||||||
|
"zincsearch"
|
||||||
|
],
|
||||||
|
"i18n-ally.localesPaths": [
|
||||||
|
"src/locales"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
37
Dockerfile
@@ -1,37 +0,0 @@
|
|||||||
FROM node:18-alpine AS base
|
|
||||||
|
|
||||||
FROM base AS deps
|
|
||||||
RUN apk add --no-cache libc6-compat
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY package.json yarn.lock ./
|
|
||||||
RUN yarn config set registry https://nexus.yoshino-s.xyz/repository/npm/
|
|
||||||
RUN yarn --frozen-lockfile
|
|
||||||
|
|
||||||
FROM base AS builder
|
|
||||||
WORKDIR /app
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
RUN yarn prisma generate && 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"]
|
|
||||||
41
README.md
@@ -1,39 +1,8 @@
|
|||||||
# Mantine Next Template
|
# Mantine + Vite template
|
||||||
|
|
||||||
Get started with Mantine + Next with just a few button clicks.
|
Official [Mantine](https://mantine.dev/) + [Vite](https://vitejs.dev/) template.
|
||||||
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
|
Links:
|
||||||
|
|
||||||
This template comes with several essential features:
|
- [Mantine documentation](https://mantine.dev/)
|
||||||
|
- [Vite documentation](https://vitejs.dev/)
|
||||||
- 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
|
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
import { ActionIcon, createStyles, Group, Header, rem, Text, TextInput } from "@mantine/core";
|
|
||||||
import { useForm } from "@mantine/form";
|
|
||||||
import { Icon123, IconHome, IconSearch } from "@tabler/icons";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
|
|
||||||
const useStyles = createStyles((theme) => ({
|
|
||||||
header: {
|
|
||||||
paddingLeft: theme.spacing.md,
|
|
||||||
paddingRight: theme.spacing.md,
|
|
||||||
},
|
|
||||||
|
|
||||||
inner: {
|
|
||||||
height: rem(56),
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
|
|
||||||
link: {
|
|
||||||
display: "block",
|
|
||||||
lineHeight: 1,
|
|
||||||
padding: `${rem(8)} ${rem(12)}`,
|
|
||||||
borderRadius: theme.radius.sm,
|
|
||||||
textDecoration: "none",
|
|
||||||
color: theme.colorScheme === "dark" ? theme.colors.dark[0] : theme.colors.gray[7],
|
|
||||||
fontSize: theme.fontSizes.sm,
|
|
||||||
fontWeight: 500,
|
|
||||||
|
|
||||||
"&:hover": {
|
|
||||||
backgroundColor: theme.colorScheme === "dark" ? theme.colors.dark[6] : theme.colors.gray[0],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
linkText: {
|
|
||||||
[theme.fn.smallerThan("sm")]: {
|
|
||||||
display: "none",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
export function HeaderSearch() {
|
|
||||||
const { classes } = useStyles();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
initialValues: {
|
|
||||||
search: "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const items = [
|
|
||||||
{
|
|
||||||
link: "/",
|
|
||||||
label: "Home",
|
|
||||||
icon: <IconHome />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
link: "/statistic",
|
|
||||||
label: "Statistic",
|
|
||||||
icon: <Icon123 />,
|
|
||||||
},
|
|
||||||
].map((link) => (
|
|
||||||
<a key={link.label} href={link.link} className={classes.link}>
|
|
||||||
<Group>
|
|
||||||
{link.icon}
|
|
||||||
<Text className={classes.linkText}>{link.label}</Text>
|
|
||||||
</Group>
|
|
||||||
</a>
|
|
||||||
));
|
|
||||||
|
|
||||||
function submit({ search }: { search: string }) {
|
|
||||||
router.push({
|
|
||||||
pathname: "/search/[word]",
|
|
||||||
query: { word: search },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Header height={56} className={classes.header}>
|
|
||||||
<div className={classes.inner}>
|
|
||||||
<Group spacing={5}>{items}</Group>
|
|
||||||
<form onSubmit={form.onSubmit(submit)}>
|
|
||||||
<Group>
|
|
||||||
<TextInput placeholder="Search" required {...form.getInputProps("search")} />
|
|
||||||
<ActionIcon type="submit">
|
|
||||||
<IconSearch />
|
|
||||||
</ActionIcon>
|
|
||||||
</Group>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</Header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
import { Button, Container, Grid, Group, Pagination, ScrollArea, 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 (
|
|
||||||
<ScrollArea h="calc( 100vh - 56px )">
|
|
||||||
<Container my="2rem">
|
|
||||||
<Title>
|
|
||||||
{title} Page {page}
|
|
||||||
</Title>
|
|
||||||
<Group position="apart">
|
|
||||||
<Group>{titleAction}</Group>
|
|
||||||
<Button variant="subtle" onClick={() => router.back()} rightIcon={<IconArrowBack />}>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
<Grid my="md">
|
|
||||||
{paragraphs.map((paragraph) => {
|
|
||||||
return (
|
|
||||||
<Grid.Col xs={12} sm={4} lg={3} 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>
|
|
||||||
</ScrollArea>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { Welcome } from './Welcome';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
title: 'Welcome',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Usage = () => <Welcome />;
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
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/'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
version: "3"
|
version: "3"
|
||||||
services:
|
services:
|
||||||
ds-next:
|
ds-next:
|
||||||
build: .
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/Dockerfile
|
||||||
container_name: ds-next
|
container_name: ds-next
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- "9090:3000"
|
- "9090:80"
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
25
docker/Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
FROM node:18-alpine AS base
|
||||||
|
|
||||||
|
FROM base AS deps
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json yarn.lock ./
|
||||||
|
RUN yarn config set registry https://nexus.yoshino-s.xyz/repository/npm/
|
||||||
|
RUN yarn --frozen-lockfile
|
||||||
|
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN yarn build
|
||||||
|
|
||||||
|
FROM nginx:1.21-alpine AS runner
|
||||||
|
|
||||||
|
COPY docker/default.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=builder /app/dist /static
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
14
docker/default.conf
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
|
||||||
|
autoindex off;
|
||||||
|
|
||||||
|
server_name _;
|
||||||
|
server_tokens off;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
root /static;
|
||||||
|
index index.html;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/src/assets//favicon.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Vite + Mantine App</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,16 +1,11 @@
|
|||||||
const nextJest = require('next/jest');
|
module.exports = {
|
||||||
|
preset: 'ts-jest',
|
||||||
const createJestConfig = nextJest({
|
testEnvironment: 'jest-environment-jsdom',
|
||||||
dir: './',
|
|
||||||
});
|
|
||||||
|
|
||||||
const customJestConfig = {
|
|
||||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
'^@/components/(.*)$': '<rootDir>/components/$1',
|
'^@test-utils': '<rootDir>/test-utils',
|
||||||
'^@/pages/(.*)$': '<rootDir>/pages/$1',
|
},
|
||||||
|
transform: {
|
||||||
|
'^.+\\.ts?$': 'ts-jest',
|
||||||
},
|
},
|
||||||
testEnvironment: 'jest-environment-jsdom',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = createJestConfig(customJestConfig);
|
|
||||||
|
|||||||
@@ -1 +1,26 @@
|
|||||||
import '@testing-library/jest-dom/extend-expect';
|
require('@testing-library/jest-dom/extend-expect');
|
||||||
|
|
||||||
|
const { getComputedStyle } = window;
|
||||||
|
window.getComputedStyle = (elt) => getComputedStyle(elt);
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: jest.fn().mockImplementation((query) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: jest.fn(),
|
||||||
|
removeListener: jest.fn(),
|
||||||
|
addEventListener: jest.fn(),
|
||||||
|
removeEventListener: jest.fn(),
|
||||||
|
dispatchEvent: jest.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
class ResizeObserver {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.ResizeObserver = ResizeObserver;
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import dayjs from "dayjs";
|
|
||||||
import relativeTime from "dayjs/plugin/relativeTime";
|
|
||||||
|
|
||||||
dayjs.extend(relativeTime);
|
|
||||||
|
|
||||||
const DayJS = dayjs();
|
|
||||||
|
|
||||||
export default DayJS;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { PrismaClient } from "@prisma/client";
|
|
||||||
|
|
||||||
export const prismaClient = new PrismaClient();
|
|
||||||
63
lib/minio.ts
@@ -1,63 +0,0 @@
|
|||||||
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();
|
|
||||||
50
migrate.ts
@@ -1,50 +0,0 @@
|
|||||||
import { createReadStream } from "node:fs";
|
|
||||||
import { createInterface } from "node:readline/promises";
|
|
||||||
|
|
||||||
import { Paragraph, PrismaClient } from "@prisma/client";
|
|
||||||
|
|
||||||
import { prismaClient } from "./lib/db";
|
|
||||||
|
|
||||||
const client = new PrismaClient();
|
|
||||||
|
|
||||||
async function processLineByLine() {
|
|
||||||
await prismaClient.$executeRaw`delete from Paragraph`;
|
|
||||||
const fileStream = createReadStream("../paragraph.json");
|
|
||||||
|
|
||||||
const rl = createInterface({
|
|
||||||
input: fileStream,
|
|
||||||
crlfDelay: Infinity,
|
|
||||||
});
|
|
||||||
|
|
||||||
let idx = 1;
|
|
||||||
|
|
||||||
let submitData: Paragraph[] = [];
|
|
||||||
|
|
||||||
for await (const line of rl) {
|
|
||||||
const data = JSON.parse(line);
|
|
||||||
idx++;
|
|
||||||
submitData.push({
|
|
||||||
id: data.id,
|
|
||||||
content: data.content,
|
|
||||||
cover: data.cover,
|
|
||||||
title: data.title,
|
|
||||||
tags: data.tags.join(","),
|
|
||||||
author: data.author,
|
|
||||||
markdown: data.markdown,
|
|
||||||
time: new Date(data.time.$date),
|
|
||||||
});
|
|
||||||
if (idx % 1000 === 0) {
|
|
||||||
console.log(idx);
|
|
||||||
await client.paragraph.createMany({
|
|
||||||
data: submitData,
|
|
||||||
});
|
|
||||||
submitData = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await client.paragraph.createMany({
|
|
||||||
data: submitData,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
processLineByLine();
|
|
||||||
5
next-env.d.ts
vendored
@@ -1,5 +0,0 @@
|
|||||||
/// <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.
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
||||||
const withBundleAnalyzer = require("@next/bundle-analyzer")({
|
|
||||||
enabled: process.env.ANALYZE === "true",
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = withBundleAnalyzer({
|
|
||||||
reactStrictMode: false,
|
|
||||||
eslint: {
|
|
||||||
ignoreDuringBuilds: true,
|
|
||||||
},
|
|
||||||
output: "standalone",
|
|
||||||
});
|
|
||||||
143
package.json
@@ -1,83 +1,94 @@
|
|||||||
{
|
{
|
||||||
"name": "mantine-next-template",
|
"name": "codesecer-ui",
|
||||||
"version": "1.0.0",
|
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "vite",
|
||||||
"build": "next build",
|
"build": "tsc && vite build",
|
||||||
"analyze": "ANALYZE=true next build",
|
"preview": "vite preview",
|
||||||
"start": "next start",
|
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"export": "next build && next export",
|
"lint": "eslint src",
|
||||||
"lint": "next lint",
|
|
||||||
"jest": "jest",
|
"jest": "jest",
|
||||||
"jest:watch": "jest --watch",
|
"jest:watch": "jest --watch",
|
||||||
"prettier:check": "prettier --check \"**/*.{ts,tsx}\"",
|
"test": "yarn typecheck && yarn lint",
|
||||||
"prettier:write": "prettier --write \"**/*.{ts,tsx}\"",
|
"storybook": "NODE_OPTIONS=--openssl-legacy-provider start-storybook -p 6006",
|
||||||
"test": "npm run prettier:check && npm run lint && npm run typecheck && npm run jest",
|
"build-storybook": "build-storybook",
|
||||||
"storybook": "start-storybook -p 7001",
|
"chromatic": "npx chromatic --project-token=180ac2186305"
|
||||||
"storybook:build": "build-storybook"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.10.4",
|
"@emotion/react": "^11.10.5",
|
||||||
"@emotion/server": "^11.10.0",
|
"@grafana/faro-react": "^1.0.2",
|
||||||
"@mantine/carousel": "6.0.0",
|
"@grafana/faro-web-tracing": "^1.0.2",
|
||||||
"@mantine/core": "6.0.0",
|
"@mantine/core": "6.0.0",
|
||||||
"@mantine/dates": "6.0.0",
|
"@mantine/form": "^6.0.0",
|
||||||
"@mantine/form": "^6.0.2",
|
"@mantine/hooks": "^6.0.0",
|
||||||
"@mantine/hooks": "6.0.0",
|
"@mantine/notifications": "^6.0.0",
|
||||||
"@mantine/next": "6.0.0",
|
"@reduxjs/toolkit": "^1.9.3",
|
||||||
"@mantine/notifications": "6.0.0",
|
"@tabler/icons-react": "^2.7.0",
|
||||||
"@mantine/prism": "6.0.0",
|
"axios": "^1.3.4",
|
||||||
"@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",
|
"dayjs": "^1.11.7",
|
||||||
"dotenv": "^16.0.3",
|
"i18next": ">=21.0.0",
|
||||||
"embla-carousel-react": "^7.0.3",
|
"i18next-browser-languagedetector": "^6.1.4",
|
||||||
"mongodb": "^5.1.0",
|
"lodash": "^4.17.21",
|
||||||
"next": "13.1.6",
|
"react": "^18.0.0",
|
||||||
"prisma": "^4.11.0",
|
"react-dom": "^18.0.0",
|
||||||
"react": "18.2.0",
|
"react-i18next": "^11.17.1",
|
||||||
"react-dom": "18.2.0",
|
"react-redux": "^8.0.5",
|
||||||
|
"react-router-dom": "^6.3.0",
|
||||||
"remark": "^14.0.2",
|
"remark": "^14.0.2",
|
||||||
"remark-html": "^15.0.2"
|
"remark-html": "^15.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.17.8",
|
"@babel/core": "^7.21.0",
|
||||||
"@next/eslint-plugin-next": "^12.1.4",
|
"@storybook/addon-actions": "^6.5.16",
|
||||||
"@storybook/react": "^6.5.13",
|
"@storybook/addon-essentials": "^6.5.16",
|
||||||
"@testing-library/dom": "^8.12.0",
|
"@storybook/addon-interactions": "^6.5.16",
|
||||||
"@testing-library/jest-dom": "^5.16.3",
|
"@storybook/addon-links": "^6.5.16",
|
||||||
"@testing-library/react": "^13.0.0",
|
"@storybook/addons": ">=6.5.0",
|
||||||
"@testing-library/user-event": "^14.0.4",
|
"@storybook/api": ">=6.5.0",
|
||||||
"@types/jest": "^27.4.1",
|
"@storybook/builder-vite": "^0.4.2",
|
||||||
"@types/node": "^18.11.4",
|
"@storybook/components": ">=6.5.0",
|
||||||
"@types/react": "18.0.21",
|
"@storybook/core-events": ">=6.5.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.30.0",
|
"@storybook/react": "^6.5.16",
|
||||||
"@typescript-eslint/parser": "^5.30.0",
|
"@storybook/testing-library": "^0.0.13",
|
||||||
"babel-loader": "^8.2.4",
|
"@storybook/theming": ">=6.5.0",
|
||||||
"eslint": "^8.18.0",
|
"@testing-library/dom": "^8.20.0",
|
||||||
"eslint-config-airbnb": "19.0.4",
|
"@testing-library/jest-dom": "^5.16.5",
|
||||||
"eslint-config-airbnb-typescript": "^17.0.0",
|
"@testing-library/react": "^13.4.0",
|
||||||
"eslint-config-mantine": "2.0.0",
|
"@testing-library/user-event": "^14.4.3",
|
||||||
"eslint-config-prettier": "^8.7.0",
|
"@types/jest": "^29.4.0",
|
||||||
"eslint-plugin-import": "^2.26.0",
|
"@types/lodash": "^4.14.191",
|
||||||
"eslint-plugin-jest": "^26.1.1",
|
"@types/react": "^18.0.27",
|
||||||
"eslint-plugin-jsx-a11y": "^6.6.0",
|
"@types/react-dom": "^18.0.10",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^5.50.0",
|
||||||
|
"@typescript-eslint/parser": "^5.50.0",
|
||||||
|
"@vitejs/plugin-react": "^3.1.0",
|
||||||
|
"babel-loader": "^8.3.0",
|
||||||
|
"chromatic": "^6.17.1",
|
||||||
|
"eslint": "^8.33.0",
|
||||||
|
"eslint-config-prettier": "^8.6.0",
|
||||||
|
"eslint-import-resolver-typescript": "^3.5.3",
|
||||||
|
"eslint-plugin-import": "^2.27.5",
|
||||||
|
"eslint-plugin-jsx-a11y": "^6.7.1",
|
||||||
"eslint-plugin-prettier": "^4.2.1",
|
"eslint-plugin-prettier": "^4.2.1",
|
||||||
"eslint-plugin-react": "^7.30.1",
|
"eslint-plugin-react": "^7.32.2",
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
"eslint-plugin-storybook": "^0.5.7",
|
"eslint-plugin-storybook": "^0.6.11",
|
||||||
"eslint-plugin-testing-library": "^5.2.0",
|
"i18next-http-backend": "^1.4.0",
|
||||||
"jest": "^27.5.1",
|
"install-peerdeps": "^3.0.3",
|
||||||
"prettier": "^2.7.1",
|
"jest": "^29.4.1",
|
||||||
"storybook-addon-turbo-build": "^1.1.0",
|
"jest-environment-jsdom": "^29.4.1",
|
||||||
"storybook-dark-mode": "^1.1.2",
|
"prettier": "^2.8.3",
|
||||||
"ts-jest": "^27.1.4",
|
"react-router": "^6.3.0",
|
||||||
"ts-node": "^10.9.1",
|
"storybook": "^6.5.16",
|
||||||
"typescript": "4.8.4"
|
"storybook-addon-react-router-v6": "0.2.1",
|
||||||
}
|
"storybook-dark-mode": "^2.1.1",
|
||||||
|
"storybook-react-i18next": "1.1.2",
|
||||||
|
"stylis-plugin-rtl": "^2.1.1",
|
||||||
|
"ts-jest": "^29.0.5",
|
||||||
|
"typescript": "^4.9.5",
|
||||||
|
"vite": "^4.1.1"
|
||||||
|
},
|
||||||
|
"readme": "ERROR: No README data found!",
|
||||||
|
"_id": "mantine-vite-template@0.0.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
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";
|
|
||||||
|
|
||||||
import { HeaderSearch } from "../components/Header/Header";
|
|
||||||
|
|
||||||
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>
|
|
||||||
<HeaderSearch />
|
|
||||||
<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",
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import Document from 'next/document';
|
|
||||||
import { createGetInitialProps } from '@mantine/next';
|
|
||||||
|
|
||||||
const getInitialProps = createGetInitialProps();
|
|
||||||
|
|
||||||
export default class _Document extends Document {
|
|
||||||
static getInitialProps = getInitialProps;
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
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 } };
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function HomePage(props: ListProps) {
|
|
||||||
return <ParagraphGrid title="DS-Next" {...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 } };
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
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="calc( 100vh - 56px )">
|
|
||||||
<Container my="2rem">
|
|
||||||
<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.split(",").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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
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;
|
|
||||||
word: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TagPage(props: ListProps) {
|
|
||||||
return <ParagraphGrid title={`DS-Next | Search ${props.word}`} {...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 word = ctx.params?.word;
|
|
||||||
|
|
||||||
if (typeof word !== "string") {
|
|
||||||
return { notFound: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
const condition = {
|
|
||||||
content: {
|
|
||||||
search: word,
|
|
||||||
},
|
|
||||||
tags: {
|
|
||||||
search: word,
|
|
||||||
},
|
|
||||||
author: {
|
|
||||||
search: word,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
search: word,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
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: { word, paragraphs, skip, take, total } };
|
|
||||||
}
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
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="2rem">
|
|
||||||
<Title>Statistic</Title>
|
|
||||||
<Group position="apart" mb="1rem">
|
|
||||||
<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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
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: {
|
|
||||||
contains: 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 } };
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
-- CreateTable
|
|
||||||
CREATE TABLE `Paragraph` (
|
|
||||||
`id` VARCHAR(191) NOT NULL,
|
|
||||||
`author` VARCHAR(191) NOT NULL,
|
|
||||||
`content` VARCHAR(191) NOT NULL,
|
|
||||||
`cover` VARCHAR(191) NULL,
|
|
||||||
`markdown` BOOLEAN NULL,
|
|
||||||
`tags` VARCHAR(191) NOT NULL,
|
|
||||||
`time` DATE NOT NULL,
|
|
||||||
`title` VARCHAR(191) NOT NULL,
|
|
||||||
|
|
||||||
INDEX `Paragraph_author_idx`(`author`),
|
|
||||||
INDEX `Paragraph_time_idx`(`time`),
|
|
||||||
INDEX `Paragraph_title_idx`(`title`),
|
|
||||||
FULLTEXT INDEX `Paragraph_content_author_title_tags_idx`(`content`, `author`, `title`, `tags`),
|
|
||||||
PRIMARY KEY (`id`)
|
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE `Paragraph` MODIFY `content` LONGTEXT NOT NULL;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE `Paragraph` MODIFY `time` DATETIME NOT NULL;
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
/*
|
|
||||||
Warnings:
|
|
||||||
|
|
||||||
- You are about to alter the column `time` on the `Paragraph` table. The data in that column could be lost. The data in that column will be cast from `DateTime(0)` to `DateTime`.
|
|
||||||
|
|
||||||
*/
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE `Paragraph` MODIFY `time` DATETIME NOT NULL;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
# Please do not edit this file manually
|
|
||||||
# It should be added in your version-control system (i.e. Git)
|
|
||||||
provider = "mysql"
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
generator client {
|
|
||||||
provider = "prisma-client-js"
|
|
||||||
previewFeatures = ["fullTextSearch", "fullTextIndex"]
|
|
||||||
}
|
|
||||||
|
|
||||||
datasource db {
|
|
||||||
provider = "mysql"
|
|
||||||
url = env("DATABASE_URL")
|
|
||||||
}
|
|
||||||
|
|
||||||
model Paragraph {
|
|
||||||
id String @id
|
|
||||||
author String
|
|
||||||
content String @db.LongText
|
|
||||||
cover String?
|
|
||||||
markdown Boolean?
|
|
||||||
tags String
|
|
||||||
time DateTime @db.DateTime
|
|
||||||
title String
|
|
||||||
|
|
||||||
@@index([author])
|
|
||||||
@@index([time(order: desc)])
|
|
||||||
@@index([title])
|
|
||||||
@@fulltext([content, author, title, tags])
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 937 B |
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 827 B |
|
Before Width: | Height: | Size: 562 B |
13
src/App.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
|
import { RouterProvider } from "react-router-dom";
|
||||||
|
|
||||||
|
import Loading from "./page/Loading";
|
||||||
|
import router from "./router";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
src/ThemeProvider.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { ColorSchemeProvider, MantineProvider } from "@mantine/core";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
import { usePreferenceState } from "./store/module/preference";
|
||||||
|
|
||||||
|
interface ThemeProviderProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: ThemeProviderProps) {
|
||||||
|
const {
|
||||||
|
state: { colorScheme },
|
||||||
|
toggleColorScheme,
|
||||||
|
} = usePreferenceState();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log(colorScheme);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ColorSchemeProvider
|
||||||
|
colorScheme={colorScheme}
|
||||||
|
toggleColorScheme={toggleColorScheme}
|
||||||
|
>
|
||||||
|
<MantineProvider
|
||||||
|
theme={{ colorScheme }}
|
||||||
|
withGlobalStyles
|
||||||
|
withNormalizeCSS
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</MantineProvider>
|
||||||
|
</ColorSchemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
BIN
src/assets/favicon.png
Normal file
|
After Width: | Height: | Size: 277 KiB |
BIN
src/assets/illustration_dark.png
Normal file
|
After Width: | Height: | Size: 466 KiB |
BIN
src/assets/illustration_light.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
@@ -1,5 +1,5 @@
|
|||||||
import { ActionIcon, Group, useMantineColorScheme } from "@mantine/core";
|
import { ActionIcon, Group, useMantineColorScheme } from "@mantine/core";
|
||||||
import { IconMoonStars, IconSun } from "@tabler/icons";
|
import { IconMoonStars, IconSun } from "@tabler/icons-react";
|
||||||
|
|
||||||
export function ColorSchemeToggle() {
|
export function ColorSchemeToggle() {
|
||||||
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
|
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
|
||||||
@@ -11,8 +11,13 @@ export function ColorSchemeToggle() {
|
|||||||
size="xl"
|
size="xl"
|
||||||
sx={(theme) => ({
|
sx={(theme) => ({
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
theme.colorScheme === "dark" ? theme.colors.dark[6] : theme.colors.gray[0],
|
theme.colorScheme === "dark"
|
||||||
color: theme.colorScheme === "dark" ? theme.colors.yellow[4] : theme.colors.blue[6],
|
? theme.colors.dark[6]
|
||||||
|
: theme.colors.gray[0],
|
||||||
|
color:
|
||||||
|
theme.colorScheme === "dark"
|
||||||
|
? theme.colors.yellow[4]
|
||||||
|
: theme.colors.blue[6],
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{colorScheme === "dark" ? (
|
{colorScheme === "dark" ? (
|
||||||
101
src/component/Header/Header.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Group,
|
||||||
|
Header,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
createStyles,
|
||||||
|
rem,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useForm } from "@mantine/form";
|
||||||
|
import { useDocumentTitle } from "@mantine/hooks";
|
||||||
|
import { IconSearch, IconSettings } from "@tabler/icons-react";
|
||||||
|
import { Dispatch, SetStateAction, createContext, useContext } from "react";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
|
||||||
|
const useStyles = createStyles((theme) => ({
|
||||||
|
header: {
|
||||||
|
paddingLeft: theme.spacing.md,
|
||||||
|
paddingRight: theme.spacing.md,
|
||||||
|
},
|
||||||
|
|
||||||
|
inner: {
|
||||||
|
height: rem(56),
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
|
||||||
|
link: {
|
||||||
|
display: "block",
|
||||||
|
lineHeight: 1,
|
||||||
|
padding: `${rem(8)} ${rem(12)}`,
|
||||||
|
borderRadius: theme.radius.sm,
|
||||||
|
textDecoration: "none",
|
||||||
|
color:
|
||||||
|
theme.colorScheme === "dark"
|
||||||
|
? theme.colors.dark[0]
|
||||||
|
: theme.colors.gray[7],
|
||||||
|
fontSize: theme.fontSizes.sm,
|
||||||
|
fontWeight: 500,
|
||||||
|
|
||||||
|
"&:hover": {
|
||||||
|
backgroundColor:
|
||||||
|
theme.colorScheme === "dark"
|
||||||
|
? theme.colors.dark[6]
|
||||||
|
: theme.colors.gray[0],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const TitleContext = createContext<
|
||||||
|
[string, Dispatch<SetStateAction<string>>]
|
||||||
|
>(["DS-Next", () => 0]);
|
||||||
|
|
||||||
|
export function HeaderSearch() {
|
||||||
|
const { classes } = useStyles();
|
||||||
|
const [title, _] = useContext(TitleContext);
|
||||||
|
|
||||||
|
useDocumentTitle(title);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: {
|
||||||
|
search: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function submit({ search }: { search: string }) {
|
||||||
|
console.log(search);
|
||||||
|
navigate(`/search/${encodeURIComponent(search)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Header height={56} className={classes.header}>
|
||||||
|
<div className={classes.inner}>
|
||||||
|
<span>
|
||||||
|
<Text weight={600} component="a" href="/">
|
||||||
|
DS-Next
|
||||||
|
</Text>
|
||||||
|
<Text weight={600} component="span">
|
||||||
|
{" | "}
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
</span>
|
||||||
|
<Group>
|
||||||
|
<form onSubmit={form.onSubmit(submit)}>
|
||||||
|
<TextInput
|
||||||
|
placeholder="Search"
|
||||||
|
icon={<IconSearch size="1rem" stroke={1.5} />}
|
||||||
|
{...form.getInputProps("search")}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
<ActionIcon component="a" href="/settings">
|
||||||
|
<IconSettings />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
</div>
|
||||||
|
</Header>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
src/component/ParagraphCard/ParagraphCard.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { Card, Group, Image, Text, createStyles } from "@mantine/core";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import relativeTime from "dayjs/plugin/relativeTime";
|
||||||
|
|
||||||
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
|
const useStyles = createStyles((theme) => ({
|
||||||
|
card: {
|
||||||
|
backgroundColor:
|
||||||
|
theme.colorScheme === "dark" ? theme.colors.dark[7] : theme.white,
|
||||||
|
},
|
||||||
|
|
||||||
|
title: {
|
||||||
|
fontWeight: 700,
|
||||||
|
fontFamily: `Greycliff CF, ${theme.fontFamily}`,
|
||||||
|
lineHeight: 1.2,
|
||||||
|
},
|
||||||
|
|
||||||
|
body: {
|
||||||
|
padding: theme.spacing.md,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export function ParagraphCard({
|
||||||
|
cover,
|
||||||
|
title,
|
||||||
|
"@timestamp": time,
|
||||||
|
author,
|
||||||
|
tags,
|
||||||
|
_id,
|
||||||
|
}: Paragraph) {
|
||||||
|
const { classes } = useStyles();
|
||||||
|
const url = `/paragraph/${_id}`;
|
||||||
|
return (
|
||||||
|
<Card withBorder radius="md" p={0} className={classes.card}>
|
||||||
|
<Group noWrap spacing={0}>
|
||||||
|
<a href={url}>
|
||||||
|
{cover && <Image src={cover} height={140} width={140} />}
|
||||||
|
</a>
|
||||||
|
<div className={classes.body}>
|
||||||
|
<Text transform="uppercase" color="dimmed" weight={700} size="xs">
|
||||||
|
{tags.map((tag, index) => (
|
||||||
|
<>
|
||||||
|
{index > 0 && " • "}
|
||||||
|
<Text
|
||||||
|
component="a"
|
||||||
|
key={index}
|
||||||
|
href={`/tag/${encodeURIComponent(tag)}`}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
className={classes.title}
|
||||||
|
mt="xs"
|
||||||
|
mb="md"
|
||||||
|
component="a"
|
||||||
|
href={url}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Group noWrap spacing="xs">
|
||||||
|
<Group spacing="xs" noWrap>
|
||||||
|
<Text
|
||||||
|
size="xs"
|
||||||
|
component="a"
|
||||||
|
href={`/author/${encodeURIComponent(author)}`}
|
||||||
|
>
|
||||||
|
{author}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Text size="xs" color="dimmed">
|
||||||
|
•
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" color="dimmed">
|
||||||
|
{dayjs().to(dayjs(time))}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
src/component/Settings/Theme.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Center,
|
||||||
|
SegmentedControl,
|
||||||
|
useMantineColorScheme,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { IconMoon, IconSun } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
export function ThemeSetting() {
|
||||||
|
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SegmentedControl
|
||||||
|
value={colorScheme}
|
||||||
|
onChange={(value: "light" | "dark") => toggleColorScheme(value)}
|
||||||
|
data={[
|
||||||
|
{
|
||||||
|
value: "light",
|
||||||
|
label: (
|
||||||
|
<Center>
|
||||||
|
<IconSun size="1rem" stroke={1.5} />
|
||||||
|
<Box ml={10}>Light</Box>
|
||||||
|
</Center>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "dark",
|
||||||
|
label: (
|
||||||
|
<Center>
|
||||||
|
<IconMoon size="1rem" stroke={1.5} />
|
||||||
|
<Box ml={10}>Dark</Box>
|
||||||
|
</Center>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
src/helper/api.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import axios, { AxiosResponse } from "axios";
|
||||||
|
|
||||||
|
export interface PaginationParams {
|
||||||
|
skip?: number;
|
||||||
|
take?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
auth: {
|
||||||
|
username: "viewer",
|
||||||
|
password: "publicviewer1",
|
||||||
|
},
|
||||||
|
baseURL: "https://api.ourdomain.com",
|
||||||
|
});
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(value: AxiosResponse<any, any>) => {
|
||||||
|
if (value.data.error) {
|
||||||
|
notifications.show({
|
||||||
|
title: "API Error on " + value.config.url,
|
||||||
|
message: value.data.error,
|
||||||
|
color: "red",
|
||||||
|
autoClose: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
(error: any) => {
|
||||||
|
const value: AxiosResponse<any, any> = error.response;
|
||||||
|
if (value.data.status !== 200) {
|
||||||
|
notifications.show({
|
||||||
|
title: "API Error on " + value.config.url,
|
||||||
|
message: JSON.stringify(value.data),
|
||||||
|
color: "red",
|
||||||
|
autoClose: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export class SearchApi {
|
||||||
|
static async search(
|
||||||
|
baseUrl: string,
|
||||||
|
query: ZincQueryForSDK
|
||||||
|
): Promise<SearchResponse> {
|
||||||
|
const { data } = await api.post(
|
||||||
|
new URL("/api/paragraph/_search", baseUrl).toString(),
|
||||||
|
query
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
static wrapParagraph(s3Url: string, paragraph: Paragraph) {
|
||||||
|
const RE = /https:\/\/s3\.yoshino-s\.xyz/g;
|
||||||
|
if (paragraph.cover) {
|
||||||
|
paragraph.cover = paragraph.cover.replace(RE, s3Url);
|
||||||
|
}
|
||||||
|
paragraph.content = paragraph.content?.replace(RE, s3Url);
|
||||||
|
|
||||||
|
return paragraph;
|
||||||
|
}
|
||||||
|
static async getParagraph(baseUrl: string, id: string) {
|
||||||
|
const { data } = await api.get(
|
||||||
|
new URL(`/api/paragraph/_doc/${id}`, baseUrl).toString()
|
||||||
|
);
|
||||||
|
return data._source;
|
||||||
|
}
|
||||||
|
}
|
||||||
71
src/helper/hooks.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { Pagination } from "@mantine/core";
|
||||||
|
import { merge } from "lodash";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
|
import { SearchApi } from "./api";
|
||||||
|
|
||||||
|
import { useOptionsState } from "@/store/module/options";
|
||||||
|
|
||||||
|
export function usePaginationData<T>(query: ZincQueryForSDK) {
|
||||||
|
const [params, setParams] = useSearchParams({
|
||||||
|
page: "1",
|
||||||
|
size: "10",
|
||||||
|
});
|
||||||
|
const { state: options } = useOptionsState();
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [data, setData] = useState<T[]>([]);
|
||||||
|
const [skip, setSkip] = useState(0);
|
||||||
|
const [take, _] = useState(parseInt(params.get("size") || "10"));
|
||||||
|
const [page, setPage] = useState(parseInt(params.get("page") || "1"));
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
SearchApi.search(
|
||||||
|
options.zincsearchUrl,
|
||||||
|
merge({}, query, {
|
||||||
|
from: skip,
|
||||||
|
max_results: take,
|
||||||
|
})
|
||||||
|
).then((resp) => {
|
||||||
|
setTotal(resp.hits.total.value);
|
||||||
|
setData(
|
||||||
|
resp.hits.hits.map((hit) =>
|
||||||
|
SearchApi.wrapParagraph(
|
||||||
|
options.s3Url,
|
||||||
|
merge({ _id: hit._id }, hit._source)
|
||||||
|
)
|
||||||
|
) as T[]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, [skip, take]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("set params");
|
||||||
|
setParams({
|
||||||
|
size: take.toString(),
|
||||||
|
page: page.toString(),
|
||||||
|
});
|
||||||
|
}, [take, page]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSkip((page - 1) * take);
|
||||||
|
}, [page]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
data,
|
||||||
|
page,
|
||||||
|
refresh,
|
||||||
|
pagination: (
|
||||||
|
<Pagination
|
||||||
|
total={Math.ceil(total / take)}
|
||||||
|
onChange={setPage}
|
||||||
|
value={page}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
41
src/layout/MainLayout.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Box, Container, Flex, ScrollArea, createStyles } from "@mantine/core";
|
||||||
|
import { Suspense, useState } from "react";
|
||||||
|
import { Outlet } from "react-router";
|
||||||
|
|
||||||
|
import { HeaderSearch, TitleContext } from "@/component/Header/Header";
|
||||||
|
import Loading from "@/page/Loading";
|
||||||
|
|
||||||
|
const useStyles = createStyles((theme) => ({
|
||||||
|
contentContainer: {
|
||||||
|
backgroundColor:
|
||||||
|
theme.colorScheme === "dark" ? theme.colors.dark[7] : theme.white,
|
||||||
|
width: "100vw",
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
rootContainer: {
|
||||||
|
height: "100vh",
|
||||||
|
width: "100vw",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default function MainLayout() {
|
||||||
|
const { classes } = useStyles();
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TitleContext.Provider value={[title, setTitle]}>
|
||||||
|
<Flex direction="column" className={classes.rootContainer}>
|
||||||
|
<HeaderSearch />
|
||||||
|
<Box className={classes.contentContainer}>
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<ScrollArea h="100%">
|
||||||
|
<Container>
|
||||||
|
<Outlet />
|
||||||
|
</Container>
|
||||||
|
</ScrollArea>
|
||||||
|
</Suspense>
|
||||||
|
</Box>
|
||||||
|
</Flex>
|
||||||
|
</TitleContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
src/main.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { Notifications } from "@mantine/notifications";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { Provider } from "react-redux";
|
||||||
|
|
||||||
|
import App from "./App";
|
||||||
|
import { ThemeProvider } from "./ThemeProvider";
|
||||||
|
import store from "./store";
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<Provider store={store}>
|
||||||
|
<ThemeProvider>
|
||||||
|
<Notifications />
|
||||||
|
<App />
|
||||||
|
</ThemeProvider>
|
||||||
|
</Provider>
|
||||||
|
);
|
||||||
106
src/page/Exception.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Center,
|
||||||
|
Container,
|
||||||
|
createStyles,
|
||||||
|
Group,
|
||||||
|
rem,
|
||||||
|
Text,
|
||||||
|
Title,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useDocumentTitle } from "@mantine/hooks";
|
||||||
|
import { useNavigate, useRouteError } from "react-router-dom";
|
||||||
|
|
||||||
|
const useStyles = createStyles((theme) => ({
|
||||||
|
root: {
|
||||||
|
backgroundColor: theme.fn.variant({
|
||||||
|
variant: "filled",
|
||||||
|
color: theme.primaryColor,
|
||||||
|
}).background,
|
||||||
|
minHeight: "100vh",
|
||||||
|
},
|
||||||
|
|
||||||
|
label: {
|
||||||
|
textAlign: "center",
|
||||||
|
fontWeight: 900,
|
||||||
|
fontSize: rem(220),
|
||||||
|
lineHeight: 1,
|
||||||
|
marginBottom: `calc(${theme.spacing.xl} * 1.5)`,
|
||||||
|
color: theme.colors[theme.primaryColor][3],
|
||||||
|
|
||||||
|
[theme.fn.smallerThan("sm")]: {
|
||||||
|
fontSize: rem(120),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
title: {
|
||||||
|
fontFamily: `Greycliff CF, ${theme.fontFamily}`,
|
||||||
|
textAlign: "center",
|
||||||
|
fontWeight: 900,
|
||||||
|
fontSize: rem(38),
|
||||||
|
color: theme.white,
|
||||||
|
|
||||||
|
[theme.fn.smallerThan("sm")]: {
|
||||||
|
fontSize: rem(32),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
description: {
|
||||||
|
maxWidth: rem(540),
|
||||||
|
margin: "auto",
|
||||||
|
marginTop: theme.spacing.xl,
|
||||||
|
marginBottom: `calc(${theme.spacing.xl} * 1.5)`,
|
||||||
|
color: theme.colors[theme.primaryColor][1],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export interface ErrorPageProps {
|
||||||
|
label?: string | null;
|
||||||
|
title?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ErrorPage(props: ErrorPageProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const { classes } = useStyles();
|
||||||
|
|
||||||
|
const error = useRouteError();
|
||||||
|
|
||||||
|
useDocumentTitle(props.label ?? "Error");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Center className={classes.root}>
|
||||||
|
<Container>
|
||||||
|
<div className={classes.label}>{props.label ?? "Error"}</div>
|
||||||
|
<Title className={classes.title}>
|
||||||
|
{props.title ?? "An error occurred"}
|
||||||
|
</Title>
|
||||||
|
<Text size="lg" align="center" className={classes.description}>
|
||||||
|
{props.description ??
|
||||||
|
error?.toString?.() ??
|
||||||
|
"An error occurred while loading the page."}
|
||||||
|
</Text>
|
||||||
|
<Group position="center">
|
||||||
|
<Button variant="white" size="md" onClick={() => navigate(0)}>
|
||||||
|
Refresh
|
||||||
|
</Button>{" "}
|
||||||
|
or
|
||||||
|
<Button variant="white" size="md" onClick={() => navigate(-1)}>
|
||||||
|
Go Back
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Container>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotFoundPage() {
|
||||||
|
return (
|
||||||
|
<ErrorPage
|
||||||
|
label="404"
|
||||||
|
title="Page not found"
|
||||||
|
description="The page you are looking for might have been removed, had its name changed, or is temporarily unavailable."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
src/page/Loading.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { createStyles, LoadingOverlay } from "@mantine/core";
|
||||||
|
import { useDocumentTitle } from "@mantine/hooks";
|
||||||
|
|
||||||
|
const useStyles = createStyles((theme) => ({
|
||||||
|
root: {
|
||||||
|
height: "100vh",
|
||||||
|
backgroundColor: theme.fn.variant({
|
||||||
|
variant: "filled",
|
||||||
|
color: theme.primaryColor,
|
||||||
|
}).background,
|
||||||
|
fontSize: theme.fontSizes.xl,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
const { classes } = useStyles();
|
||||||
|
useDocumentTitle("Loading");
|
||||||
|
return (
|
||||||
|
<div className={classes.root}>
|
||||||
|
<LoadingOverlay visible />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
src/page/Paragraph.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Container,
|
||||||
|
Group,
|
||||||
|
ScrollArea,
|
||||||
|
Text,
|
||||||
|
Title,
|
||||||
|
TypographyStylesProvider,
|
||||||
|
UnstyledButton,
|
||||||
|
createStyles,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import relativeTime from "dayjs/plugin/relativeTime";
|
||||||
|
import { useLoaderData } from "react-router";
|
||||||
|
|
||||||
|
function stripStyles(content: string) {
|
||||||
|
const element = document.createElement("div");
|
||||||
|
element.innerHTML = content;
|
||||||
|
element.querySelectorAll("*").forEach((el) => {
|
||||||
|
if (!(el instanceof HTMLElement)) return;
|
||||||
|
[
|
||||||
|
"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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return element.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useStyles = createStyles(() => ({
|
||||||
|
paragraph: {
|
||||||
|
lineBreak: "anywhere",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
|
export default function ParagraphPage() {
|
||||||
|
const { classes } = useStyles();
|
||||||
|
|
||||||
|
const paragraph = useLoaderData() as Paragraph;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollArea h="calc( 100vh - 56px )">
|
||||||
|
<Container my="2rem">
|
||||||
|
<Title mb="xl">{paragraph.title}</Title>
|
||||||
|
<Group position="apart">
|
||||||
|
<Group>
|
||||||
|
<Text c="dimmed"> {dayjs().to(dayjs(paragraph.time))}</Text>
|
||||||
|
<UnstyledButton
|
||||||
|
component="a"
|
||||||
|
href={`/author/${encodeURIComponent(
|
||||||
|
paragraph.author || "unknown"
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
<Badge ml="1rem" radius="sm">
|
||||||
|
{paragraph.author}
|
||||||
|
</Badge>
|
||||||
|
</UnstyledButton>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
<Group mb="xl">
|
||||||
|
{paragraph.tags?.map((tag) => (
|
||||||
|
<UnstyledButton
|
||||||
|
key={tag}
|
||||||
|
component="a"
|
||||||
|
href={`/tag/${encodeURIComponent(tag)}`}
|
||||||
|
>
|
||||||
|
<Badge fz="xs" variant="dot">
|
||||||
|
{tag}
|
||||||
|
</Badge>
|
||||||
|
</UnstyledButton>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
<TypographyStylesProvider>
|
||||||
|
<div
|
||||||
|
className={classes.paragraph}
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: stripStyles(paragraph.content),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TypographyStylesProvider>
|
||||||
|
</Container>
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
src/page/Search.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { Grid, Group } from "@mantine/core";
|
||||||
|
import { merge } from "lodash";
|
||||||
|
import { useContext, useEffect } from "react";
|
||||||
|
import { useLoaderData, useLocation, useParams } from "react-router";
|
||||||
|
|
||||||
|
import { ParagraphCard } from "../component/ParagraphCard/ParagraphCard";
|
||||||
|
|
||||||
|
import { TitleContext } from "@/component/Header/Header";
|
||||||
|
import { usePaginationData } from "@/helper/hooks";
|
||||||
|
|
||||||
|
export interface SearchPageProps {
|
||||||
|
query?: ZincQueryForSDK;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SearchPage(props: SearchPageProps) {
|
||||||
|
const [_, setTitle] = useContext(TitleContext);
|
||||||
|
|
||||||
|
const params = useLoaderData();
|
||||||
|
|
||||||
|
const query: ZincQueryForSDK = merge(
|
||||||
|
{
|
||||||
|
search_type: "matchall",
|
||||||
|
sort_fields: ["-@timestamp"],
|
||||||
|
_source: ["title", "cover", "author", "tags"],
|
||||||
|
},
|
||||||
|
props.query,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
page,
|
||||||
|
pagination,
|
||||||
|
refresh,
|
||||||
|
data: paragraphs,
|
||||||
|
} = usePaginationData<Paragraph>(query);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("refresh");
|
||||||
|
refresh();
|
||||||
|
}, [params]);
|
||||||
|
|
||||||
|
const location = useLocation();
|
||||||
|
const param = useParams();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let action = "Index";
|
||||||
|
if (location.pathname.startsWith("/search/")) {
|
||||||
|
action = "Search";
|
||||||
|
} else if (location.pathname.startsWith("/tag/")) {
|
||||||
|
action = `Tag ${param.tag}`;
|
||||||
|
} else if (location.pathname.startsWith("/author/")) {
|
||||||
|
action = `Author ${param.author}`;
|
||||||
|
}
|
||||||
|
const title = `${action} Page 1`;
|
||||||
|
setTitle(title);
|
||||||
|
}, [page]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Grid my="md">
|
||||||
|
{paragraphs.map((paragraph) => {
|
||||||
|
return (
|
||||||
|
<Grid.Col xs={12} sm={6} key={paragraph._id}>
|
||||||
|
<ParagraphCard {...paragraph} />
|
||||||
|
</Grid.Col>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Grid>
|
||||||
|
<Group position="center">{pagination}</Group>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
src/page/Settings.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import {
|
||||||
|
Container,
|
||||||
|
createStyles,
|
||||||
|
Paper,
|
||||||
|
Table,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
Title,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { ReactNode, useContext, useEffect } from "react";
|
||||||
|
|
||||||
|
import { TitleContext } from "@/component/Header/Header";
|
||||||
|
import { ThemeSetting } from "@/component/Settings/Theme";
|
||||||
|
import store from "@/store";
|
||||||
|
import { useOptionsState } from "@/store/module/options";
|
||||||
|
import { setS3Url, setZincsearchUrl } from "@/store/reducer/options";
|
||||||
|
|
||||||
|
const useStyles = createStyles((theme) => ({
|
||||||
|
settingTable: {
|
||||||
|
tableLayout: "fixed",
|
||||||
|
"& thead": {
|
||||||
|
backgroundColor:
|
||||||
|
theme.colorScheme === "dark"
|
||||||
|
? theme.colors.dark[7]
|
||||||
|
: theme.colors.gray[3],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface SettingItem {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
value: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const { classes } = useStyles();
|
||||||
|
const [_, setTitle] = useContext(TitleContext);
|
||||||
|
const { state: options } = useOptionsState();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTitle("Settings");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const settings: SettingItem[] = [
|
||||||
|
{
|
||||||
|
title: "Theme",
|
||||||
|
description: "Change the theme of your UI",
|
||||||
|
value: <ThemeSetting />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Minio URL",
|
||||||
|
description: "The URL of your Minio instance",
|
||||||
|
value: (
|
||||||
|
<TextInput
|
||||||
|
value={options.s3Url}
|
||||||
|
onChange={(e) => {
|
||||||
|
store.dispatch(setS3Url(e.currentTarget.value));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Zincsearch URL",
|
||||||
|
description: "The URL of your Zincsearch instance",
|
||||||
|
value: (
|
||||||
|
<TextInput
|
||||||
|
value={options.zincsearchUrl}
|
||||||
|
onChange={(e) => {
|
||||||
|
store.dispatch(setZincsearchUrl(e.currentTarget.value));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Title mt="lg" order={1}>
|
||||||
|
Settings
|
||||||
|
</Title>
|
||||||
|
Customize the look and feel of your Coder deployment.
|
||||||
|
<Paper my="xl" radius="md" withBorder style={{ overflow: "hidden" }}>
|
||||||
|
<Table verticalSpacing="lg" className={classes.settingTable} striped>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Value</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{settings.map((setting) => (
|
||||||
|
<tr key={`${setting.title}`}>
|
||||||
|
<td>
|
||||||
|
<div>
|
||||||
|
<Text size="md" weight={500}>
|
||||||
|
{setting.title}
|
||||||
|
</Text>
|
||||||
|
<Text color="dimmed" size="sm">
|
||||||
|
{setting.description}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{setting.value}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
</Paper>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
src/router/index.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { lazy } from "react";
|
||||||
|
import { createBrowserRouter } from "react-router-dom";
|
||||||
|
import { remark } from "remark";
|
||||||
|
import remarkHtml from "remark-html";
|
||||||
|
|
||||||
|
import { SearchApi } from "@/helper/api";
|
||||||
|
import MainLayout from "@/layout/MainLayout";
|
||||||
|
import SearchPage from "@/page/Search";
|
||||||
|
import store from "@/store";
|
||||||
|
|
||||||
|
const NotFound = lazy(async () => ({
|
||||||
|
default: (await import("@/page/Exception")).NotFoundPage,
|
||||||
|
}));
|
||||||
|
const ErrorPage = lazy(() => import("@/page/Exception"));
|
||||||
|
const LoadingPage = lazy(async () => import("@/page/Loading"));
|
||||||
|
const ParagraphPage = lazy(async () => import("@/page/Paragraph"));
|
||||||
|
const SettingsPage = lazy(async () => import("@/page/Settings"));
|
||||||
|
|
||||||
|
const router = createBrowserRouter([
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
element: <MainLayout />,
|
||||||
|
errorElement: <ErrorPage />,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
element: <SearchPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/settings",
|
||||||
|
element: <SettingsPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/tag/:tag",
|
||||||
|
element: <SearchPage />,
|
||||||
|
loader({ params: { tag } }) {
|
||||||
|
if (!tag) {
|
||||||
|
return { redirect: "/" };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
search_type: "querystring",
|
||||||
|
query: {
|
||||||
|
term: `tags:${JSON.stringify(tag)}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/author/:author",
|
||||||
|
element: <SearchPage />,
|
||||||
|
loader({ params: { author } }) {
|
||||||
|
if (!author) {
|
||||||
|
return { redirect: "/" };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
search_type: "querystring",
|
||||||
|
query: {
|
||||||
|
term: `author:${JSON.stringify(author)}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/search/:search",
|
||||||
|
element: <SearchPage />,
|
||||||
|
loader({ params: { search } }) {
|
||||||
|
if (!search) {
|
||||||
|
return { redirect: "/" };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
search_type: "querystring",
|
||||||
|
query: {
|
||||||
|
term: search,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/paragraph/:id",
|
||||||
|
element: <ParagraphPage />,
|
||||||
|
async loader({ params: { id } }) {
|
||||||
|
if (!id) {
|
||||||
|
return { redirect: "/" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const paragraph = await SearchApi.getParagraph(
|
||||||
|
store.getState().options.zincsearchUrl,
|
||||||
|
id
|
||||||
|
).then((p) =>
|
||||||
|
SearchApi.wrapParagraph(store.getState().options.s3Url, p)
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(paragraph.markdown);
|
||||||
|
if (paragraph.markdown) {
|
||||||
|
paragraph.content = (
|
||||||
|
await remark().use(remarkHtml).process(paragraph.content)
|
||||||
|
).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return paragraph;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/loading",
|
||||||
|
element: <LoadingPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "*",
|
||||||
|
element: <NotFound />,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default router;
|
||||||
37
src/store/index.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { configureStore, Middleware } from "@reduxjs/toolkit";
|
||||||
|
import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
|
||||||
|
|
||||||
|
import optionsReducer from "./reducer/options";
|
||||||
|
import preferenceReducer from "./reducer/preference";
|
||||||
|
|
||||||
|
const localStorageMiddleware: Middleware = ({ getState }) => {
|
||||||
|
return (next) => (action) => {
|
||||||
|
const result = next(action);
|
||||||
|
localStorage.setItem("applicationState", JSON.stringify(getState()));
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const reHydrateStore = () => {
|
||||||
|
if (localStorage.getItem("applicationState") !== null) {
|
||||||
|
return JSON.parse(localStorage.getItem("applicationState") ?? "{}"); // re-hydrate the store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const store = configureStore({
|
||||||
|
reducer: {
|
||||||
|
options: optionsReducer,
|
||||||
|
preference: preferenceReducer,
|
||||||
|
},
|
||||||
|
middleware: (getDefaultMiddleware) =>
|
||||||
|
getDefaultMiddleware().concat(localStorageMiddleware),
|
||||||
|
preloadedState: reHydrateStore(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type AppState = ReturnType<typeof store.getState>;
|
||||||
|
type AppDispatch = typeof store.dispatch;
|
||||||
|
|
||||||
|
export const useAppSelector: TypedUseSelectorHook<AppState> = useSelector;
|
||||||
|
export const useAppDispatch: () => AppDispatch = useDispatch;
|
||||||
|
|
||||||
|
export default store;
|
||||||
11
src/store/module/options.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import store, { useAppSelector } from "..";
|
||||||
|
|
||||||
|
export const useOptionsState = () => {
|
||||||
|
const state = useAppSelector((state) => state.options);
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
getState: () => {
|
||||||
|
return store.getState().options;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
14
src/store/module/preference.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import store, { useAppSelector } from "..";
|
||||||
|
import { toggleColorScheme as toggleColorScheme_ } from "../reducer/preference";
|
||||||
|
|
||||||
|
function toggleColorScheme() {
|
||||||
|
store.dispatch(toggleColorScheme_());
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePreferenceState = () => {
|
||||||
|
const state = useAppSelector((state) => state.preference);
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
toggleColorScheme,
|
||||||
|
};
|
||||||
|
};
|
||||||
27
src/store/reducer/options.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { PayloadAction, createSlice } from "@reduxjs/toolkit";
|
||||||
|
|
||||||
|
export interface OptionsState {
|
||||||
|
zincsearchUrl: string;
|
||||||
|
s3Url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const optionsSlice = createSlice({
|
||||||
|
name: "stats",
|
||||||
|
initialState: {
|
||||||
|
zincsearchUrl: "https://zincsearch.k8s.yoshino-s.xyz",
|
||||||
|
s3Url: "https://s3.yoshino-s.xyz",
|
||||||
|
} as OptionsState,
|
||||||
|
reducers: {
|
||||||
|
setZincsearchUrl: (state, action: PayloadAction<string | undefined>) => {
|
||||||
|
state.zincsearchUrl =
|
||||||
|
action.payload ?? "https://zincsearch.k8s.yoshino-s.xyz";
|
||||||
|
},
|
||||||
|
setS3Url: (state, action: PayloadAction<string | undefined>) => {
|
||||||
|
state.s3Url = action.payload ?? "https://s3.yoshino-s.xyz";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { setS3Url, setZincsearchUrl } = optionsSlice.actions;
|
||||||
|
|
||||||
|
export default optionsSlice.reducer;
|
||||||
33
src/store/reducer/preference.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||||
|
|
||||||
|
interface PreferenceState {
|
||||||
|
colorScheme: "light" | "dark";
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferenceSlice = createSlice({
|
||||||
|
name: "preference",
|
||||||
|
initialState: {
|
||||||
|
colorScheme: localStorage.getItem("colorScheme") || "light",
|
||||||
|
} as PreferenceState,
|
||||||
|
reducers: {
|
||||||
|
setColorScheme(state, action: PayloadAction<"light" | "dark">) {
|
||||||
|
localStorage.setItem("colorScheme", action.payload);
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
theme: action.payload,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
toggleColorScheme(state) {
|
||||||
|
const colorScheme = state.colorScheme === "light" ? "dark" : "light";
|
||||||
|
localStorage.setItem("colorScheme", colorScheme);
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
colorScheme,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { setColorScheme, toggleColorScheme } = preferenceSlice.actions;
|
||||||
|
|
||||||
|
export default preferenceSlice.reducer;
|
||||||
11
src/types/paragraph.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
declare interface Paragraph {
|
||||||
|
_id: string;
|
||||||
|
"@timestamp": string;
|
||||||
|
content: string;
|
||||||
|
markdown: string;
|
||||||
|
title: string;
|
||||||
|
author: string;
|
||||||
|
cover: string;
|
||||||
|
time: string;
|
||||||
|
tags: string[];
|
||||||
|
}
|
||||||
43
src/types/search.d.ts
vendored
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
declare interface ZincQueryForSDK {
|
||||||
|
_source?: boolean | string[];
|
||||||
|
explain?: boolean;
|
||||||
|
from?: number;
|
||||||
|
max_results?: number;
|
||||||
|
search_type?:
|
||||||
|
| "matchall"
|
||||||
|
| "alldocument"
|
||||||
|
| "match"
|
||||||
|
| "matchphrase"
|
||||||
|
| "querystring"
|
||||||
|
| "prefix"
|
||||||
|
| "wildcard"
|
||||||
|
| "fuzzy"
|
||||||
|
| "datarange";
|
||||||
|
sort_fields?: string[];
|
||||||
|
query?: {
|
||||||
|
boost?: number;
|
||||||
|
end_time?: string;
|
||||||
|
field?: string;
|
||||||
|
start_time?: string;
|
||||||
|
term?: string;
|
||||||
|
terms: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
declare interface SearchResponse {
|
||||||
|
error?: string;
|
||||||
|
hits: {
|
||||||
|
max_score: number;
|
||||||
|
total: {
|
||||||
|
value: number;
|
||||||
|
};
|
||||||
|
hits: {
|
||||||
|
_source: Paragraph;
|
||||||
|
_index: string;
|
||||||
|
_type: string;
|
||||||
|
_id: string;
|
||||||
|
_score: string;
|
||||||
|
"@timestamp": string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
}
|
||||||
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -1,35 +1,24 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "es5",
|
"target": "ESNext",
|
||||||
"lib": [
|
"useDefineForClassFields": true,
|
||||||
"dom",
|
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||||
"dom.iterable",
|
"allowJs": false,
|
||||||
"esnext"
|
|
||||||
],
|
|
||||||
"allowJs": true,
|
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": false,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"noEmit": true,
|
"module": "ESNext",
|
||||||
"esModuleInterop": true,
|
"moduleResolution": "Node",
|
||||||
"module": "esnext",
|
|
||||||
"moduleResolution": "node",
|
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "preserve",
|
"noEmit": true,
|
||||||
"incremental": true
|
"jsx": "react-jsx",
|
||||||
},
|
"paths": {
|
||||||
"exclude": [
|
"@test-utils": ["./test-utils"],
|
||||||
"node_modules"
|
"@/*": ["./src/*"]
|
||||||
],
|
|
||||||
"include": [
|
|
||||||
"next-env.d.ts",
|
|
||||||
"**/*.ts",
|
|
||||||
"**/*.tsx"
|
|
||||||
],
|
|
||||||
"ts-node": {
|
|
||||||
"compilerOptions": {
|
|
||||||
"module": "commonjs"
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
}
|
}
|
||||||
8
tsconfig.node.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "node"
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
13
vite.config.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { resolve } from "path";
|
||||||
|
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@/": `${resolve(__dirname, "src")}/`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||