update
@@ -1,9 +1,24 @@
|
||||
.env
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
.dockerignore
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
npm-debug.log
|
||||
README.md
|
||||
.next
|
||||
.git
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# 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",
|
||||
parserOptions: {
|
||||
sourceType: "module",
|
||||
project: "./tsconfig.json"
|
||||
},
|
||||
plugins: ["@typescript-eslint/eslint-plugin", "testing-library", "jest"],
|
||||
overrides: [
|
||||
{
|
||||
files: ["**/?(*.)+(spec|test).[jt]s?(x)"],
|
||||
extends: ["plugin:testing-library/react"],
|
||||
},
|
||||
],
|
||||
extends: [
|
||||
"plugin:@typescript-eslint/eslint-recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:react/recommended",
|
||||
"plugin:import/recommended",
|
||||
"plugin:import/typescript",
|
||||
"plugin:prettier/recommended",
|
||||
"plugin:storybook/recommended",
|
||||
],
|
||||
plugins: ["@typescript-eslint/eslint-plugin"],
|
||||
extends: ["plugin:@typescript-eslint/eslint-recommended", "plugin:@typescript-eslint/recommended", "plugin:react/recommended", "plugin:import/recommended", "plugin:import/typescript", "plugin:prettier/recommended", "plugin:storybook/recommended"],
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
jest: true,
|
||||
jest: true
|
||||
},
|
||||
rules: {
|
||||
"prettier/prettier": [
|
||||
"error",
|
||||
{
|
||||
singleQuote: false,
|
||||
},
|
||||
],
|
||||
"prettier/prettier": ["error", {
|
||||
singleQuote: false
|
||||
}],
|
||||
"@typescript-eslint/explicit-function-return-type": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/explicit-module-boundary-types": "off",
|
||||
"@typescript-eslint/quotes": [2, "double", "avoid-escape"],
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
argsIgnorePattern: "^_",
|
||||
destructuredArrayIgnorePattern: "^_",
|
||||
},
|
||||
],
|
||||
quotes: [2, "double", "avoid-escape"],
|
||||
semi: ["error", "always"],
|
||||
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_", "destructuredArrayIgnorePattern": "^_" }],
|
||||
"quotes": [2, "double", "avoid-escape"],
|
||||
"semi": ["error", "always"],
|
||||
"eol-last": ["error", "always"],
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"import/no-unresolved": "off",
|
||||
"import/order": [
|
||||
"error",
|
||||
{
|
||||
"newlines-between": "always",
|
||||
alphabetize: {
|
||||
order: "asc",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
"import/order": ["error", {
|
||||
"newlines-between": "always",
|
||||
"alphabetize": {
|
||||
"order": "asc"
|
||||
}
|
||||
}]
|
||||
}
|
||||
};
|
||||
52
.gitignore
vendored
@@ -1,38 +1,24 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
*.tsbuildinfo
|
||||
|
||||
# storybook
|
||||
storybook-static
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.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 { MantineProvider, ColorSchemeProvider } from '@mantine/core';
|
||||
import { NotificationsProvider } from '@mantine/notifications';
|
||||
import rtlPlugin from 'stylis-plugin-rtl';
|
||||
|
||||
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 (
|
||||
<ColorSchemeProvider colorScheme="light" toggleColorScheme={() => {}}>
|
||||
<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
|
||||
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>
|
||||
</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": [
|
||||
"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.
|
||||
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.
|
||||
Official [Mantine](https://mantine.dev/) + [Vite](https://vitejs.dev/) template.
|
||||
|
||||
## Features
|
||||
Links:
|
||||
|
||||
This template comes with several essential features:
|
||||
|
||||
- Server side rendering setup for Mantine
|
||||
- Color scheme is stored in cookie to avoid color scheme mismatch after hydration
|
||||
- Storybook with color scheme toggle
|
||||
- Jest with react testing library
|
||||
- ESLint setup with [eslint-config-mantine](https://github.com/mantinedev/eslint-config-mantine)
|
||||
|
||||
## npm scripts
|
||||
|
||||
### Build and dev scripts
|
||||
|
||||
- `dev` – start dev server
|
||||
- `build` – bundle application for production
|
||||
- `export` – exports static website to `out` folder
|
||||
- `analyze` – analyzes application bundle with [@next/bundle-analyzer](https://www.npmjs.com/package/@next/bundle-analyzer)
|
||||
|
||||
### Testing scripts
|
||||
|
||||
- `typecheck` – checks TypeScript types
|
||||
- `lint` – runs ESLint
|
||||
- `prettier:check` – checks files with Prettier
|
||||
- `jest` – runs jest tests
|
||||
- `jest:watch` – starts jest watch
|
||||
- `test` – runs `jest`, `prettier:check`, `lint` and `typecheck` scripts
|
||||
|
||||
### Other scripts
|
||||
|
||||
- `storybook` – starts storybook dev server
|
||||
- `storybook:build` – build production storybook bundle to `storybook-static`
|
||||
- `prettier:write` – formats all files with Prettier
|
||||
- [Mantine documentation](https://mantine.dev/)
|
||||
- [Vite documentation](https://vitejs.dev/)
|
||||
|
||||
@@ -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"
|
||||
services:
|
||||
ds-next:
|
||||
build: .
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/Dockerfile
|
||||
container_name: ds-next
|
||||
restart: always
|
||||
ports:
|
||||
- "9090:3000"
|
||||
env_file:
|
||||
- .env
|
||||
- "9090:80"
|
||||
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');
|
||||
|
||||
const createJestConfig = nextJest({
|
||||
dir: './',
|
||||
});
|
||||
|
||||
const customJestConfig = {
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'jest-environment-jsdom',
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
||||
moduleNameMapper: {
|
||||
'^@/components/(.*)$': '<rootDir>/components/$1',
|
||||
'^@/pages/(.*)$': '<rootDir>/pages/$1',
|
||||
'^@test-utils': '<rootDir>/test-utils',
|
||||
},
|
||||
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",
|
||||
"version": "1.0.0",
|
||||
"name": "codesecer-ui",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"analyze": "ANALYZE=true next build",
|
||||
"start": "next start",
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"export": "next build && next export",
|
||||
"lint": "next lint",
|
||||
"lint": "eslint src",
|
||||
"jest": "jest",
|
||||
"jest:watch": "jest --watch",
|
||||
"prettier:check": "prettier --check \"**/*.{ts,tsx}\"",
|
||||
"prettier:write": "prettier --write \"**/*.{ts,tsx}\"",
|
||||
"test": "npm run prettier:check && npm run lint && npm run typecheck && npm run jest",
|
||||
"storybook": "start-storybook -p 7001",
|
||||
"storybook:build": "build-storybook"
|
||||
"test": "yarn typecheck && yarn lint",
|
||||
"storybook": "NODE_OPTIONS=--openssl-legacy-provider start-storybook -p 6006",
|
||||
"build-storybook": "build-storybook",
|
||||
"chromatic": "npx chromatic --project-token=180ac2186305"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.10.4",
|
||||
"@emotion/server": "^11.10.0",
|
||||
"@mantine/carousel": "6.0.0",
|
||||
"@emotion/react": "^11.10.5",
|
||||
"@grafana/faro-react": "^1.0.2",
|
||||
"@grafana/faro-web-tracing": "^1.0.2",
|
||||
"@mantine/core": "6.0.0",
|
||||
"@mantine/dates": "6.0.0",
|
||||
"@mantine/form": "^6.0.2",
|
||||
"@mantine/hooks": "6.0.0",
|
||||
"@mantine/next": "6.0.0",
|
||||
"@mantine/notifications": "6.0.0",
|
||||
"@mantine/prism": "6.0.0",
|
||||
"@next/bundle-analyzer": "^13.1.6",
|
||||
"@prisma/client": "4.11.0",
|
||||
"@tabler/icons": "^1.107.0",
|
||||
"@tabler/icons-react": "^2.4.0",
|
||||
"cookies-next": "^2.1.1",
|
||||
"@mantine/form": "^6.0.0",
|
||||
"@mantine/hooks": "^6.0.0",
|
||||
"@mantine/notifications": "^6.0.0",
|
||||
"@reduxjs/toolkit": "^1.9.3",
|
||||
"@tabler/icons-react": "^2.7.0",
|
||||
"axios": "^1.3.4",
|
||||
"dayjs": "^1.11.7",
|
||||
"dotenv": "^16.0.3",
|
||||
"embla-carousel-react": "^7.0.3",
|
||||
"mongodb": "^5.1.0",
|
||||
"next": "13.1.6",
|
||||
"prisma": "^4.11.0",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"i18next": ">=21.0.0",
|
||||
"i18next-browser-languagedetector": "^6.1.4",
|
||||
"lodash": "^4.17.21",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0",
|
||||
"react-i18next": "^11.17.1",
|
||||
"react-redux": "^8.0.5",
|
||||
"react-router-dom": "^6.3.0",
|
||||
"remark": "^14.0.2",
|
||||
"remark-html": "^15.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.17.8",
|
||||
"@next/eslint-plugin-next": "^12.1.4",
|
||||
"@storybook/react": "^6.5.13",
|
||||
"@testing-library/dom": "^8.12.0",
|
||||
"@testing-library/jest-dom": "^5.16.3",
|
||||
"@testing-library/react": "^13.0.0",
|
||||
"@testing-library/user-event": "^14.0.4",
|
||||
"@types/jest": "^27.4.1",
|
||||
"@types/node": "^18.11.4",
|
||||
"@types/react": "18.0.21",
|
||||
"@typescript-eslint/eslint-plugin": "^5.30.0",
|
||||
"@typescript-eslint/parser": "^5.30.0",
|
||||
"babel-loader": "^8.2.4",
|
||||
"eslint": "^8.18.0",
|
||||
"eslint-config-airbnb": "19.0.4",
|
||||
"eslint-config-airbnb-typescript": "^17.0.0",
|
||||
"eslint-config-mantine": "2.0.0",
|
||||
"eslint-config-prettier": "^8.7.0",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-jest": "^26.1.1",
|
||||
"eslint-plugin-jsx-a11y": "^6.6.0",
|
||||
"@babel/core": "^7.21.0",
|
||||
"@storybook/addon-actions": "^6.5.16",
|
||||
"@storybook/addon-essentials": "^6.5.16",
|
||||
"@storybook/addon-interactions": "^6.5.16",
|
||||
"@storybook/addon-links": "^6.5.16",
|
||||
"@storybook/addons": ">=6.5.0",
|
||||
"@storybook/api": ">=6.5.0",
|
||||
"@storybook/builder-vite": "^0.4.2",
|
||||
"@storybook/components": ">=6.5.0",
|
||||
"@storybook/core-events": ">=6.5.0",
|
||||
"@storybook/react": "^6.5.16",
|
||||
"@storybook/testing-library": "^0.0.13",
|
||||
"@storybook/theming": ">=6.5.0",
|
||||
"@testing-library/dom": "^8.20.0",
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@testing-library/user-event": "^14.4.3",
|
||||
"@types/jest": "^29.4.0",
|
||||
"@types/lodash": "^4.14.191",
|
||||
"@types/react": "^18.0.27",
|
||||
"@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-react": "^7.30.1",
|
||||
"eslint-plugin-react": "^7.32.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-storybook": "^0.5.7",
|
||||
"eslint-plugin-testing-library": "^5.2.0",
|
||||
"jest": "^27.5.1",
|
||||
"prettier": "^2.7.1",
|
||||
"storybook-addon-turbo-build": "^1.1.0",
|
||||
"storybook-dark-mode": "^1.1.2",
|
||||
"ts-jest": "^27.1.4",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "4.8.4"
|
||||
}
|
||||
"eslint-plugin-storybook": "^0.6.11",
|
||||
"i18next-http-backend": "^1.4.0",
|
||||
"install-peerdeps": "^3.0.3",
|
||||
"jest": "^29.4.1",
|
||||
"jest-environment-jsdom": "^29.4.1",
|
||||
"prettier": "^2.8.3",
|
||||
"react-router": "^6.3.0",
|
||||
"storybook": "^6.5.16",
|
||||
"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 { IconMoonStars, IconSun } from "@tabler/icons";
|
||||
import { IconMoonStars, IconSun } from "@tabler/icons-react";
|
||||
|
||||
export function ColorSchemeToggle() {
|
||||
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
|
||||
@@ -11,8 +11,13 @@ export function ColorSchemeToggle() {
|
||||
size="xl"
|
||||
sx={(theme) => ({
|
||||
backgroundColor:
|
||||
theme.colorScheme === "dark" ? theme.colors.dark[6] : theme.colors.gray[0],
|
||||
color: theme.colorScheme === "dark" ? theme.colors.yellow[4] : theme.colors.blue[6],
|
||||
theme.colorScheme === "dark"
|
||||
? theme.colors.dark[6]
|
||||
: theme.colors.gray[0],
|
||||
color:
|
||||
theme.colorScheme === "dark"
|
||||
? theme.colors.yellow[4]
|
||||
: theme.colors.blue[6],
|
||||
})}
|
||||
>
|
||||
{colorScheme === "dark" ? (
|
||||
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": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": false,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true
|
||||
},
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
],
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx"
|
||||
],
|
||||
"ts-node": {
|
||||
"compilerOptions": {
|
||||
"module": "commonjs"
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"paths": {
|
||||
"@test-utils": ["./test-utils"],
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"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")}/`,
|
||||
},
|
||||
},
|
||||
});
|
||||