update
This commit is contained in:
6
app/component/Header/Header.tsx
Normal file
6
app/component/Header/Header.tsx
Normal 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]);
|
||||
29
app/component/Hits/Hits.module.css
Normal file
29
app/component/Hits/Hits.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
21
app/component/Hits/Hits.tsx
Normal file
21
app/component/Hits/Hits.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
app/component/Pagination/Pagination.tsx
Normal file
20
app/component/Pagination/Pagination.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
app/component/ParagraphCard/ParagraphCard.tsx
Normal file
53
app/component/ParagraphCard/ParagraphCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
130
app/component/Refinement/Refinement.tsx
Normal file
130
app/component/Refinement/Refinement.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
app/component/ScrollTop/ScrollTop.tsx
Normal file
25
app/component/ScrollTop/ScrollTop.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
app/component/SearchBox/SearchBox.tsx
Normal file
41
app/component/SearchBox/SearchBox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
app/component/Settings/Theme.tsx
Normal file
47
app/component/Settings/Theme.tsx
Normal 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
9
app/constants.ts
Normal 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
14
app/entry.client.tsx
Normal 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
71
app/entry.server.tsx
Normal 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);
|
||||
});
|
||||
}
|
||||
9
app/hooks/useContentFix.ts
Normal file
9
app/hooks/useContentFix.ts
Normal 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
129
app/layouts/Main.layout.tsx
Normal 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
7
app/main.css
Normal 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);
|
||||
40
app/pages/Exception/ErrorPage.module.css
Normal file
40
app/pages/Exception/ErrorPage.module.css
Normal 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);
|
||||
}
|
||||
28
app/pages/Exception/ErrorPage.tsx
Normal file
28
app/pages/Exception/ErrorPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
app/pages/Exception/NotFound.tsx
Normal file
11
app/pages/Exception/NotFound.tsx
Normal 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
13
app/pages/Loading.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { LoadingOverlay } from "@mantine/core";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: "100vh",
|
||||
}}
|
||||
>
|
||||
<LoadingOverlay visible />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
141
app/pages/Paragraph.page.tsx
Normal file
141
app/pages/Paragraph.page.tsx
Normal 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
19
app/pages/Search.page.tsx
Normal 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
220
app/pages/Settings.page.tsx
Normal 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
97
app/root.tsx
Normal 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
14
app/routes.ts
Normal 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
41
app/store/config.ts
Normal 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
1
app/store/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./config";
|
||||
5
app/theme.ts
Normal file
5
app/theme.ts
Normal 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
12
app/types/paragraph.d.ts
vendored
Normal 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
43
app/types/search.d.ts
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
declare interface ZincQueryForSDK {
|
||||
_source?: boolean | string[];
|
||||
explain?: boolean;
|
||||
from?: number;
|
||||
max_results?: number;
|
||||
search_type?:
|
||||
| "matchall"
|
||||
| "alldocument"
|
||||
| "match"
|
||||
| "matchphrase"
|
||||
| "querystring"
|
||||
| "prefix"
|
||||
| "wildcard"
|
||||
| "fuzzy"
|
||||
| "datarange";
|
||||
sort_fields?: string[];
|
||||
query?: {
|
||||
boost?: number;
|
||||
end_time?: string;
|
||||
field?: string;
|
||||
start_time?: string;
|
||||
term?: string;
|
||||
terms: string[];
|
||||
};
|
||||
}
|
||||
|
||||
declare interface SearchResponse {
|
||||
error?: string;
|
||||
hits: {
|
||||
max_score: number;
|
||||
total: {
|
||||
value: number;
|
||||
};
|
||||
hits: {
|
||||
_source: Paragraph;
|
||||
_index: string;
|
||||
_type: string;
|
||||
_id: string;
|
||||
_score: string;
|
||||
"@timestamp": string;
|
||||
}[];
|
||||
};
|
||||
}
|
||||
11
app/utils/meilisearchContext.ts
Normal file
11
app/utils/meilisearchContext.ts
Normal 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
17
app/utils/remark.ts
Normal 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
27
app/utils/useMetadata.ts
Normal 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
1
app/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user