This commit is contained in:
2025-08-08 17:14:09 +08:00
parent 5fc4d09c39
commit 7f34a2f4a0
71 changed files with 6725 additions and 5646 deletions

View File

@@ -0,0 +1,6 @@
import type { Dispatch, SetStateAction } from "react";
import { createContext } from "react";
export const TitleContext = createContext<
[string, Dispatch<SetStateAction<string>>]
>(["DS-Next", () => 0]);

View File

@@ -0,0 +1,29 @@
.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

@@ -0,0 +1,53 @@
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,
time,
author,
tags,
id,
}: Paragraph) {
const url = `/paragraph/${id}`;
cover = useContentFix(cover) ?? "";
return (
<Card withBorder radius="md" padding="lg" shadow="sm">
<Card.Section>
<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} target="_blank">
{title}
</Text>
<Group>
{tags.map((tag, index) => (
<Badge key={index}>{tag}</Badge>
))}
</Group>
</Group>
<Group>
<Group>
<Text size="xs">{author}</Text>
</Group>
<Text size="xs" c="dimmed">
</Text>
<Text size="xs" c="dimmed">
{dayjs().to(dayjs(time))}
</Text>
</Group>
</Card>
);
}

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

@@ -0,0 +1,47 @@
import {
Box,
Center,
SegmentedControl,
useMantineColorScheme,
} from "@mantine/core";
import { TbMoon, TbRobot, TbSun } from "react-icons/tb";
export function ThemeSetting() {
const { colorScheme, setColorScheme } = useMantineColorScheme();
return (
<SegmentedControl
value={colorScheme}
onChange={(v) => setColorScheme(v as any)}
data={[
{
value: "light",
label: (
<Center>
<TbSun size="1rem" />
<Box ml={10}>Light</Box>
</Center>
),
},
{
value: "auto",
label: (
<Center>
<TbRobot size="1rem" />
<Box ml={10}>Auto</Box>
</Center>
),
},
{
value: "dark",
label: (
<Center>
<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,40 @@
.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

@@ -0,0 +1,28 @@
import { Button, Container, Group, Text, Title } from "@mantine/core";
import classes from "./ErrorPage.module.css";
export interface ErrorPageProps {
label?: string | null;
title?: string | null;
description?: string | null;
}
export default function ErrorPage(props: ErrorPageProps) {
return (
<div className={classes.root}>
<Container>
<div className={classes.label}>{props.label}</div>
<Title className={classes.title}>{props.title}</Title>
<Text size="lg" ta="center" className={classes.description}>
{props.description}
</Text>
<Group justify="center">
<Button variant="white" size="md">
Refresh the page
</Button>
</Group>
</Container>
</div>
);
}

View File

@@ -0,0 +1,11 @@
import ErrorPage from "./ErrorPage";
export default 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."
/>
);
}

13
app/pages/Loading.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { LoadingOverlay } from "@mantine/core";
export default function Loading() {
return (
<div
style={{
height: "100vh",
}}
>
<LoadingOverlay visible />
</div>
);
}

View File

@@ -0,0 +1,141 @@
import { useEffect, useState } from "react";
import {
Badge,
Container,
Group,
Text,
Title,
TypographyStylesProvider,
} from "@mantine/core";
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");
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;
}
export default function ParagraphPage() {
const { id } = useParams<{ id: string }>();
const s3Url = useConfigStore((store) => store.s3Url);
const meilisearch = useMeilisearch();
const [paragraph, setParagraph] = useState<Paragraph | null>(null);
useEffect(() => {
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>
<Group justify="space-between" align="center" my="md">
<Group>
<Text size="sm" c="dimmed">
{" "}
{dayjs().to(dayjs(paragraph?.time))}
</Text>
<Text
ml="1rem"
size="sm"
component={Link}
to={`/author/${encodeURIComponent(paragraph?.author || "unknown")}`}
>
{paragraph?.author}
</Text>
</Group>
<Group>
{paragraph?.tags.map((tag, index) => (
<>
<Badge
size="sm"
component={Link}
key={index}
to={`/tag/${encodeURIComponent(tag)}`}
>
{tag}
</Badge>
</>
))}
{paragraph?.source_url && (
<a href={paragraph?.source_url}>Goto Source</a>
)}
</Group>
</Group>
<TypographyStylesProvider
style={{
paddingLeft: 0,
}}
>
<div
style={{
lineBreak: "anywhere",
}}
dangerouslySetInnerHTML={{
__html: paragraph?.content ?? "<p>Content not found</p>",
}}
/>
</TypographyStylesProvider>
</Container>
);
}

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 (e) {
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 (e) {
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 */
});

12
app/types/paragraph.d.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
declare interface Paragraph {
id: string;
time: string;
content: string;
markdown: string;
title: string;
author: string;
cover: string;
time: string;
tags: string[];
source_url?: string;
}

43
app/types/search.d.ts vendored Normal file
View File

@@ -0,0 +1,43 @@
declare interface ZincQueryForSDK {
_source?: boolean | string[];
explain?: boolean;
from?: number;
max_results?: number;
search_type?:
| "matchall"
| "alldocument"
| "match"
| "matchphrase"
| "querystring"
| "prefix"
| "wildcard"
| "fuzzy"
| "datarange";
sort_fields?: string[];
query?: {
boost?: number;
end_time?: string;
field?: string;
start_time?: string;
term?: string;
terms: string[];
};
}
declare interface SearchResponse {
error?: string;
hits: {
max_score: number;
total: {
value: number;
};
hits: {
_source: Paragraph;
_index: string;
_type: string;
_id: string;
_score: string;
"@timestamp": string;
}[];
};
}

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

1
app/vite-env.d.ts vendored Normal file
View File

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