Compare commits

...

17 Commits

Author SHA1 Message Date
5b47ca8a40 update 2025-08-08 18:26:26 +08:00
f1f8db340b update 2025-08-08 17:17:28 +08:00
519713ffb4 update 2025-08-08 17:17:20 +08:00
7f34a2f4a0 update 2025-08-08 17:14:09 +08:00
5fc4d09c39 update 2024-12-08 20:43:52 +08:00
977492e78c update 2024-12-07 19:20:04 +08:00
d84b92fe4f fix: bug 2024-12-05 00:25:48 +08:00
86fe107ec2 fix: bug 2024-12-05 00:25:26 +08:00
0460443a9d fix s3url 2024-12-05 00:17:38 +08:00
a9d6ab7b34 fix 2024-06-25 10:57:46 +08:00
d22d717849 feat: split vendor 2024-06-25 10:40:53 +08:00
d8f109cdb4 Merge remote-tracking branch 'origin/main' 2024-06-25 10:00:08 +08:00
23d93ec5b5 feat: meilisearch 2024-06-25 09:59:40 +08:00
4e16d6a8cc Update file Dockerfile 2024-06-24 16:25:24 +00:00
733c663ec2 Update file Dockerfile 2024-06-24 16:00:34 +00:00
dd4d57fb05 Update file Dockerfile 2024-06-24 15:56:49 +00:00
b249cccef3 add docker 2024-06-24 15:55:23 +00:00
80 changed files with 9596 additions and 10976 deletions

1
.eslintcache Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1,26 +0,0 @@
// eslint-disable-next-line no-undef
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react-hooks/recommended",
"plugin:prettier/recommended",
"plugin:storybook/recommended",
],
ignorePatterns: ["dist", "src/gql/*"],
parser: "@typescript-eslint/parser",
plugins: ["react-refresh", "prettier"],
rules: {
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
},
};

4
.gitignore vendored
View File

@@ -24,5 +24,5 @@ dist-ssr
*.sw?
.vscode
# Sentry Config File
.env.sentry-build-plugin
build

View File

@@ -1,20 +0,0 @@
# The Docker image that will be used to build your app
image: node:18.17.1
# Functions that should be executed before the build script is run
before_script:
- corepack enable
- corepack prepare pnpm@latest-8 --activate
- pnpm config set store-dir .pnpm-store
pages:
script:
- pnpm install
- pnpm build
- mv dist public
artifacts:
paths:
# The folder that contains the files to be exposed at the Page URL
- public
rules:
# This ensures that only pushes to the default branch will trigger
# a pages deploy
- if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH

1
.husky/pre-commit Normal file
View File

@@ -0,0 +1 @@
pnpm test

View File

@@ -0,0 +1,9 @@
// Generated by React Router
import "react-router";
declare module "react-router" {
interface Future {
unstable_middleware: false
}
}

View File

@@ -0,0 +1,47 @@
// Generated by React Router
import "react-router"
declare module "react-router" {
interface Register {
pages: Pages
routeFiles: RouteFiles
}
}
type Pages = {
"/": {
params: {};
};
"/settings": {
params: {};
};
"/paragraph/:id": {
params: {
"id": string;
};
};
};
type RouteFiles = {
"root.tsx": {
id: "root";
page: "/" | "/settings" | "/paragraph/:id";
};
"./layouts/Main.layout.tsx": {
id: "layouts/Main.layout";
page: "/" | "/settings" | "/paragraph/:id";
};
"./pages/Search.page.tsx": {
id: "pages/Search.page";
page: "/";
};
"./pages/Settings.page.tsx": {
id: "pages/Settings.page";
page: "/settings";
};
"./pages/Paragraph.page.tsx": {
id: "pages/Paragraph.page";
page: "/paragraph/:id";
};
};

17
.react-router/types/+server-build.d.ts vendored Normal file
View File

@@ -0,0 +1,17 @@
// Generated by React Router
declare module "virtual:react-router/server-build" {
import { ServerBuild } from "react-router";
export const assets: ServerBuild["assets"];
export const assetsBuildDirectory: ServerBuild["assetsBuildDirectory"];
export const basename: ServerBuild["basename"];
export const entry: ServerBuild["entry"];
export const future: ServerBuild["future"];
export const isSpaMode: ServerBuild["isSpaMode"];
export const prerender: ServerBuild["prerender"];
export const publicPath: ServerBuild["publicPath"];
export const routeDiscovery: ServerBuild["routeDiscovery"];
export const routes: ServerBuild["routes"];
export const ssr: ServerBuild["ssr"];
export const unstable_getCriticalCss: ServerBuild["unstable_getCriticalCss"];
}

View File

@@ -0,0 +1,59 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../root.js")
type Info = GetInfo<{
file: "root.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../root.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// unstable_middleware
export type unstable_MiddlewareFunction = Annotations["unstable_MiddlewareFunction"];
// unstable_clientMiddleware
export type unstable_ClientMiddlewareFunction = Annotations["unstable_ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}

View File

@@ -0,0 +1,62 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../Main.layout.js")
type Info = GetInfo<{
file: "./layouts/Main.layout.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "layouts/Main.layout";
module: typeof import("../Main.layout.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// unstable_middleware
export type unstable_MiddlewareFunction = Annotations["unstable_MiddlewareFunction"];
// unstable_clientMiddleware
export type unstable_ClientMiddlewareFunction = Annotations["unstable_ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}

View File

@@ -0,0 +1,65 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../Paragraph.page.js")
type Info = GetInfo<{
file: "./pages/Paragraph.page.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "layouts/Main.layout";
module: typeof import("../../layouts/Main.layout.js");
}, {
id: "pages/Paragraph.page";
module: typeof import("../Paragraph.page.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// unstable_middleware
export type unstable_MiddlewareFunction = Annotations["unstable_MiddlewareFunction"];
// unstable_clientMiddleware
export type unstable_ClientMiddlewareFunction = Annotations["unstable_ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}

View File

@@ -0,0 +1,65 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../Search.page.js")
type Info = GetInfo<{
file: "./pages/Search.page.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "layouts/Main.layout";
module: typeof import("../../layouts/Main.layout.js");
}, {
id: "pages/Search.page";
module: typeof import("../Search.page.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// unstable_middleware
export type unstable_MiddlewareFunction = Annotations["unstable_MiddlewareFunction"];
// unstable_clientMiddleware
export type unstable_ClientMiddlewareFunction = Annotations["unstable_ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}

View File

@@ -0,0 +1,65 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../Settings.page.js")
type Info = GetInfo<{
file: "./pages/Settings.page.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "layouts/Main.layout";
module: typeof import("../../layouts/Main.layout.js");
}, {
id: "pages/Settings.page";
module: typeof import("../Settings.page.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// unstable_middleware
export type unstable_MiddlewareFunction = Annotations["unstable_MiddlewareFunction"];
// unstable_clientMiddleware
export type unstable_ClientMiddlewareFunction = Annotations["unstable_ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}

View File

@@ -1,18 +0,0 @@
import { DocsContainer as BaseContainer } from "@storybook/blocks";
import { themes } from "@storybook/theming";
import React from "react";
import { useDarkMode } from "storybook-dark-mode";
export const DocsContainer = ({ children, context }) => {
const dark = useDarkMode();
return (
<BaseContainer
context={context}
theme={dark ? themes.dark : themes.light}
>
{children}
</BaseContainer>
);
};

View File

@@ -1,20 +0,0 @@
import type { StorybookConfig } from "@storybook/react-vite";
const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
addons: [
"@storybook/addon-links",
"@storybook/addon-essentials",
"@storybook/addon-interactions",
"storybook-dark-mode",
"storybook-react-i18next"
],
framework: {
name: "@storybook/react-vite",
options: {},
},
docs: {
autodocs: "tag",
},
};
export default config;

View File

@@ -1,3 +0,0 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@100;400&display=swap" rel="stylesheet">

View File

@@ -1,50 +0,0 @@
import { Container, MantineProvider } from "@mantine/core";
import '@mantine/core/styles.css';
import type { Preview } from "@storybook/react";
import { useDarkMode } from 'storybook-dark-mode';
import i18n from "../src/i18n";
import React, { Fragment } from "react";
import { DocsContainer } from "./DocsContainer";
const preview: Preview = {
parameters: {
actions: { argTypesRegex: "^on[A-Z].*" },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
docs: {
container: DocsContainer,
},
i18n
},
globals: {
locale: 'en',
locales: {
en: 'English',
zh: '中文',
},
},
decorators: [
(Story, runtime) => {
const isDark = useDarkMode();
const C = runtime.parameters.layout === 'fullscreen' ? Fragment : Container;
return <MantineProvider withCssVariables forceColorScheme={
isDark ? 'dark' : 'light'
}>
<C>
<Story />
</C>
</MantineProvider>
},
]
};
export default preview;

1
.stylelintcache Normal file
View File

@@ -0,0 +1 @@
[["1","2","3"],{"key":"4","value":"5"},{"key":"6","value":"7"},{"key":"8","value":"9"},"/Users/y/Workspace/ds.pages.yoshino-s.xyz/app/pages/Exception/ErrorPage.module.css",{"size":890,"mtime":1754644590804,"data":"10"},"/Users/y/Workspace/ds.pages.yoshino-s.xyz/app/main.css",{"size":526,"mtime":1752488689137,"data":"11"},"/Users/y/Workspace/ds.pages.yoshino-s.xyz/app/component/Hits/Hits.module.css",{"size":573,"mtime":1754644590795,"data":"12"},{"hashOfConfig":"13"},{"hashOfConfig":"13"},{"hashOfConfig":"13"},"1evb2tp"]

1
.stylelintignore Normal file
View File

@@ -0,0 +1 @@
build

28
.stylelintrc.json Normal file
View File

@@ -0,0 +1,28 @@
{
"extends": ["stylelint-config-standard-scss"],
"rules": {
"custom-property-pattern": null,
"selector-class-pattern": null,
"scss/no-duplicate-mixins": null,
"declaration-empty-line-before": null,
"declaration-block-no-redundant-longhand-properties": null,
"alpha-value-notation": null,
"custom-property-empty-line-before": null,
"property-no-vendor-prefix": null,
"color-function-notation": null,
"length-zero-no-unit": null,
"selector-not-notation": null,
"no-descending-specificity": null,
"comment-empty-line-before": null,
"scss/at-mixin-pattern": null,
"scss/at-rule-no-unknown": null,
"value-keyword-case": null,
"media-feature-range-notation": null,
"selector-pseudo-class-no-unknown": [
true,
{
"ignorePseudoClasses": ["global"]
}
]
}
}

View File

@@ -1,4 +1,5 @@
import { Dispatch, SetStateAction, createContext } from "react";
import type { Dispatch, SetStateAction } from "react";
import { createContext } from "react";
export const TitleContext = createContext<
[string, Dispatch<SetStateAction<string>>]

View File

@@ -0,0 +1,32 @@
.img-wrapper {
column-count: 1;
column-gap: 10px;
@media screen and (min-width: 500px) {
column-count: 2;
}
@media screen and (min-width: 720px) {
column-count: 3;
}
@media screen and (min-width: 1100px) {
column-count: 5;
}
counter-reset: count;
margin: 0 auto;
@media (max-width: $mantine-breakpoint-md) {
column-count: 2;
}
@media (max-width: $mantine-breakpoint-sm) {
column-count: 1;
}
& > div {
position: relative;
margin-bottom: 10px;
}
}

View File

@@ -0,0 +1,21 @@
import { Box } from "@mantine/core";
import type { UseHitsProps } from "react-instantsearch";
import { useHits } from "react-instantsearch";
import { ParagraphCard } from "../ParagraphCard/ParagraphCard";
import styles from "./Hits.module.css";
export default function Hits(props: UseHitsProps<Paragraph>) {
const { results } = useHits(props);
return (
<Box className={styles["img-wrapper"]}>
{results?.hits?.map((hit) => (
<Box key={hit.id}>
<ParagraphCard {...hit} key={`paragraph-card-${hit.id}`} />
</Box>
))}
</Box>
);
}

View File

@@ -0,0 +1,20 @@
import { Center, Group, Pagination as MantinePagination } from "@mantine/core";
import type { UsePaginationProps } from "react-instantsearch";
import { usePagination } from "react-instantsearch";
export default function Pagination(props: UsePaginationProps) {
const { currentRefinement, nbPages, refine } = usePagination(props);
return (
<Center>
<Group>
<MantinePagination
total={nbPages}
value={currentRefinement + 1}
onChange={(value) => refine(value - 1)}
/>
</Group>
</Center>
);
}

View File

@@ -1,54 +1,45 @@
import { Badge, Card, Group, Image, Text } from "@mantine/core";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { Link } from "react-router-dom";
import { useContentFix } from "@/hooks/useContentFix";
dayjs.extend(relativeTime);
export function ParagraphCard({
cover,
title,
"@timestamp": time,
time,
author,
tags,
_id,
id,
}: Paragraph) {
const url = `/paragraph/${_id}`;
const url = `/paragraph/${id}`;
cover = useContentFix(cover) ?? "";
return (
<Card withBorder radius="md" padding="lg" shadow="sm">
<Card.Section>
<Link to={url}>
<Link to={url} target="_blank">
{cover && <Image src={cover} height={140} width={140} />}
</Link>
</Card.Section>
<Group justify="space-between" mt="md" mb="xs">
<Text component={Link} to={url}>
<Text component={Link} to={url} target="_blank">
{title}
</Text>
<Group>
{tags.map((tag, index) => (
<>
<Badge
component={Link}
key={index}
to={`/tag/${encodeURIComponent(tag)}`}
>
{tag}
</Badge>
</>
<Badge key={index}>{tag}</Badge>
))}
</Group>
</Group>
<Group>
<Group>
<Text
size="xs"
component={Link}
to={`/author/${encodeURIComponent(author)}`}
>
{author}
</Text>
<Text size="xs">{author}</Text>
</Group>
<Text size="xs" c="dimmed">

View File

@@ -0,0 +1,130 @@
import { useEffect } from "react";
import type {
ComboboxItem,
ComboboxLikeRenderOptionInput,
} from "@mantine/core";
import {
Badge,
Box,
CheckIcon,
Group,
MultiSelect,
Select,
rem,
} from "@mantine/core";
import {
useHitsPerPage,
useRefinementList,
useSortBy,
} from "react-instantsearch";
import { SourceLabelMap } from "@/constants";
const sortItems = [
{ value: "paragraph:time:desc", label: "Newest" },
{ value: "paragraph:time:asc", label: "Oldest" },
];
const hitsPerPageItems = [
{ value: 20, label: "20", default: true },
{ value: 40, label: "40" },
{ value: 60, label: "60" },
{ value: 100, label: "100" },
];
export default function Refinement() {
const { items, refine } = useRefinementList({ attribute: "source" });
const { currentRefinement, refine: refineSortBy } = useSortBy({
items: sortItems,
});
const hitsPerPage = useHitsPerPage({
items: hitsPerPageItems,
});
const currentVal = items
.filter((item) => item.isRefined)
.map((item) => item.value);
useEffect(() => {
refineSortBy(sortItems[0].value);
}, [refineSortBy]);
function SelectItem(props: ComboboxLikeRenderOptionInput<ComboboxItem>) {
return (
<Group justify="space-between" w="100%">
<Box
style={{
gap: "0.5em",
display: "flex",
justifyContent: "flex-start",
alignItems: "center",
}}
>
{currentVal.includes(props.option.value) && (
<CheckIcon
style={{
opacity: "0.4",
width: "0.8em",
minWidth: "0.8em",
height: "0.8em",
}}
/>
)}
{props.option.label}
</Box>
<Badge>
{items.find((item) => item.value === props.option.value)?.count}
</Badge>
</Group>
);
}
return (
<Group justify="space-between" align="center" my="md">
<Group>
<Select
data={sortItems}
value={currentRefinement}
defaultValue={sortItems[0].value}
onChange={(value) => value && refineSortBy(value)}
label="Sort by"
/>
<MultiSelect
styles={{
wrapper: {
width: rem(300),
},
}}
data={items.map((item) => ({
value: item.label,
label: SourceLabelMap[item.label],
}))}
renderOption={SelectItem}
value={currentVal}
label="Source"
clearable
onChange={(values) => {
const diff = values
.filter((value) => !currentVal.includes(value))
.concat(currentVal.filter((value) => !values.includes(value)));
diff.forEach((value) => refine(value));
}}
/>
</Group>
<Group w={rem(96)}>
<Select
data={hitsPerPageItems.map((item) => ({
value: item.value.toString(),
label: item.label.toString(),
}))}
value={hitsPerPage.items
.find((item) => item.isRefined)
?.value.toString()}
onChange={(value) => value && hitsPerPage.refine(parseInt(value))}
label="Hits per page"
/>
</Group>
</Group>
);
}

View File

@@ -0,0 +1,25 @@
import { Affix, Button, Transition, rem } from "@mantine/core";
import { useWindowScroll } from "@mantine/hooks";
import { TbArrowUp } from "react-icons/tb";
export default function ScrollTop() {
const [scroll, scrollTo] = useWindowScroll();
return (
<Affix position={{ bottom: 20, right: 20 }}>
<Transition transition="slide-up" mounted={scroll.y > 0}>
{(transitionStyles) => (
<Button
leftSection={
<TbArrowUp style={{ width: rem(16), height: rem(16) }} />
}
style={transitionStyles}
onClick={() => scrollTo({ y: 0 })}
>
Scroll to top
</Button>
)}
</Transition>
</Affix>
);
}

View File

@@ -0,0 +1,41 @@
import { useCallback, useState } from "react";
import { TextInput, rem } from "@mantine/core";
import { TbSearch } from "react-icons/tb";
import type { UseSearchBoxProps } from "react-instantsearch";
import { useSearchBox } from "react-instantsearch";
export default function SearchBox(props: UseSearchBoxProps) {
const { query, refine } = useSearchBox(props);
const [inputValue, setInputValue] = useState(query);
const setQuery = useCallback(
(value: string) => {
setInputValue(value);
refine(value);
},
[refine, setInputValue],
);
return (
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<TextInput
placeholder="Search"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
type="search"
leftSection={<TbSearch style={{ width: rem(16), height: rem(16) }} />}
value={inputValue}
onChange={(event) => setQuery(event.currentTarget.value)}
/>
</form>
);
}

View File

@@ -4,7 +4,7 @@ import {
SegmentedControl,
useMantineColorScheme,
} from "@mantine/core";
import { IconMoon, IconRobot, IconSun } from "@tabler/icons-react";
import { TbMoon, TbRobot, TbSun } from "react-icons/tb";
export function ThemeSetting() {
const { colorScheme, setColorScheme } = useMantineColorScheme();
@@ -18,7 +18,7 @@ export function ThemeSetting() {
value: "light",
label: (
<Center>
<IconSun size="1rem" stroke={1.5} />
<TbSun size="1rem" />
<Box ml={10}>Light</Box>
</Center>
),
@@ -27,7 +27,7 @@ export function ThemeSetting() {
value: "auto",
label: (
<Center>
<IconRobot size="1rem" stroke={1.5} />
<TbRobot size="1rem" />
<Box ml={10}>Auto</Box>
</Center>
),
@@ -36,7 +36,7 @@ export function ThemeSetting() {
value: "dark",
label: (
<Center>
<IconMoon size="1rem" stroke={1.5} />
<TbMoon size="1rem" />
<Box ml={10}>Dark</Box>
</Center>
),

9
app/constants.ts Normal file
View File

@@ -0,0 +1,9 @@
export const SourceLabelMap: Record<string, string> = {
tttang: "跳跳糖",
secin: "Sec-In",
seebug: "Seebug",
wechat: "微信公众号",
xianzhi: "先知",
anquanke: "安全客",
freebuf: "FreeBuf",
};

14
app/entry.client.tsx Normal file
View File

@@ -0,0 +1,14 @@
import { StrictMode, startTransition } from "react";
import { hydrateRoot } from "react-dom/client";
import { HydratedRouter } from "react-router/dom";
import "./main.css";
startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<HydratedRouter />
</StrictMode>,
);
});

71
app/entry.server.tsx Normal file
View File

@@ -0,0 +1,71 @@
import { PassThrough } from "node:stream";
import type { AppLoadContext, EntryContext } from "react-router";
import { ServerRouter } from "react-router";
import { createReadableStreamFromReadable } from "@react-router/node";
import { isbot } from "isbot";
import type { RenderToPipeableStreamOptions } from "react-dom/server";
import { renderToPipeableStream } from "react-dom/server";
export const streamTimeout = 5_000;
export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
_loadContext: AppLoadContext,
// If you have middleware enabled:
// loadContext: unstable_RouterContextProvider
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
const userAgent = request.headers.get("user-agent");
// Ensure requests from bots and SPA Mode renders wait for all content to load before responding
// https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
const readyOption: keyof RenderToPipeableStreamOptions =
(userAgent && isbot(userAgent)) || routerContext.isSpaMode
? "onAllReady"
: "onShellReady";
const { pipe, abort } = renderToPipeableStream(
<ServerRouter context={routerContext} url={request.url} />,
{
[readyOption]() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
responseHeaders.set("Content-Type", "text/html");
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
}),
);
pipe(body);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
responseStatusCode = 500;
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error);
}
},
},
);
// Abort the rendering stream after the `streamTimeout` so it has time to
// flush down the rejected boundaries
setTimeout(abort, streamTimeout + 1000);
});
}

View File

@@ -0,0 +1,9 @@
import useConfigStore from "@/store/config";
export function useContentFix(content?: string | null) {
const s3Url = useConfigStore((store) => store.s3Url);
return content?.replace(
/https?:\/\/(?:minio-hdd)\.yoshino-s\.(?:online|xyz)\//g,
s3Url,
);
}

129
app/layouts/Main.layout.tsx Normal file
View File

@@ -0,0 +1,129 @@
import { Suspense, useEffect, useState } from "react";
import { Outlet, useLocation } from "react-router";
import {
AppShell,
Avatar,
Center,
Group,
UnstyledButton,
rem,
} from "@mantine/core";
import { useHeadroom } from "@mantine/hooks";
import { TbSettings } from "react-icons/tb";
import type {
InstantMeiliSearchInstance,
MeiliSearch,
} from "@meilisearch/instant-meilisearch";
import { instantMeiliSearch } from "@meilisearch/instant-meilisearch";
import { singleIndex } from "instantsearch.js/es/lib/stateMappings";
import { InstantSearch } from "react-instantsearch";
import { Link } from "react-router-dom";
import ScrollTop from "@/component/ScrollTop/ScrollTop";
import SearchBox from "@/component/SearchBox/SearchBox";
import Loading from "@/pages/Loading";
import useConfigStore from "@/store/config";
import { MeilisearchProvider } from "@/utils/meilisearchContext";
export default function MainLayout() {
const pinned = useHeadroom({ fixedAt: 60 });
const selector = useConfigStore();
const path = useLocation().pathname;
const isSearchPage = path === "/";
const [searchClient, setSearchClient] =
useState<InstantMeiliSearchInstance>();
const [meilisearch, setMeilisearch] = useState<MeiliSearch | null>(null);
useEffect(() => {
const { meilisearchUrl, meilisearchToken, enableHybridSearch } = selector;
const { searchClient, meiliSearchInstance } = instantMeiliSearch(
meilisearchUrl,
meilisearchToken,
{
finitePagination: true,
meiliSearchParams: {
hybrid: enableHybridSearch
? {
embedder: "cloudflare",
}
: undefined,
attributesToRetrieve: [
"cover",
"title",
"time",
"author",
"tags",
"id",
],
},
},
);
setSearchClient(searchClient);
setMeilisearch(meiliSearchInstance);
}, [selector, setSearchClient]);
const shell = (
<>
<AppShell
header={{ height: 60, collapsed: !pinned, offset: false }}
h="100vh"
>
<AppShell.Header>
<Group h="100%" justify="space-between" px="md">
<Group>
<Avatar fw={700} component={Link} to="/">
DS
</Avatar>
</Group>
<Group>
{isSearchPage && <SearchBox />}
<UnstyledButton component={Link} to="/settings">
<Center>
<TbSettings />
</Center>
</UnstyledButton>
</Group>
</Group>
</AppShell.Header>
<AppShell.Main
pt={`calc(${rem(60)} + var(--mantine-spacing-md))`}
pb={rem(60)}
>
<Suspense fallback={<Loading />}>
<MeilisearchProvider value={meilisearch}>
<Outlet />
</MeilisearchProvider>
</Suspense>
</AppShell.Main>
</AppShell>
<ScrollTop />
</>
);
return (
<>
{searchClient &&
(isSearchPage ? (
<InstantSearch
searchClient={searchClient as any}
indexName="paragraph"
routing={{
stateMapping: singleIndex("paragraph"),
}}
future={{
preserveSharedStateOnUnmount: true,
}}
>
{shell}
</InstantSearch>
) : (
shell
))}
</>
);
}

7
app/main.css Normal file
View File

@@ -0,0 +1,7 @@
@layer mantine-core, mantine-dates, mantine-datatable, mantine-contextmenu, mantine-notifications, mantine-spotlight, mantine-split-pane;
@import "@mantine/core/styles.css" layer(mantine-core);
@import "@mantine/dates/styles.css" layer(mantine-dates);
@import "mantine-datatable/styles.css" layer(mantine-datatable);
@import "mantine-contextmenu/styles.css" layer(mantine-contextmenu);
@import "@mantine/notifications/styles.css" layer(mantine-notifications);
@import "@mantine/spotlight/styles.css" layer(mantine-spotlight);

View File

@@ -0,0 +1,38 @@
.root {
padding-top: rem(80px);
padding-bottom: rem(120px);
background-color: var(--mantine-color-blue-filled);
}
.label {
text-align: center;
font-weight: 900;
font-size: rem(220px);
line-height: 1;
margin-bottom: calc(var(--mantine-spacing-xl) * 1.5);
color: var(--mantine-color-blue-3);
@media (max-width: $mantine-breakpoint-sm) {
font-size: rem(120px);
}
}
.title {
font-family: "Greycliff CF", var(--mantine-font-family);
text-align: center;
font-weight: 900;
font-size: rem(38px);
color: var(--mantine-color-white);
@media (max-width: $mantine-breakpoint-sm) {
font-size: rem(32px);
}
}
.description {
max-width: rem(540px);
margin: auto;
margin-top: var(--mantine-spacing-xl);
margin-bottom: calc(var(--mantine-spacing-xl) * 1.5);
color: var(--mantine-color-blue-1);
}

View File

@@ -1,4 +1,5 @@
import { Button, Container, Group, Text, Title } from "@mantine/core";
import classes from "./ErrorPage.module.css";
export interface ErrorPageProps {

View File

@@ -1,4 +1,5 @@
import { TitleContext } from "@/component/Header/Header";
import { useEffect, useState } from "react";
import {
Badge,
Container,
@@ -7,12 +8,13 @@ import {
Title,
TypographyStylesProvider,
} from "@mantine/core";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { useContext, useEffect } from "react";
import { useLoaderData } from "react-router";
import { Link } from "react-router-dom";
import dayjs from "dayjs";
import { Link, useParams } from "react-router-dom";
import useConfigStore from "@/store/config";
import { useMeilisearch } from "@/utils/meilisearchContext";
import { markdownToHtml } from "@/utils/remark";
function stripStyles(content: string) {
const element = document.createElement("div");
@@ -52,36 +54,57 @@ function stripStyles(content: string) {
return element.innerHTML;
}
dayjs.extend(relativeTime);
export default function ParagraphPage() {
const [_title, setTitle] = useContext(TitleContext);
const paragraph = useLoaderData() as Paragraph;
const { id } = useParams<{ id: string }>();
const s3Url = useConfigStore((store) => store.s3Url);
const meilisearch = useMeilisearch();
const [paragraph, setParagraph] = useState<Paragraph | null>(null);
useEffect(() => {
setTitle(paragraph.title);
}, [setTitle, paragraph.title]);
async function fetchParagraph() {
if (!meilisearch || !id) return;
const paragraph: Paragraph = await meilisearch
.index("paragraph")
.getDocument(id);
if (paragraph.markdown) {
paragraph.content = await markdownToHtml(paragraph.content);
paragraph.content = stripStyles(paragraph.content)?.replace(
/https?:\/\/(?:minio-hdd)\.yoshino-s\.(?:online|xyz)\//g,
s3Url,
);
console.log(paragraph.content);
} else {
// paragraph.content = "NO HTML!";
}
setParagraph(paragraph);
}
fetchParagraph();
}, [id, meilisearch, s3Url]);
return (
<Container py="2rem">
<Title>{paragraph.title}</Title>
<Title>{paragraph?.title}</Title>
<Group justify="space-between" align="center" my="md">
<Group>
<Text size="sm" c="dimmed">
{" "}
{dayjs().to(dayjs(paragraph["@timestamp"]))}
{dayjs().to(dayjs(paragraph?.time))}
</Text>
<Text
ml="1rem"
size="sm"
component={Link}
to={`/author/${encodeURIComponent(paragraph.author || "unknown")}`}
to={`/author/${encodeURIComponent(paragraph?.author || "unknown")}`}
>
{paragraph.author}
{paragraph?.author}
</Text>
</Group>
<Group>
{paragraph.tags.map((tag, index) => (
{paragraph?.tags.map((tag, index) => (
<>
<Badge
size="sm"
@@ -93,8 +116,8 @@ export default function ParagraphPage() {
</Badge>
</>
))}
{paragraph.source_url && (
<a href={paragraph.source_url}>Goto Source</a>
{paragraph?.source_url && (
<a href={paragraph?.source_url}>Goto Source</a>
)}
</Group>
</Group>
@@ -109,7 +132,7 @@ export default function ParagraphPage() {
lineBreak: "anywhere",
}}
dangerouslySetInnerHTML={{
__html: stripStyles(paragraph.content),
__html: paragraph?.content ?? "<p>Content not found</p>",
}}
/>
</TypographyStylesProvider>

19
app/pages/Search.page.tsx Normal file
View File

@@ -0,0 +1,19 @@
import { Container, Grid, Stack } from "@mantine/core";
import Hits from "@/component/Hits/Hits";
import Pagination from "@/component/Pagination/Pagination";
import Refinement from "@/component/Refinement/Refinement";
export default function SearchPage() {
return (
<Container size="xl">
<Stack>
<Refinement />
<Grid my="md">
<Hits />
</Grid>
<Pagination />
</Stack>
</Container>
);
}

220
app/pages/Settings.page.tsx Normal file
View File

@@ -0,0 +1,220 @@
import type { ReactNode } from "react";
import { useContext, useEffect, useState } from "react";
import {
ActionIcon,
Button,
Container,
Paper,
PasswordInput,
Switch,
Table,
Text,
TextInput,
Title,
rem,
} from "@mantine/core";
import { useForm } from "@mantine/form";
import { TbBrandGithub, TbMail } from "react-icons/tb";
import { Meilisearch } from "meilisearch";
import { TitleContext } from "@/component/Header/Header";
import { ThemeSetting } from "@/component/Settings/Theme";
import useConfigStore from "@/store/config";
interface SettingItem {
title?: ReactNode;
description?: ReactNode;
value?: ReactNode;
}
export default function SettingsPage() {
const [_, setTitle] = useContext(TitleContext);
const {
setS3Url,
setEnableHybridSearch,
setMeilisearchToken,
setMeilisearchUrl,
...options
} = useConfigStore();
useEffect(() => {
setTitle("Settings");
}, [setTitle]);
const form = useForm({
initialValues: options,
validateInputOnBlur: true,
validateInputOnChange: true,
});
const [meilisearchVersion, setMeilisearchVersion] = useState<string | null>(
null,
);
const onSubmit = async (v: typeof options) => {
try {
new URL(v.s3Url, location.origin);
form.clearFieldError("s3Url");
} catch {
form.setFieldError("s3Url", "Invalid Minio URL");
}
try {
const client = new Meilisearch({
host: v.meilisearchUrl,
apiKey: v.meilisearchToken,
});
const version = await client.getVersion();
setMeilisearchVersion(version.pkgVersion);
form.clearFieldError("meilisearchUrl");
form.clearFieldError("meilisearchToken");
} catch {
form.setFieldError("meilisearchUrl", "Invalid Meilisearch URL");
form.setFieldError("meilisearchToken", "Invalid Meilisearch Token");
}
if (form.errors.length !== 0) {
return;
}
if (v.s3Url !== options.s3Url) {
setS3Url(v.s3Url);
}
if (v.meilisearchUrl !== options.meilisearchUrl) {
setMeilisearchUrl(v.meilisearchUrl);
}
if (v.meilisearchToken !== options.meilisearchToken) {
setMeilisearchToken(v.meilisearchToken);
}
};
const settings: SettingItem[][] = [
[
{
title: "Theme",
description: "Change the theme of your UI",
value: <ThemeSetting />,
},
{
title: "Enable Hybrid Search",
description: "Enable hybrid search for Meilisearch",
value: (
<div>
<Switch
size="md"
checked={options.enableHybridSearch}
onChange={(value) => {
const v = value.currentTarget.checked;
setEnableHybridSearch(v);
}}
/>
</div>
),
},
],
[
{
title: "Minio URL",
description: "The URL of your Minio instance",
value: <TextInput {...form.getInputProps("s3Url")} required />,
},
{
title: "Meilisearch URL",
description: "The URL of your Meilisearch instance",
value: <TextInput {...form.getInputProps("meilisearchUrl")} required />,
},
{
title: "Meilisearch Token",
description: "The token of your Meilisearch instance",
value: (
<PasswordInput {...form.getInputProps("meilisearchToken")} required />
),
},
{
title: <Button type="submit">Test And Save</Button>,
value: meilisearchVersion ? (
<span
style={{
color: "var(--mantine-color-green-6)",
}}
>
Meilisearch V{meilisearchVersion}
</span>
) : null,
},
],
[
{
title: "Made With ❤️ By",
description: (
<a href="https://github.com/yoshino-s">
https://github.com/yoshino-s
</a>
),
value: (
<ActionIcon.Group>
<ActionIcon
variant="default"
size="lg"
aria-label="Gallery"
component="a"
href="https://github.com/yoshino-s"
target="_blank"
>
<TbBrandGithub style={{ width: rem(20) }} />
</ActionIcon>
<ActionIcon
variant="default"
size="lg"
aria-label="Settings"
component="a"
href="mailto:yoshino.prog@gmail.com"
>
<TbMail style={{ width: rem(20) }} />
</ActionIcon>
</ActionIcon.Group>
),
},
],
];
return (
<Container>
<Title mt="lg" order={1}>
Settings
</Title>
Customize the look and feel of your Coder deployment.
<form onSubmit={form.onSubmit(onSubmit)}>
{settings.map((settingItem, index) => (
<Paper
my="xl"
radius="md"
withBorder
style={{ overflow: "hidden" }}
key={index}
>
<Table verticalSpacing="lg" striped>
<Table.Tbody>
{settingItem.map((setting) => (
<Table.Tr key={`${setting.title}`}>
<Table.Td>
<Text size="md" fw={500}>
{setting.title}
</Text>
<Text c="dimmed" size="sm">
{setting.description}
</Text>
</Table.Td>
<Table.Td>{setting.value}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Paper>
))}
</form>
</Container>
);
}

97
app/root.tsx Normal file
View File

@@ -0,0 +1,97 @@
import { useState } from "react";
import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
isRouteErrorResponse,
} from "react-router";
import { MantineProvider } from "@mantine/core";
import { useDocumentTitle } from "@mantine/hooks";
import { Notifications } from "@mantine/notifications";
import { ContextMenuProvider } from "mantine-contextmenu";
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
import relativeTime from "dayjs/plugin/relativeTime";
import { theme } from "@/theme";
import { type Metadata, MetaContext } from "@/utils/useMetadata";
import type { Route } from "./+types/root";
import "dayjs/locale/en";
import "dayjs/locale/zh-cn";
dayjs.extend(duration);
dayjs.extend(relativeTime);
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
{import.meta.env.DEV && (
<script src="https://unpkg.com/react-scan/dist/auto.global.js"></script>
)}
</head>
<body style={{ colorScheme: "dark" }}>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function App() {
const [metadata, setMetadata] = useState<Metadata>({
title: "Mantine App",
});
useDocumentTitle(metadata.title ?? "Mantine App");
return (
<MantineProvider theme={theme} withCssVariables>
<ContextMenuProvider>
<MetaContext.Provider value={[metadata, setMetadata]}>
<Notifications />
<Outlet />
</MetaContext.Provider>
</ContextMenuProvider>
</MantineProvider>
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
let message = "Oops!";
let details = "An unexpected error occurred.";
let stack: string | undefined;
if (isRouteErrorResponse(error)) {
message = error.status === 404 ? "404" : "Error";
details =
error.status === 404
? "The requested page could not be found."
: error.statusText || details;
} else if (error && error instanceof Error) {
if (import.meta.env.DEV) {
details = error.message;
stack = error.stack;
}
}
return (
<main>
<h1>{message}</h1>
<p>{details}</p>
{stack && (
<pre>
<code>{stack}</code>
</pre>
)}
</main>
);
}

14
app/routes.ts Normal file
View File

@@ -0,0 +1,14 @@
import {
type RouteConfig,
index,
layout,
route,
} from "@react-router/dev/routes";
export default [
layout("./layouts/Main.layout.tsx", [
index("./pages/Search.page.tsx"),
route("settings", "./pages/Settings.page.tsx"),
route("paragraph/:id", "./pages/Paragraph.page.tsx"),
]),
] satisfies RouteConfig;

41
app/store/config.ts Normal file
View File

@@ -0,0 +1,41 @@
import { type ExtractState, create } from "zustand";
import { combine, devtools, persist } from "zustand/middleware";
export type ConfigStore = ExtractState<typeof useConfigStore>;
const useConfigStore = create(
devtools(
persist(
combine(
{
meilisearchUrl: "https://meilisearch.yoshino-s.xyz/",
meilisearchToken:
"70014cdf1f1fb94b6ed420e11abf2e74e0dfa7bc00ddd77f213599c50bd1e26f",
s3Url: "https://minio-hdd.yoshino-s.xyz/",
enableHybridSearch: true,
},
(set) => ({
setMeilisearchUrl: (url: string | undefined) =>
set((state) => ({
meilisearchUrl: url ?? state.meilisearchUrl,
})),
setMeilisearchToken: (token: string | undefined) =>
set((state) => ({
meilisearchToken: token ?? state.meilisearchToken,
})),
setS3Url: (url: string | undefined) =>
set((state) => ({
s3Url: url ?? state.s3Url,
})),
setEnableHybridSearch: (enable: boolean | undefined) =>
set((state) => ({
enableHybridSearch: enable ?? state.enableHybridSearch,
})),
}),
),
{ name: "ds-pages" },
),
),
);
export default useConfigStore;

1
app/store/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from "./config";

5
app/theme.ts Normal file
View File

@@ -0,0 +1,5 @@
import { createTheme } from "@mantine/core";
export const theme = createTheme({
/** Put your mantine theme override here */
});

View File

@@ -1,6 +1,6 @@
declare interface Paragraph {
_id: string;
"@timestamp": string;
id: string;
time: string;
content: string;
markdown: string;
title: string;

View File

@@ -0,0 +1,11 @@
import { createContext, useContext } from "react";
import type { Meilisearch } from "meilisearch";
export const MeilisearchContext = createContext<Meilisearch | null>(null);
export const MeilisearchProvider = MeilisearchContext.Provider;
export function useMeilisearch() {
return useContext(MeilisearchContext);
}

17
app/utils/remark.ts Normal file
View File

@@ -0,0 +1,17 @@
import rehypeStarryNight from "rehype-starry-night";
import rehypeStringify from "rehype-stringify";
import remarkGfm from "remark-gfm";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import { unified } from "unified";
export async function markdownToHtml(markdown: string) {
const result = await unified()
.use(remarkParse)
.use(remarkRehype)
.use(remarkGfm)
.use(rehypeStarryNight)
.use(rehypeStringify)
.process(markdown);
return result.toString();
}

27
app/utils/useMetadata.ts Normal file
View File

@@ -0,0 +1,27 @@
import type React from "react";
import {
type Dispatch,
type SetStateAction,
createContext,
useContext,
} from "react";
export interface Metadata {
title?: string;
description?: string;
rightActions?: React.ReactNode;
withTabs?: boolean;
}
export const MetaContext = createContext<
[Metadata, Dispatch<SetStateAction<Metadata>>]
>([{}, () => {}]);
export default function useMetadata(): [
Metadata,
Dispatch<SetStateAction<Metadata>>,
] {
const [meta, setMeta] = useContext(MetaContext);
return [meta, setMeta];
}

View File

@@ -1,14 +0,0 @@
server {
listen 80;
autoindex off;
server_name _;
server_tokens off;
location / {
root /static;
index index.html;
try_files $uri $uri/ /index.html;
}
}

78
eslint.config.js Normal file
View File

@@ -0,0 +1,78 @@
import eslint from "@eslint/js";
import * as pluginImportX from "eslint-plugin-import-x";
import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended";
import * as reactHooks from "eslint-plugin-react-hooks";
import tsEslint from "typescript-eslint";
export default tsEslint.config(
eslint.configs.recommended,
tsEslint.configs.recommended,
pluginImportX.flatConfigs.recommended,
pluginImportX.flatConfigs.typescript,
reactHooks.configs["recommended-latest"],
eslintPluginPrettierRecommended,
{
rules: {
"prettier/prettier": ["error", {}],
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/consistent-type-imports": "error",
"import-x/no-named-as-default-member": "off",
"import-x/order": [
"error",
{
pathGroupsExcludedImportTypes: ["builtin"],
pathGroups: [
{
pattern: "@/**",
group: "external",
position: "after",
},
{
pattern: "{react,react-router}",
group: "external",
position: "before",
},
{
pattern: "{{@mantine,mantine-*,react-icons}/**,mantine-*}",
group: "external",
position: "before",
},
{
pattern: "{@connectrpc,@tanstack,@buf,@bufbuild}/**",
group: "external",
position: "before",
},
],
"newlines-between": "always",
alphabetize: {
order: "asc",
},
named: {
enabled: true,
types: "types-first",
},
},
],
},
languageOptions: {
parserOptions: {
project: ["./tsconfig.eslint.json"],
tsconfigRootDir: import.meta.dirname,
},
},
},
{
ignores: [
"node_modules",
"dist",
".react-router",
"app/gen",
"build",
"output",
],
},
);

View File

@@ -1,13 +0,0 @@
<!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>DS-Next</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -1,11 +0,0 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'jest-environment-jsdom',
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
moduleNameMapper: {
'^@test-utils': '<rootDir>/test-utils',
},
transform: {
'^.+\\.ts?$': 'ts-jest',
},
};

View File

@@ -1,26 +0,0 @@
require('@testing-library/jest-dom/extend-expect');
const { getComputedStyle } = window;
window.getComputedStyle = (elt) => getComputedStyle(elt);
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
window.ResizeObserver = ResizeObserver;

View File

@@ -1,95 +1,118 @@
{
"name": "codesecer-ui",
"name": "ds-pages",
"private": true,
"version": "0.0.1",
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"dev": "react-router dev --watch",
"build": "react-router build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"lint": "eslint src",
"jest": "jest",
"jest:watch": "jest --watch",
"test": "pnpm typecheck && pnpm lint",
"storybook": "NODE_OPTIONS=--openssl-legacy-provider start-storybook -p 6006",
"build-storybook": "build-storybook",
"chromatic": "npx chromatic --project-token=180ac2186305"
"lint": "pnpm run '/lint:.*/'",
"lint:eslint": "eslint . --ext .ts,.tsx --cache",
"lint:stylelint": "stylelint '**/*.css' --cache && prettier --check 'app/**/*.css' --tab-width 4",
"lint-fix": "pnpm run \"/lint-fix:.*/\"",
"lint-fix:eslint": "eslint . --ext .ts,.tsx --fix --cache",
"lint-fix:stylelint": "stylelint '**/*.css' --fix --cache && prettier --write 'app/**/*.css' --tab-width 4",
"vitest": "vitest run",
"vitest:watch": "vitest",
"test": "pnpm run '/^(typecheck|lint)$/'",
"prepare": "husky"
},
"dependencies": {
"@mantine/core": "^7.6.2",
"@mantine/hooks": "^7.6.2",
"@mantine/notifications": "^7.6.2",
"@reduxjs/toolkit": "^2.2.1",
"@sentry/react": "^7.108.0",
"@sentry/vite-plugin": "^2.16.0",
"@tabler/icons-react": "^3.1.0",
"axios": "^1.6.8",
"dayjs": "^1.11.10",
"i18next": "^23.10.1",
"i18next-browser-languagedetector": "^7.2.0",
"lodash": "^4.17.21",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-i18next": "^14.1.0",
"react-redux": "^9.1.0",
"react-router-dom": "^6.22.3",
"remark": "^15.0.1",
"remark-html": "^16.0.1"
"@mantine/core": "^8.1.2",
"@mantine/dates": "^8.1.2",
"@mantine/form": "^8.1.2",
"@mantine/hooks": "^8.1.2",
"@mantine/modals": "^8.1.2",
"@mantine/notifications": "^8.1.2",
"@mantine/spotlight": "^8.1.2",
"@meilisearch/instant-meilisearch": "^0.27.0",
"@microlink/react-json-view": "^1.26.2",
"@monaco-editor/react": "4.7.0",
"@radix-ui/react-slot": "^1.2.3",
"@react-router/node": "^7.6.3",
"@react-router/serve": "^7.6.3",
"@tanstack/react-query": "^5.81.5",
"@types/canvas-confetti": "^1.9.0",
"@types/sarif": "^2.1.7",
"axios": "^1.10.0",
"canvas-confetti": "^1.9.3",
"class-transformer": "^0.5.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"instantsearch.js": "^4.79.1",
"isbot": "^5.1.28",
"js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.525.0",
"mantine-contextmenu": "^8.1.1",
"mantine-datatable": "^8.1.2",
"meilisearch": "^0.51.0",
"monaco-editor": "^0.52.2",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-icons": "^5.5.0",
"react-instantsearch": "^7.16.1",
"react-router": "^7.6.3",
"react-router-dom": "^7.6.3",
"reflect-metadata": "^0.2.2",
"rehype-starry-night": "^2.2.0",
"rehype-stringify": "^10.0.1",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2",
"unified": "^11.0.5",
"zustand": "^5.0.6"
},
"devDependencies": {
"@babel/core": "^7.24.1",
"@mantine/form": "^7.6.2",
"@storybook/addon-actions": "^8.0.2",
"@storybook/addon-essentials": "^8.0.2",
"@storybook/addon-interactions": "^8.0.2",
"@storybook/addon-links": "^8.0.2",
"@storybook/addons": "^7.6.17",
"@storybook/api": "^7.6.17",
"@storybook/builder-vite": "^8.0.2",
"@storybook/components": "^8.0.2",
"@storybook/core-events": "^8.0.2",
"@storybook/react": "^8.0.2",
"@storybook/testing-library": "^0.2.2",
"@storybook/theming": "^8.0.2",
"@testing-library/dom": "^9.3.4",
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^14.2.2",
"@testing-library/user-event": "^14.5.2",
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.0",
"@types/react": "^18.2.67",
"@types/react-dom": "^18.2.22",
"@typescript-eslint/eslint-plugin": "^7.3.1",
"@typescript-eslint/parser": "^7.3.1",
"@vitejs/plugin-react": "^4.2.1",
"babel-loader": "^9.1.3",
"chromatic": "^11.1.1",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-import-resolver-typescript": "^3.6.1",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-jsx-a11y": "^6.8.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-react": "^7.34.1",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6",
"eslint-plugin-storybook": "^0.8.0",
"i18next-http-backend": "^2.5.0",
"install-peerdeps": "^3.0.3",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"postcss": "^8.4.37",
"postcss-preset-mantine": "^1.13.0",
"@eslint/js": "^9.30.0",
"@react-router/dev": "^7.6.3",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/js-yaml": "^4.0.9",
"@types/json-bigint": "^1.0.4",
"@types/node": "^24.0.7",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@typescript-eslint/eslint-plugin": "^8.35.0",
"@typescript-eslint/parser": "^8.35.0",
"eslint": "^9.30.0",
"eslint-config-prettier": "^10.1.5",
"eslint-import-resolver-alias": "^1.1.2",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import-x": "^4.16.1",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.5.1",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^6.0.0",
"husky": "^9.1.7",
"identity-obj-proxy": "^3.0.0",
"jsdom": "^26.1.0",
"postcss": "^8.5.6",
"postcss-import": "^16.1.1",
"postcss-preset-mantine": "1.17.0",
"postcss-simple-vars": "^7.0.1",
"prettier": "^3.2.5",
"react-router": "^6.22.3",
"storybook": "^8.0.2",
"storybook-dark-mode": "^4.0.1",
"storybook-react-i18next": "3.0.1",
"stylis-plugin-rtl": "^2.1.1",
"ts-jest": "^29.1.2",
"typescript": "^5.4.2",
"vite": "^5.2.0"
"prettier": "^3.6.2",
"prop-types": "^15.8.1",
"stylelint": "^16.21.0",
"stylelint-config-standard-scss": "^15.0.1",
"tw-animate-css": "^1.3.4",
"typescript": "^5.8.3",
"typescript-eslint": "^8.35.0",
"vite": "^7.0.0",
"vite-plugin-devtools-json": "^0.2.0",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.2.4"
},
"pnpm": {
"onlyBuiltDependencies": [
"es5-ext",
"esbuild",
"unrs-resolver"
]
}
}

17715
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
// eslint-disable-next-line no-undef
export default {
module.exports = {
plugins: {
"postcss-preset-mantine": {},
"postcss-simple-vars": {

6
react-router.config.ts Normal file
View File

@@ -0,0 +1,6 @@
import type { Config } from "@react-router/dev/config";
export default {
appDirectory: "app",
ssr: false,
} satisfies Config;

View File

@@ -1,13 +0,0 @@
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>
);
}

View File

@@ -1,72 +0,0 @@
import { notifications } from "@mantine/notifications";
import axios, { AxiosResponse } from "axios";
import { merge } from "lodash";
export interface PaginationParams {
skip?: number;
take?: number;
}
const api = axios.create({
withCredentials: false,
auth: {
username: "viewer",
password: "publicviewer1",
},
});
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 merge(data._source, {
_id: data._id,
"@timestamp": data["@timestamp"],
});
}
}

View File

@@ -1,71 +0,0 @@
import { Pagination } from "@mantine/core";
import { useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { useOptionsState } from "@/store/module/options";
import { useDebounceCallback, useMediaQuery } from "@mantine/hooks";
import { merge } from "lodash";
import { SearchApi } from "./api";
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 [take, _] = useState(parseInt(params.get("size") || "10"));
const [page, setPage] = useState(parseInt(params.get("page") || "1"));
const isMobile = useMediaQuery("(max-width: 768px)");
const update = useDebounceCallback(async function update() {
console.log("query", query, page, take, options);
const resp = await SearchApi.search(
options.zincsearchUrl,
merge(
{
search_type: "matchall",
sort_fields: ["-@timestamp"],
_source: ["title", "cover", "author", "tags"],
},
query,
{
from: (page - 1) * take,
max_results: take,
},
),
);
setTotal(resp.hits.total.value);
setData(
resp.hits.hits.map((hit) =>
SearchApi.wrapParagraph(
options.s3Url,
merge({ _id: hit._id, "@timestamp": hit["@timestamp"] }, hit._source),
),
) as T[],
);
}, 200);
useEffect(update, [query, page, take, options, update]);
useEffect(() => {
setParams({
size: take.toString(),
page: page.toString(),
});
}, [take, page, setParams]);
return {
data,
page,
pagination: (
<Pagination
size={isMobile ? "sm" : "md"}
total={Math.ceil(total / take)}
onChange={setPage}
value={page}
/>
),
};
}

View File

@@ -1,112 +0,0 @@
import {
Affix,
AppShell,
Avatar,
Button,
Center,
Group,
Text,
TextInput,
Transition,
UnstyledButton,
rem,
} from "@mantine/core";
import { Suspense, useCallback, useState } from "react";
import { Outlet, useNavigate } from "react-router";
import { TitleContext } from "@/component/Header/Header";
import Loading from "@/page/Loading";
import { useForm } from "@mantine/form";
import { useHeadroom, useMediaQuery, useWindowScroll } from "@mantine/hooks";
import { IconArrowUp, IconSearch, IconSettings } from "@tabler/icons-react";
import { Link } from "react-router-dom";
export default function MainLayout() {
const [title, setTitle] = useState("");
const pinned = useHeadroom({ fixedAt: 60 });
const [scroll, scrollTo] = useWindowScroll();
const isMobile = useMediaQuery("(max-width: 768px)");
const navigate = useNavigate();
const form = useForm({
initialValues: {
search: "",
},
});
const search = useCallback(
function submit({ search }: { search: string }) {
console.log(search);
navigate(`/search/${encodeURIComponent(search)}`);
},
[navigate],
);
return (
<TitleContext.Provider value={[title, setTitle]}>
<AppShell
header={{ height: 60, collapsed: !pinned, offset: false }}
padding="md"
h="100vh"
>
<AppShell.Header>
<Group h="100%" justify="space-between" px="md">
<Group>
<Avatar fw={700} component={Link} to="/">
DS
</Avatar>
{!isMobile && (
<Text size="lg" fw={700} ml="sm">
{title}
</Text>
)}
</Group>
<Group>
<form onSubmit={form.onSubmit(search)}>
<TextInput
placeholder="Search"
{...form.getInputProps("search")}
leftSection={
<IconSearch
style={{ width: rem(16), height: rem(16) }}
stroke={1.5}
/>
}
/>
</form>
<UnstyledButton component={Link} to="/settings">
<Center>
<IconSettings />
</Center>
</UnstyledButton>
</Group>
</Group>
</AppShell.Header>
<AppShell.Main pt={`calc(${rem(60)} + var(--mantine-spacing-md))`}>
<Suspense fallback={<Loading />}>
<Outlet />
</Suspense>
</AppShell.Main>
</AppShell>
<Affix position={{ bottom: 20, right: 20 }}>
<Transition transition="slide-up" mounted={scroll.y > 0}>
{(transitionStyles) => (
<Button
leftSection={
<IconArrowUp style={{ width: rem(16), height: rem(16) }} />
}
style={transitionStyles}
onClick={() => scrollTo({ y: 0 })}
>
Scroll to top
</Button>
)}
</Transition>
</Affix>
</TitleContext.Provider>
);
}

View File

@@ -1,23 +0,0 @@
import { Notifications } from "@mantine/notifications";
import ReactDOM from "react-dom/client";
import { Provider } from "react-redux";
import { MantineProvider, createTheme } from "@mantine/core";
import App from "./App";
import "./sentry";
import store from "./store";
import "@mantine/core/styles.css";
const theme = createTheme({
/** Put your mantine theme override here */
});
ReactDOM.createRoot(document.getElementById("root")!).render(
<Provider store={store}>
<MantineProvider withCssVariables theme={theme}>
<Notifications />
<App />
</MantineProvider>
</Provider>,
);

View File

@@ -1,40 +0,0 @@
.root {
padding-top: rem(80px);
padding-bottom: rem(120px);
background-color: var(--mantine-color-blue-filled);
}
.label {
text-align: center;
font-weight: 900;
font-size: rem(220px);
line-height: 1;
margin-bottom: calc(var(--mantine-spacing-xl) * 1.5);
color: var(--mantine-color-blue-3);
@media (max-width: $mantine-breakpoint-sm) {
font-size: rem(120px);
}
}
.title {
font-family:
Greycliff CF,
var(--mantine-font-family);
text-align: center;
font-weight: 900;
font-size: rem(38px);
color: var(--mantine-color-white);
@media (max-width: $mantine-breakpoint-sm) {
font-size: rem(32px);
}
}
.description {
max-width: rem(540px);
margin: auto;
margin-top: var(--mantine-spacing-xl);
margin-bottom: calc(var(--mantine-spacing-xl) * 1.5);
color: var(--mantine-color-blue-1);
}

View File

@@ -1,51 +0,0 @@
import { Container, Grid, Group } from "@mantine/core";
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 default function SearchPage() {
const [_title, setTitle] = useContext(TitleContext);
const params = useLoaderData() as ZincQueryForSDK;
const {
page,
pagination,
data: paragraphs,
} = usePaginationData<Paragraph>(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 ${page}`;
setTitle(title);
}, [page, location, param, setTitle]);
return (
<Container>
<Grid my="md">
{paragraphs.map((paragraph) => {
return (
<Grid.Col span={{ base: 12, sm: 6 }} key={paragraph._id}>
<ParagraphCard {...paragraph} key={`${paragraph._id}_card`} />
</Grid.Col>
);
})}
</Grid>
<Group justify="center">{pagination}</Group>
</Container>
);
}

View File

@@ -1,96 +0,0 @@
import { Container, 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";
interface SettingItem {
title: string;
description: string;
value: ReactNode;
}
export default function SettingsPage() {
const [_, setTitle] = useContext(TitleContext);
const { state: options } = useOptionsState();
useEffect(() => {
setTitle("Settings");
}, [setTitle]);
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));
}}
/>
),
},
{
title: "Made By",
description: "Yoshino-s",
value: (
<a href="https://github.com/yoshino-s">https://github.com/yoshino-s</a>
),
},
];
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" striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Name</Table.Th>
<Table.Th>Value</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{settings.map((setting) => (
<Table.Tr key={`${setting.title}`}>
<Table.Td>
<Text size="md" fw={500}>
{setting.title}
</Text>
<Text c="dimmed" size="sm">
{setting.description}
</Text>
</Table.Td>
<Table.Td>{setting.value}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Paper>
</Container>
);
}

View File

@@ -1,116 +0,0 @@
import { lazy } from "react";
import { createHashRouter } 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(() => import("@/page/Exception/NotFound"));
const ErrorPage = lazy(() => import("@/page/Exception/ErrorPage"));
const LoadingPage = lazy(async () => import("@/page/Loading"));
const ParagraphPage = lazy(async () => import("@/page/Paragraph"));
const SettingsPage = lazy(async () => import("@/page/Settings"));
const router = createHashRouter([
{
path: "/",
element: <MainLayout />,
errorElement: <ErrorPage />,
children: [
{
path: "/",
element: <SearchPage />,
loader() {
return {};
},
},
{
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;

View File

@@ -1,22 +0,0 @@
import * as Sentry from "@sentry/react";
Sentry.init({
dsn: "https://fee4fec58516c464f60613b40b5d3a7d@sentry.yoshino-s.xyz/2",
integrations: [
Sentry.browserProfilingIntegration(),
Sentry.browserTracingIntegration(),
Sentry.replayIntegration({
maskAllText: false,
blockAllMedia: false,
}),
],
// Performance Monitoring
tracesSampleRate: 1.0, // Capture 100% of the transactions
// Set 'tracePropagationTargets' to control for which URLs distributed tracing should be enabled
tracePropagationTargets: ["localhost", "ds.pages.yoshino-s.xyz"],
// Session Replay
replaysSessionSampleRate: 1.0, // This sets the sample rate at 10%. You may want to change it to 100% while in development and then sample at a lower rate in production.
replaysOnErrorSampleRate: 1.0, // If you're not already sampling the entire session, change the sample rate to 100% when sampling sessions where errors occur.
profilesSampleRate: 1.0, // Capture 100% of the profiles
});

View File

@@ -1,36 +0,0 @@
import { configureStore, Middleware } from "@reduxjs/toolkit";
import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
import optionsReducer from "./reducer/options";
const localStorageMiddleware: Middleware = ({ getState }) => {
return (next) => (action) => {
const result = next(action);
console.log(result);
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,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(localStorageMiddleware),
preloadedState: reHydrateStore() as () => any,
});
type AppState = ReturnType<typeof store.getState>;
type AppDispatch = typeof store.dispatch;
export const useAppSelector: TypedUseSelectorHook<AppState> = useSelector;
export const useAppDispatch: () => AppDispatch = useDispatch;
export default store;

View File

@@ -1,11 +0,0 @@
import store, { useAppSelector } from "..";
export const useOptionsState = () => {
const state = useAppSelector((state) => state.options);
return {
state,
getState: () => {
return store.getState().options;
},
};
};

View File

@@ -1,29 +0,0 @@
import { PayloadAction, createSlice } from "@reduxjs/toolkit";
export interface OptionsState {
zincsearchUrl: string;
s3Url: string;
}
const ZINCSEARCH_URL = "https://zincsearch.yoshino-s.xyz";
const MINIO_URL = "https://minio-hdd.yoshino-s.xyz";
const optionsSlice = createSlice({
name: "stats",
initialState: {
zincsearchUrl: ZINCSEARCH_URL,
s3Url: MINIO_URL,
} as OptionsState,
reducers: {
setZincsearchUrl: (state, action: PayloadAction<string | undefined>) => {
state.zincsearchUrl = action.payload ?? ZINCSEARCH_URL;
},
setS3Url: (state, action: PayloadAction<string | undefined>) => {
state.s3Url = action.payload ?? MINIO_URL;
},
},
});
export const { setS3Url, setZincsearchUrl } = optionsSlice.actions;
export default optionsSlice.reducer;

16
tsconfig.eslint.json Normal file
View File

@@ -0,0 +1,16 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"allowJs": true,
},
"include": [
".storybook/**/*",
"app",
"test-utils",
"vitest",
"*.js",
"*.cjs",
"*.mts",
"*.ts",
],
}

View File

@@ -1,24 +1,30 @@
{
"include": [
"**/*",
"**/.server/**/*",
"**/.client/**/*",
".react-router/types/**/*"
],
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"types": ["node", "vite/client"],
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"rootDirs": [".", "./.react-router/types"],
"baseUrl": ".",
"paths": {
"@test-utils": ["./test-utils"],
"@/*": ["./src/*"]
}
"@/*": ["./app/*"]
},
"references": [{ "path": "./tsconfig.node.json" }]
"incremental": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"esModuleInterop": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true
}
}

View File

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

1
tsconfig.tsbuildinfo Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1,32 +1,17 @@
import { sentryVitePlugin } from "@sentry/vite-plugin";
import { resolve } from "path";
import react from "@vitejs/plugin-react";
import { reactRouter } from "@react-router/dev/vite";
import { defineConfig } from "vite";
import devtoolsJson from "vite-plugin-devtools-json";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
appType: "spa",
server: {
headers: {
"Document-Policy": "js-profiling",
cors: true,
},
},
plugins: [
react(),
sentryVitePlugin({
org: "sentry",
project: "ds-viewer",
url: "https://sentry.yoshino-s.xyz",
}),
],
resolve: {
alias: {
"@/": `${resolve(__dirname, "src")}/`,
},
},
plugins: [reactRouter(), devtoolsJson(), tsconfigPaths()],
build: {
sourcemap: true,
assetsDir: "static",
manifest: true,
},
});