This commit is contained in:
2023-04-19 13:33:07 +00:00
parent b76d5599d8
commit af432a7bfb
89 changed files with 5801 additions and 3322 deletions

View File

@@ -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
View 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
View File

@@ -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
View File

@@ -0,0 +1 @@
*.js

View File

@@ -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
View File

@@ -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?

View File

@@ -1 +0,0 @@
module.exports = require('eslint-config-mantine/.prettierrc.js');

View File

@@ -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
View 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
}
}

View File

@@ -0,0 +1,3 @@
<script>
window.global = window;
</script>

View File

@@ -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>];

View File

@@ -12,6 +12,10 @@
}, },
"cSpell.words": [ "cSpell.words": [
"mantine", "mantine",
"MINIO" "MINIO",
"zincsearch"
],
"i18n-ally.localesPaths": [
"src/locales"
] ]
} }

View File

@@ -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"]

View File

@@ -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

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -1,7 +0,0 @@
import { Welcome } from './Welcome';
export default {
title: 'Welcome',
};
export const Usage = () => <Welcome />;

View File

@@ -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,
},
},
}));

View File

@@ -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/'
);
});
});

View File

@@ -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>
</>
);
}

View File

@@ -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
View 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
View 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
View 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>

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;

View File

@@ -1,8 +0,0 @@
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
dayjs.extend(relativeTime);
const DayJS = dayjs();
export default DayJS;

View File

@@ -1,3 +0,0 @@
import { PrismaClient } from "@prisma/client";
export const prismaClient = new PrismaClient();

View File

@@ -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();

View File

@@ -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
View File

@@ -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.

View File

@@ -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",
});

View File

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

View File

@@ -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",
};
};

View File

@@ -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;
}

View File

@@ -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 } };
}

View File

@@ -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 } };
}

View File

@@ -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,
},
};
}

View File

@@ -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 } };
}

View File

@@ -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,
},
};
}

View File

@@ -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 } };
}

View File

@@ -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;

View File

@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE `Paragraph` MODIFY `content` LONGTEXT NOT NULL;

View File

@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE `Paragraph` MODIFY `time` DATETIME NOT NULL;

View File

@@ -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;

View File

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

View File

@@ -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])
}

View File

@@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 827 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 562 B

13
src/App.tsx Normal file
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 466 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -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" ? (

View 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>
);
}

View 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>
);
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;

View 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;
},
};
};

View 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,
};
};

View 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;

View 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
View 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
View 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -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
View File

@@ -0,0 +1,8 @@
{
"compilerOptions": {
"composite": true,
"module": "esnext",
"moduleResolution": "node"
},
"include": ["vite.config.ts"]
}

13
vite.config.ts Normal file
View 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")}/`,
},
},
});

6101
yarn.lock

File diff suppressed because it is too large Load Diff