=> {
32 | const { slug } = await params
33 | const url = slug.join('/')
34 | const blog = getPostBySlug(url)
35 |
36 | return generateKunMetadataTemplate(blog)
37 | }
38 |
39 | export default async function Kun({ params }: Props) {
40 | const { slug } = await params
41 | const url = slug.join('/')
42 | const { content, frontmatter } = getPostBySlug(url)
43 | const { prev, next } = getAdjacentPosts(url)
44 |
45 | return (
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
60 |
61 | )
62 | }
63 |
--------------------------------------------------------------------------------
/frontend/app/docs/layout.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode } from 'react'
2 |
3 | import { KunSidebar } from '@/components/docs/Sidebar'
4 | import { getDirectoryTree } from '@/lib/mdx/directoryTree'
5 |
6 | interface LayoutProps {
7 | children: ReactNode
8 | }
9 |
10 | export default function Layout({ children }: LayoutProps) {
11 | const tree = getDirectoryTree()
12 |
13 | return (
14 |
15 |
16 |
17 | {children}
18 |
19 |
20 | )
21 | }
22 |
--------------------------------------------------------------------------------
/frontend/app/docs/metadata.ts:
--------------------------------------------------------------------------------
1 | import type { Metadata } from 'next'
2 |
3 | import { t } from '@/i18n'
4 |
5 | export const kunMetadata: Metadata = {
6 | title: t('docsTitle'),
7 | }
8 |
--------------------------------------------------------------------------------
/frontend/app/docs/page.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from 'next'
2 |
3 | import { kunMetadata } from './metadata'
4 |
5 | import { getAllPosts } from '@/lib/mdx/getPosts'
6 | import { KunAboutCard } from '@/components/docs/Card'
7 | import { KunMasonryGrid } from '@/components/MasonryGrid'
8 |
9 | export const metadata: Metadata = kunMetadata
10 |
11 | export default function Kun() {
12 | const posts = getAllPosts()
13 |
14 | return (
15 |
16 |
17 |
18 | {posts.map((post) => (
19 |
20 | ))}
21 |
22 |
23 |
24 | )
25 | }
26 |
--------------------------------------------------------------------------------
/frontend/app/error.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useEffect } from 'react'
4 |
5 | import { t } from '@/i18n'
6 |
7 | export default function Error({
8 | error,
9 | reset,
10 | }: {
11 | error: Error
12 | reset: () => void
13 | }) {
14 | useEffect(() => {
15 | // Log the error to an error reporting service
16 | /* eslint-disable no-console */
17 | console.error(error)
18 | }, [error])
19 |
20 | return (
21 |
22 |
{t('errorSomethingWrong')}
23 |
31 |
32 | )
33 | }
34 |
--------------------------------------------------------------------------------
/frontend/app/files/[...slug]/page.tsx:
--------------------------------------------------------------------------------
1 | import { notFound } from 'next/navigation'
2 |
3 | import { checknodevariety, node2list } from '@/algorithm/tree'
4 | import { Sidebar } from '@/components/sidebar'
5 | import { FileList } from '@/components/fileList'
6 | import { tree } from '@/config/root'
7 | import { RoundArrowButton } from '@/components/returnButton'
8 | import { GameIntro } from '@/components/gameIntro'
9 |
10 | export default async function BrowserPage({
11 | params,
12 | }: {
13 | params: Promise<{ slug: string[] }>
14 | }) {
15 | const origin_slug = (await params).slug
16 | const slug = origin_slug.map(decodeURIComponent)
17 |
18 | origin_slug.pop()
19 | let node: any = tree
20 |
21 | try {
22 | for (const key of slug) {
23 | node = node[key]
24 | }
25 | } catch {
26 | notFound()
27 | }
28 |
29 | let variety = checknodevariety(node)
30 |
31 | if (variety === '404') {
32 | notFound()
33 | }
34 |
35 | const inode = node2list(node)
36 |
37 | return (
38 |
39 |
40 |
41 |
42 | {variety === 'file' ? (
43 |
44 | ) : (
45 |
46 | )}
47 |
48 |
49 | )
50 | }
51 |
--------------------------------------------------------------------------------
/frontend/app/files/layout.tsx:
--------------------------------------------------------------------------------
1 | export default function FilesLayout({
2 | children,
3 | }: {
4 | children: React.ReactNode
5 | }) {
6 | return
7 | }
8 |
--------------------------------------------------------------------------------
/frontend/app/files/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import Link from 'next/link'
4 | import { Card, CardBody } from '@heroui/react'
5 |
6 | import { title } from '@/components/primitives'
7 | import { IndexList } from '@/config/indexList'
8 | import { t } from '@/i18n'
9 |
10 | export default function FilesPage() {
11 | return (
12 |
13 |
{t('allGames')}
14 |
15 | {IndexList.map((item, index) => (
16 |
24 |
25 | {item.body}
26 | {item.title}
27 |
28 |
29 | ))}
30 |
31 |
32 | )
33 | }
34 |
--------------------------------------------------------------------------------
/frontend/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import { Metadata, Viewport } from 'next'
2 | import { Link } from '@heroui/link'
3 | import Script from 'next/script'
4 | import '@/styles/index.css'
5 |
6 | import { Providers } from './providers'
7 |
8 | import { cn } from '@/utils/cn'
9 | import { siteConfig } from '@/config/site'
10 | import { fontSans } from '@/config/fonts'
11 | import { Navbar } from '@/components/navbar'
12 |
13 | export const metadata: Metadata = {
14 | title: {
15 | default: siteConfig.name,
16 | template: `%s - ${siteConfig.name}`,
17 | },
18 | description: siteConfig.description,
19 | icons: {
20 | icon: '/favicon.ico',
21 | },
22 | }
23 |
24 | export const viewport: Viewport = {
25 | themeColor: [
26 | { media: '(prefers-color-scheme: light)', color: 'white' },
27 | { media: '(prefers-color-scheme: dark)', color: 'black' },
28 | ],
29 | }
30 |
31 | export default function RootLayout({
32 | children,
33 | }: {
34 | children: React.ReactNode
35 | }) {
36 | return (
37 |
38 |
39 |
43 |
51 |
56 |
57 |
63 |
67 |
68 |
69 |
70 |
71 | {children}
72 |
73 |
85 |
86 |
87 |
88 |
89 | )
90 | }
91 |
--------------------------------------------------------------------------------
/frontend/app/page.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from '@heroui/link'
2 | import { button as buttonStyles } from '@heroui/theme'
3 |
4 | import { siteConfig } from '@/config/site'
5 | import { title, subtitle } from '@/components/primitives'
6 | import Search from '@/components/search/search'
7 | import { t } from '@/i18n'
8 |
9 | export default function Home() {
10 | return (
11 |
12 |
13 |
14 | {t('websiteName').slice(0, 2)}
15 |
16 |
{t('websiteName').slice(2)}
17 |
18 | {t('pageWelcomeDescription')}
19 |
20 |
21 |
22 |
27 |
28 |
29 |
33 | {t('browseAllGames')}
34 |
35 |
40 | {t('advertisement')}
41 |
42 |
43 |
44 | )
45 | }
46 |
--------------------------------------------------------------------------------
/frontend/app/providers.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import type { ThemeProviderProps } from 'next-themes'
4 |
5 | import * as React from 'react'
6 | import { HeroUIProvider } from '@heroui/system'
7 | import { useRouter } from 'next/navigation'
8 | import { ThemeProvider as NextThemesProvider } from 'next-themes'
9 | import { AppProgressProvider as ProgressProvider } from '@bprogress/next'
10 |
11 | export interface ProvidersProps {
12 | children: React.ReactNode
13 | themeProps?: ThemeProviderProps
14 | }
15 |
16 | export function Providers({ children, themeProps }: ProvidersProps) {
17 | const router = useRouter()
18 |
19 | return (
20 |
21 |
22 |
28 | {children}
29 |
30 |
31 |
32 | )
33 | }
34 |
--------------------------------------------------------------------------------
/frontend/app/search/layout.tsx:
--------------------------------------------------------------------------------
1 | export default function SearchLayout({
2 | children,
3 | }: {
4 | children: React.ReactNode
5 | }) {
6 | return
7 | }
8 |
--------------------------------------------------------------------------------
/frontend/app/search/page.tsx:
--------------------------------------------------------------------------------
1 | import { notFound } from 'next/navigation'
2 |
3 | import Search from '@/components/search/search'
4 | import { SearchAnswer } from '@/components/search/search-answer'
5 | import { ai_search } from '@/algorithm/search'
6 | import { SearchIntro } from '@/components/search/search-intro'
7 |
8 | export default async function SearchPage({
9 | searchParams,
10 | }: {
11 | searchParams: Promise<{ [key: string]: string | string[] | undefined }>
12 | }) {
13 | const q = (await searchParams).q as string
14 | const answer = await ai_search(q, 200)
15 |
16 | if (q) {
17 | return (
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | )
32 | } else {
33 | notFound()
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/frontend/components/MasonryGrid.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useEffect, useRef, useState } from 'react'
4 |
5 | import { useResizeObserver } from '@/hooks/useResizeObserver'
6 | import { cn } from '@/utils/cn'
7 |
8 | interface KunMasonryGridProps {
9 | children: React.ReactNode[]
10 | columnWidth?: number
11 | gap?: number
12 | className?: string
13 | }
14 |
15 | export const KunMasonryGrid = ({
16 | children,
17 | columnWidth = 256,
18 | gap = 24,
19 | className,
20 | }: KunMasonryGridProps) => {
21 | const containerRef = useRef(null)
22 | const [columns, setColumns] = useState(1)
23 | const [isLoaded, setIsLoaded] = useState(false)
24 |
25 | const { width: containerWidth } = useResizeObserver(containerRef)
26 |
27 | useEffect(() => {
28 | const calculateColumns = () => {
29 | if (!containerWidth) {
30 | return
31 | }
32 |
33 | const newColumns = Math.max(
34 | 1,
35 | Math.floor((containerWidth + gap) / (columnWidth + gap)),
36 | )
37 |
38 | setColumns(newColumns)
39 | if (!isLoaded) setIsLoaded(true)
40 | }
41 |
42 | calculateColumns()
43 | }, [containerWidth, columnWidth, gap, isLoaded])
44 |
45 | const distributeItems = () => {
46 | if (!Array.isArray(children)) {
47 | return []
48 | }
49 |
50 | const columnHeights = Array(columns).fill(0)
51 | const columnItems: React.ReactNode[][] = Array(columns)
52 | .fill(null)
53 | .map(() => [])
54 |
55 | children.forEach((child) => {
56 | if (!child) {
57 | return
58 | }
59 | const shortestColumn = columnHeights.indexOf(Math.min(...columnHeights))
60 |
61 | columnItems[shortestColumn].push(child)
62 | columnHeights[shortestColumn]++
63 | })
64 |
65 | return columnItems
66 | }
67 |
68 | return (
69 |
82 | {distributeItems().map((column, columnIndex) => (
83 |
88 | {column.map((item, itemIndex) => (
89 |
90 | {item}
91 |
92 | ))}
93 |
94 | ))}
95 |
96 | )
97 | }
98 |
--------------------------------------------------------------------------------
/frontend/components/counter.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useState } from 'react'
4 | import { Button } from '@heroui/button'
5 |
6 | export const Counter = () => {
7 | const [count, setCount] = useState(0)
8 |
9 | return (
10 |
13 | )
14 | }
15 |
--------------------------------------------------------------------------------
/frontend/components/docs/BlogHeader.tsx:
--------------------------------------------------------------------------------
1 | import type { KunFrontmatter } from '@/lib/mdx/types'
2 |
3 | import { Card, CardBody, CardHeader } from '@heroui/card'
4 | import { Avatar } from '@heroui/avatar'
5 | import { Image } from '@heroui/image'
6 | import { CalendarDays } from 'lucide-react'
7 |
8 | import { formatDate } from '@/utils/time'
9 |
10 | interface BlogHeaderProps {
11 | frontmatter: KunFrontmatter
12 | }
13 |
14 | export const BlogHeader = ({ frontmatter }: BlogHeaderProps) => {
15 | return (
16 |
17 |
18 |
19 |
27 |
28 |
29 |
30 |
31 |
32 |
33 | {frontmatter.title}
34 |
35 |
36 |
37 |
45 |
46 |
47 | {frontmatter.authorName}
48 |
49 |
50 |
51 |
52 | {formatDate(frontmatter.date, {
53 | isPrecise: true,
54 | isShowYear: true,
55 | })}
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 | )
64 | }
65 |
--------------------------------------------------------------------------------
/frontend/components/docs/Card.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useState } from 'react'
4 | import { Card, CardBody, CardFooter } from '@heroui/react'
5 | import { Calendar, Type } from 'lucide-react'
6 | import { Image } from '@heroui/image'
7 | import Link from 'next/link'
8 |
9 | import { KunPostMetadata } from '@/lib/mdx/types'
10 | import { formatDistanceToNow } from '@/utils/formatDistanceToNow'
11 | import { t } from '@/i18n'
12 |
13 | interface Props {
14 | post: KunPostMetadata
15 | }
16 |
17 | export const KunAboutCard = ({ post }: Props) => {
18 | const [imageLoaded, setImageLoaded] = useState(false)
19 |
20 | return (
21 |
27 |
28 | {post.title}
29 |
30 |
36 |
setImageLoaded(true)}
44 | />
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 | {post.textCount} {t('characters')}
55 |
56 |
57 |
58 |
59 |
60 | {t('readMore')}
61 |
62 |
63 | )
64 | }
65 |
--------------------------------------------------------------------------------
/frontend/components/docs/Navigation.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Button } from '@heroui/react'
4 | import { ChevronLeft, ChevronRight } from 'lucide-react'
5 | import Link from 'next/link'
6 |
7 | import { KunPostMetadata } from '@/lib/mdx/types'
8 |
9 | interface NavigationProps {
10 | prev: KunPostMetadata | null
11 | next: KunPostMetadata | null
12 | }
13 |
14 | export const KunBottomNavigation = ({ prev, next }: NavigationProps) => {
15 | return (
16 |
17 | {prev ? (
18 |
}
22 | variant='light'
23 | >
24 | {prev.title}
25 |
26 | ) : (
27 |
28 | )}
29 | {next ? (
30 |
}
33 | href={`/docs/${next.slug}`}
34 | variant='light'
35 | >
36 | {next.title}
37 |
38 | ) : (
39 |
40 | )}
41 |
42 | )
43 | }
44 |
--------------------------------------------------------------------------------
/frontend/components/docs/SideTreeItem.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useState } from 'react'
4 | import { Link } from '@heroui/link'
5 | import { ChevronRight, FileText, FolderOpen } from 'lucide-react'
6 | import { useRouter } from 'next/navigation'
7 |
8 | import { KunTreeNode } from '@/lib/mdx/types'
9 | import { cn } from '@/utils/cn'
10 |
11 | interface TreeItemProps {
12 | node: KunTreeNode
13 | level: number
14 | }
15 |
16 | export const TreeItem = ({ node, level }: TreeItemProps) => {
17 | const router = useRouter()
18 | const [isOpen, setIsOpen] = useState(true)
19 |
20 | const handleClick = () => {
21 | if (node.type === 'directory') {
22 | setIsOpen(!isOpen)
23 | } else {
24 | router.push(`/docs/${node.path}`)
25 | }
26 | }
27 |
28 | return (
29 |
66 | )
67 | }
68 |
--------------------------------------------------------------------------------
/frontend/components/docs/Sidebar.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import {
4 | Drawer,
5 | DrawerContent,
6 | DrawerHeader,
7 | DrawerBody,
8 | useDisclosure,
9 | Link,
10 | } from '@heroui/react'
11 | import { ChevronRight } from 'lucide-react'
12 |
13 | import { SidebarContent } from './SidebarContent'
14 |
15 | import { t } from '@/i18n'
16 | import { KunTreeNode } from '@/lib/mdx/types'
17 |
18 | interface Props {
19 | tree: KunTreeNode
20 | }
21 |
22 | export const KunSidebar = ({ tree }: Props) => {
23 | const { isOpen, onOpen, onOpenChange } = useDisclosure()
24 |
25 | return (
26 |
27 |
35 |
36 |
42 |
43 |
49 |
50 |
51 | {t('navMenuDirectory')}
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 | )
60 | }
61 |
--------------------------------------------------------------------------------
/frontend/components/docs/SidebarContent.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { TreeItem } from './SideTreeItem'
4 |
5 | import { KunTreeNode } from '@/lib/mdx/types'
6 |
7 | interface Props {
8 | tree: KunTreeNode
9 | }
10 |
11 | export const SidebarContent = ({ tree }: Props) => {
12 | return (
13 |
14 | {tree.type === 'directory' &&
15 | tree.children?.map((child, index) => (
16 |
17 | ))}
18 |
19 | )
20 | }
21 |
--------------------------------------------------------------------------------
/frontend/components/docs/TableOfContents.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useEffect, useState } from 'react'
4 |
5 | import { t } from '@/i18n'
6 |
7 | interface TOCItem {
8 | id: string
9 | text: string
10 | level: number
11 | }
12 |
13 | const scrollToHeading = (id: string) => {
14 | const headingElement = document.getElementById(id)
15 |
16 | if (headingElement) {
17 | headingElement.scrollIntoView({
18 | behavior: 'smooth',
19 | block: 'center',
20 | })
21 | }
22 | }
23 |
24 | export const TableOfContents = () => {
25 | const [headings, setHeadings] = useState([])
26 | const [activeId, setActiveId] = useState('')
27 |
28 | useEffect(() => {
29 | const elements = Array.from(
30 | document.querySelectorAll('article h1, article h2, article h3'),
31 | ).map((element) => ({
32 | id: element.id,
33 | text: element.textContent || '',
34 | level: Number(element.tagName.charAt(1)),
35 | }))
36 |
37 | setHeadings(elements)
38 |
39 | const observer = new IntersectionObserver(
40 | (entries) => {
41 | entries.forEach((entry) => {
42 | if (entry.isIntersecting) {
43 | setActiveId(entry.target.id)
44 | }
45 | })
46 | },
47 | { rootMargin: '0px 0px -80% 0px' },
48 | )
49 |
50 | document
51 | .querySelectorAll('article h1, article h2, article h3')
52 | .forEach((heading) => {
53 | observer.observe(heading)
54 | })
55 |
56 | return () => observer.disconnect()
57 | }, [])
58 |
59 | return (
60 |
86 | )
87 | }
88 |
--------------------------------------------------------------------------------
/frontend/components/fileList.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import type { Inode } from '@/types'
4 |
5 | import { FileZipOutlined, FolderOpenOutlined } from '@ant-design/icons'
6 | import { Listbox, ListboxItem, Pagination } from '@heroui/react'
7 | import { useCallback, useState } from 'react'
8 |
9 | import { num2size } from '@/algorithm/util'
10 | import { generateHref } from '@/algorithm/url'
11 | import { t } from '@/i18n'
12 |
13 | interface ListboxWrapperProps {
14 | children: React.ReactNode
15 | }
16 |
17 | export const ListboxWrapper: React.FC = ({ children }) => (
18 |
19 | {children}
20 |
21 | )
22 |
23 | export const FileList: React.FC<{
24 | inode: Inode
25 | slug: string[]
26 | }> = ({ inode, slug }) => {
27 | const [page, setPage] = useState(1)
28 | const onPaginationChange = useCallback((e: number) => setPage(e), [setPage])
29 | const iconClasses =
30 | 'text-2xl text-default-500 pointer-events-none flex-shrink-0'
31 |
32 | return (
33 |
34 |
35 | {inode.slice((page - 1) * 10, page * 10).map((item, index) => (
36 |
48 | ) : item.type == 'folder' ? (
49 |
50 | ) : null
51 | }
52 | textValue={item.name}
53 | >
54 | {item.name}
55 |
56 | ))}
57 |
58 |
59 |
68 |
69 | )
70 | }
71 |
--------------------------------------------------------------------------------
/frontend/components/galgame-icons.tsx:
--------------------------------------------------------------------------------
1 | export const KRKROutlined = (props: React.ComponentProps<'svg'>) => {
2 | const styles = {
3 | path: {
4 | strokeMiterlimit: 10,
5 | fill: 'none',
6 | },
7 | circle: {
8 | strokeWidth: 0,
9 | },
10 | }
11 |
12 | return (
13 |
41 | )
42 | }
43 |
44 | export const ONSOutlined = (props: React.ComponentProps<'svg'>) => {
45 | return (
46 |
157 | )
158 | }
159 |
--------------------------------------------------------------------------------
/frontend/components/gameIntro.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import {
4 | Card,
5 | CardHeader,
6 | CardBody,
7 | CardFooter,
8 | Divider,
9 | Link,
10 | } from '@heroui/react'
11 | import React from 'react'
12 |
13 | import { FileInfo } from '@/types'
14 | import { num2size } from '@/algorithm/util'
15 | import {
16 | generate_download_url,
17 | get_game_type,
18 | trim_file_path,
19 | } from '@/algorithm/url'
20 | import { t } from '@/i18n'
21 |
22 | export const GameIntro: React.FC<{ info: FileInfo }> = ({ info }) => {
23 | const s = info.file_path.split('/')
24 | const name = s[s.length - 1]
25 | const download_url = generate_download_url(s)
26 | const accelerate_dl = (() => {
27 | if (download_url.startsWith('https://dl.oo0o.ooo/file/shinnku/')) {
28 | return download_url.replace(
29 | 'https://dl.oo0o.ooo/',
30 | 'https://download.shinnku.com/',
31 | )
32 | } else return null
33 | })()
34 |
35 | return (
36 |
37 |
38 |
39 | {name}
40 |
41 |
42 |
43 |
44 |
45 | {t('path')}
46 | {trim_file_path(info.file_path)}
47 |
48 |
49 | {get_game_type(info.file_path)}
50 | {t('size')}
51 | {num2size(info.file_size)}
52 |
53 |
54 |
55 |
56 |
57 | {t('clickToDownload')}
58 |
59 | {accelerate_dl && (
60 |
66 | {t('fastDownload')}
67 |
68 | )}
69 |
70 |
71 | )
72 | }
73 |
--------------------------------------------------------------------------------
/frontend/components/icons.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import Image from 'next/image'
3 |
4 | import { IconSvgProps } from '@/types'
5 | import { t } from '@/i18n'
6 |
7 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
8 | export const Logo = ({ size = 36, ...props }) => {
9 | return (
10 |
18 | )
19 | }
20 |
21 | export const DiscordIcon: React.FC = ({
22 | size = 24,
23 | width,
24 | height,
25 | ...props
26 | }) => {
27 | return (
28 |
39 | )
40 | }
41 |
42 | export const TwitterIcon: React.FC = ({
43 | size = 24,
44 | width,
45 | height,
46 | ...props
47 | }) => {
48 | return (
49 |
60 | )
61 | }
62 |
63 | export const GithubIcon: React.FC = ({
64 | size = 24,
65 | width,
66 | height,
67 | ...props
68 | }) => {
69 | return (
70 |
83 | )
84 | }
85 |
86 | export const MoonFilledIcon = ({
87 | size = 24,
88 | width,
89 | height,
90 | ...props
91 | }: IconSvgProps) => (
92 |
106 | )
107 |
108 | export const SunFilledIcon = ({
109 | size = 24,
110 | width,
111 | height,
112 | ...props
113 | }: IconSvgProps) => (
114 |
128 | )
129 |
130 | export const HeartFilledIcon = ({
131 | size = 24,
132 | width,
133 | height,
134 | ...props
135 | }: IconSvgProps) => (
136 |
153 | )
154 |
155 | export const SearchIcon = (props: IconSvgProps) => (
156 |
181 | )
182 |
--------------------------------------------------------------------------------
/frontend/components/navbar.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useEffect, useState } from 'react'
4 | import {
5 | Navbar as HeroUINavbar,
6 | NavbarContent,
7 | NavbarMenu,
8 | NavbarMenuToggle,
9 | NavbarBrand,
10 | NavbarItem,
11 | NavbarMenuItem,
12 | } from '@heroui/navbar'
13 | import { Link } from '@heroui/link'
14 | import { link as linkStyles } from '@heroui/theme'
15 | import NextLink from 'next/link'
16 | import { usePathname } from 'next/navigation'
17 |
18 | import { cn } from '@/utils/cn'
19 | import { siteConfig } from '@/config/site'
20 | import { ThemeSwitch } from '@/components/theme-switch'
21 | import { GithubIcon, Logo } from '@/components/icons'
22 | import { t } from '@/i18n'
23 |
24 | export const Navbar = () => {
25 | const pathname = usePathname()
26 | const [isMenuOpen, setIsMenuOpen] = useState(false)
27 |
28 | useEffect(() => {
29 | setIsMenuOpen(false)
30 | }, [pathname])
31 |
32 | return (
33 |
38 |
39 |
40 |
41 |
42 | {t('websiteName')}
43 |
44 |
45 |
46 | {siteConfig.navItems.map((item) => (
47 |
48 |
56 | {item.label}
57 |
58 |
59 | ))}
60 |
61 |
62 |
63 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 | {siteConfig.navMenuItems.map((item, index) => (
86 |
87 |
98 | {item.label}
99 |
100 |
101 | ))}
102 |
103 |
104 |
105 | )
106 | }
107 |
--------------------------------------------------------------------------------
/frontend/components/primitives.ts:
--------------------------------------------------------------------------------
1 | import { tv } from 'tailwind-variants'
2 |
3 | export const title = tv({
4 | base: 'tracking-tight inline font-semibold',
5 | variants: {
6 | color: {
7 | violet: 'from-[#FF1CF7] to-[#b249f8]',
8 | yellow: 'from-[#FF705B] to-[#FFB457]',
9 | blue: 'from-[#5EA2EF] to-[#0072F5]',
10 | cyan: 'from-[#00b7fa] to-[#01cfea]',
11 | green: 'from-[#6FEE8D] to-[#17c964]',
12 | pink: 'from-[#FF72E1] to-[#F54C7A]',
13 | foreground: 'dark:from-[#FFFFFF] dark:to-[#4B4B4B]',
14 | },
15 | size: {
16 | sm: 'text-3xl lg:text-4xl',
17 | md: 'text-[2.3rem] lg:text-5xl leading-9',
18 | lg: 'text-4xl lg:text-6xl',
19 | },
20 | fullWidth: {
21 | true: 'w-full block',
22 | },
23 | },
24 | defaultVariants: {
25 | size: 'md',
26 | },
27 | compoundVariants: [
28 | {
29 | color: [
30 | 'violet',
31 | 'yellow',
32 | 'blue',
33 | 'cyan',
34 | 'green',
35 | 'pink',
36 | 'foreground',
37 | ],
38 | class: 'bg-clip-text text-transparent bg-gradient-to-b',
39 | },
40 | ],
41 | })
42 |
43 | export const subtitle = tv({
44 | base: 'w-full md:w-1/2 my-2 text-base lg:text-lg text-default-600 block max-w-full',
45 | variants: {
46 | fullWidth: {
47 | true: '!w-full',
48 | },
49 | },
50 | defaultVariants: {
51 | fullWidth: true,
52 | },
53 | })
54 |
--------------------------------------------------------------------------------
/frontend/components/returnButton.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { ArrowLeftOutlined } from '@ant-design/icons'
4 | import { Button, Link } from '@heroui/react'
5 | import { useRouter } from 'next/navigation'
6 |
7 | interface RoundArrowButtonProps {
8 | ariaLabel?: string
9 | }
10 |
11 | export const RoundArrowButton: React.FC = ({
12 | ariaLabel,
13 | }) => {
14 | const router = useRouter()
15 |
16 | return (
17 |
29 | )
30 | }
31 |
--------------------------------------------------------------------------------
/frontend/components/search/search-answer.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import type { SearchList } from '@/types'
4 |
5 | import { ScrollShadow } from '@heroui/react'
6 |
7 | import { GameIntro } from '../gameIntro'
8 |
9 | interface SearchAnswerProps {
10 | answer: SearchList
11 | }
12 |
13 | export const SearchAnswer: React.FC = ({ answer }) => {
14 | return (
15 |
16 |
17 | {answer.map((v, i) => {
18 | return (
19 |
20 |
21 |
22 | )
23 | })}
24 |
25 |
26 | )
27 | }
28 |
--------------------------------------------------------------------------------
/frontend/components/search/search-intro.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useEffect, useState } from 'react'
4 | import ReactMarkdown from 'react-markdown'
5 | import { ScrollShadow } from '@heroui/react'
6 | import remarkBreaks from 'remark-breaks'
7 |
8 | import { WikipediaAnswer } from '@/types/wiki'
9 | import { subtitle, title } from '@/components/primitives'
10 | import { trim_wikipedia_ans, wikipediaToMarkdown } from '@/algorithm/url'
11 | import { t } from '@/i18n'
12 |
13 | interface SearchIntroProps {
14 | name: string
15 | }
16 |
17 | export const SearchIntro: React.FC = ({ name }) => {
18 | const [intro, setIntro] = useState({
19 | title: name,
20 | text: '',
21 | })
22 |
23 | useEffect(() => {
24 | if (intro.bg) {
25 | const boxMain = document.getElementById('box-main')!
26 |
27 | boxMain.style.backgroundImage = `url('https://www.shinnku.com/image/${intro.bg}')`
28 | }
29 | }, [intro.bg])
30 |
31 | useEffect(() => {
32 | fetch(`/api/aiintro?name=${encodeURIComponent(name)}`)
33 | .then(async (res) => res.json())
34 | .then((data) => setIntro(data))
35 | }, [name])
36 |
37 | return (
38 |
43 | {intro.title}
44 | {t('searchIntroFromGemini')}
45 |
46 |
47 | {wikipediaToMarkdown(trim_wikipedia_ans(intro.text))}
48 |
49 |
50 |
51 | )
52 | }
53 |
--------------------------------------------------------------------------------
/frontend/components/search/search.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import React, { useState } from 'react'
4 | import { useRouter } from 'next/navigation'
5 | import { Input } from '@heroui/input'
6 | import { Kbd } from '@heroui/kbd'
7 |
8 | import { SearchIcon } from '../icons'
9 |
10 | import { t } from '@/i18n'
11 |
12 | interface SearchProps {
13 | initialSearchTerm?: string
14 | }
15 |
16 | export const Search: React.FC = ({ initialSearchTerm = '' }) => {
17 | const [searchTerm, setSearchTerm] = useState(initialSearchTerm)
18 | const router = useRouter()
19 |
20 | const handleSearch = () => {
21 | if (searchTerm.trim() !== '') {
22 | router.push(`/search?q=${encodeURIComponent(searchTerm)}`)
23 | }
24 | }
25 |
26 | const handleInputChange = (event: React.ChangeEvent) => {
27 | setSearchTerm(event.target.value)
28 | }
29 |
30 | const handleKeyDown = (event: React.KeyboardEvent) => {
31 | if (event.key === 'Enter') {
32 | handleSearch()
33 | }
34 | }
35 |
36 | return (
37 | }
46 | labelPlacement='outside'
47 | placeholder={t('searchPlaceholder')}
48 | radius='full'
49 | size='lg'
50 | startContent={
51 |
52 | }
53 | type='search'
54 | value={searchTerm}
55 | onChange={handleInputChange}
56 | onKeyDown={handleKeyDown}
57 | />
58 | )
59 | }
60 |
61 | export default Search // You can still have a default export if needed.
62 |
--------------------------------------------------------------------------------
/frontend/components/sidebar.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Button } from '@heroui/button'
4 | import { Link } from '@heroui/link'
5 | import { usePathname } from 'next/navigation'
6 |
7 | import { IndexListForSlog } from '@/config/indexList'
8 |
9 | export const Sidebar = () => {
10 | const pathname = usePathname()
11 |
12 | return (
13 |
14 | {IndexListForSlog.map((item, index) => (
15 |
16 |
26 |
37 |
38 | ))}
39 |
40 | )
41 | }
42 |
--------------------------------------------------------------------------------
/frontend/components/theme-switch.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { FC, useLayoutEffect } from 'react'
4 | import { VisuallyHidden } from '@react-aria/visually-hidden'
5 | import { SwitchProps, useSwitch } from '@heroui/switch'
6 | import { useTheme } from 'next-themes'
7 | import { useIsSSR } from '@react-aria/ssr'
8 |
9 | import { cn } from '@/utils/cn'
10 | import { SunFilledIcon, MoonFilledIcon } from '@/components/icons'
11 |
12 | export interface ThemeSwitchProps {
13 | className?: string
14 | classNames?: SwitchProps['classNames']
15 | }
16 |
17 | export const ThemeSwitch: FC = ({
18 | className,
19 | classNames,
20 | }) => {
21 | const { theme, setTheme } = useTheme()
22 | const isSSR = useIsSSR()
23 |
24 | useLayoutEffect(() => {
25 | if (!localStorage.getItem('theme')) {
26 | setTheme('light')
27 | }
28 | }, [])
29 |
30 | const onChange = () => {
31 | theme === 'light' ? setTheme('dark') : setTheme('light')
32 | }
33 |
34 | const {
35 | Component,
36 | slots,
37 | isSelected,
38 | getBaseProps,
39 | getInputProps,
40 | getWrapperProps,
41 | } = useSwitch({
42 | isSelected: theme === 'light' || isSSR,
43 | 'aria-label': `Switch to ${theme === 'light' || isSSR ? 'dark' : 'light'} mode`,
44 | onChange,
45 | })
46 |
47 | return (
48 |
57 |
58 |
59 |
60 |
79 | {!isSelected || isSSR ? (
80 |
81 | ) : (
82 |
83 | )}
84 |
85 |
86 | )
87 | }
88 |
--------------------------------------------------------------------------------
/frontend/config/fonts.ts:
--------------------------------------------------------------------------------
1 | import { Fira_Code as FontMono, Inter as FontSans } from 'next/font/google'
2 |
3 | export const fontSans = FontSans({
4 | subsets: ['latin'],
5 | variable: '--font-sans',
6 | })
7 |
8 | export const fontMono = FontMono({
9 | subsets: ['latin'],
10 | variable: '--font-mono',
11 | })
12 |
--------------------------------------------------------------------------------
/frontend/config/indexList.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | AndroidOutlined,
3 | CodeOutlined,
4 | GlobalOutlined,
5 | WindowsFilled,
6 | WindowsOutlined,
7 | } from '@ant-design/icons'
8 | import Image from 'next/image'
9 |
10 | import { KRKROutlined, ONSOutlined } from '@/components/galgame-icons'
11 | import { t } from '@/i18n'
12 |
13 | export const IndexList = [
14 | {
15 | title: t('indexForum'),
16 | link: 'https://galgame.dev/',
17 | body: ,
18 | },
19 | {
20 | title: t('indexOldWin'),
21 | link: '/files/shinnku/0/win',
22 | body: ,
23 | },
24 | {
25 | title: t('indexNewWin'),
26 | link: '/files/shinnku/zd',
27 | body: ,
28 | },
29 | {
30 | title: t('indexApk'),
31 | link: '/files/shinnku/0/apk',
32 | body: ,
33 | },
34 | {
35 | title: t('indexOns'),
36 | link: '/files/shinnku/0/ons',
37 | body: ,
38 | },
39 | {
40 | title: t('indexKrkr'),
41 | link: '/files/shinnku/0/krkr',
42 | body: ,
43 | },
44 | {
45 | title: t('indexTools'),
46 | link: '/files/shinnku/0/tools',
47 | body: ,
48 | },
49 | {
50 | title: t('indexRaw'),
51 | link: '/files/galgame0',
52 | body: ,
53 | },
54 | ]
55 |
56 | export const IndexListForSlog = [
57 | {
58 | title: t('indexForum'),
59 | link: 'https://galgame.dev/',
60 | body: ,
61 | },
62 | {
63 | title: t('indexOldWinShort'),
64 | link: '/files/shinnku/0/win',
65 | body: ,
66 | },
67 | {
68 | title: t('indexNewWinShort'),
69 | link: '/files/shinnku/zd',
70 | body: ,
71 | },
72 | {
73 | title: t('indexApkShort'),
74 | link: '/files/shinnku/0/apk',
75 | body: ,
76 | },
77 | {
78 | title: t('indexOnsShort'),
79 | link: '/files/shinnku/0/ons',
80 | body: ,
81 | },
82 | {
83 | title: t('indexKrkrShort'),
84 | link: '/files/shinnku/0/krkr',
85 | body: ,
86 | },
87 | {
88 | title: t('indexToolsShort'),
89 | link: '/files/shinnku/0/tools',
90 | body: ,
91 | },
92 | {
93 | title: t('indexRawShort'),
94 | link: '/files/galgame0',
95 | body: ,
96 | },
97 | ]
98 |
--------------------------------------------------------------------------------
/frontend/config/root.ts:
--------------------------------------------------------------------------------
1 | import type { BucketFiles, Config, SearchList, TreeNode } from '@/types'
2 |
3 | import { promises as fs } from 'fs'
4 |
5 | import toml from 'toml'
6 |
7 | import { generateTree } from '@/algorithm/tree'
8 | import { aggregate_builder } from '@/algorithm/search'
9 |
10 | export const shinnku_bucket_files_json: BucketFiles = JSON.parse(
11 | await fs.readFile('data/shinnku_bucket_files.json', { encoding: 'utf8' }),
12 | )
13 |
14 | export const galgame0_bucket_files_json: BucketFiles = JSON.parse(
15 | await fs.readFile('data/galgame0_bucket_files.json', { encoding: 'utf8' }),
16 | )
17 |
18 | export const shinnku_tree = generateTree(shinnku_bucket_files_json)
19 | export const galgame0_tree = generateTree(galgame0_bucket_files_json)
20 |
21 | export const search_index: SearchList = aggregate_builder(
22 | shinnku_bucket_files_json,
23 | galgame0_bucket_files_json.filter((v) =>
24 | v.file_path.startsWith('合集系列/浮士德galgame游戏合集'),
25 | ),
26 | )
27 |
28 | export const tree = {
29 | shinnku: shinnku_tree,
30 | galgame0: (galgame0_tree['合集系列'] as TreeNode)[
31 | '浮士德galgame游戏合集'
32 | ] as TreeNode,
33 | }
34 |
35 | export const config: Config = toml.parse(
36 | await fs.readFile('config.toml', {
37 | encoding: 'utf8',
38 | }),
39 | )
40 |
--------------------------------------------------------------------------------
/frontend/config/site.ts:
--------------------------------------------------------------------------------
1 | export type SiteConfig = typeof siteConfig
2 |
3 | import { t } from '@/i18n'
4 |
5 | export const siteConfig = {
6 | name: t('websiteName'),
7 | description: t('siteDescription'),
8 | navItems: [
9 | {
10 | label: t('navDocs'),
11 | href: '/docs',
12 | },
13 | {
14 | label: t('navAbout'),
15 | href: '/about',
16 | },
17 | {
18 | label: t('navFiles'),
19 | href: '/files',
20 | },
21 | ],
22 | navMenuItems: [
23 | {
24 | label: t('navDocs'),
25 | href: '/docs',
26 | },
27 | {
28 | label: t('navAbout'),
29 | href: '/about',
30 | },
31 | {
32 | label: t('navFiles'),
33 | href: '/files',
34 | },
35 | ],
36 | links: {
37 | github: 'https://github.com/shinnku-nikaidou/shinnku-com',
38 | docs: '/docs',
39 | files: '/files',
40 | },
41 | }
42 |
--------------------------------------------------------------------------------
/frontend/constants/doc.ts:
--------------------------------------------------------------------------------
1 | import { t } from '@/i18n'
2 |
3 | export const docDirectoryLabelMap: Record = {
4 | notice: t('docNotice'),
5 | }
6 |
--------------------------------------------------------------------------------
/frontend/eslint.config.js:
--------------------------------------------------------------------------------
1 | const { FlatCompat } = require('@eslint/eslintrc')
2 | const compat = new FlatCompat({
3 | baseDirectory: __dirname,
4 | })
5 |
6 | const legacyConfig = {
7 | env: {
8 | browser: false,
9 | es2021: true,
10 | node: true,
11 | },
12 | extends: [
13 | 'plugin:react/recommended',
14 | 'plugin:prettier/recommended',
15 | 'plugin:react-hooks/recommended',
16 | 'plugin:jsx-a11y/recommended',
17 | 'plugin:@next/next/recommended',
18 | ],
19 | plugins: [
20 | 'react',
21 | 'unused-imports',
22 | 'import',
23 | '@typescript-eslint',
24 | 'jsx-a11y',
25 | 'prettier',
26 | ],
27 | parser: '@typescript-eslint/parser',
28 | parserOptions: {
29 | ecmaFeatures: {
30 | jsx: true,
31 | },
32 | ecmaVersion: 12,
33 | sourceType: 'module',
34 | },
35 | settings: {
36 | react: {
37 | version: 'detect',
38 | },
39 | },
40 | rules: {
41 | 'no-console': 'warn',
42 | 'react/prop-types': 'off',
43 | 'react/jsx-uses-react': 'off',
44 | 'react/react-in-jsx-scope': 'off',
45 | 'react-hooks/exhaustive-deps': 'off',
46 | 'jsx-a11y/click-events-have-key-events': 'warn',
47 | 'jsx-a11y/interactive-supports-focus': 'warn',
48 | 'prettier/prettier': 'warn',
49 | 'no-unused-vars': 'off',
50 | 'unused-imports/no-unused-vars': 'off',
51 | 'unused-imports/no-unused-imports': 'warn',
52 | '@typescript-eslint/no-unused-vars': [
53 | 'warn',
54 | {
55 | args: 'after-used',
56 | ignoreRestSiblings: false,
57 | argsIgnorePattern: '^_.*?$',
58 | },
59 | ],
60 | 'import/order': [
61 | 'warn',
62 | {
63 | groups: [
64 | 'type',
65 | 'builtin',
66 | 'object',
67 | 'external',
68 | 'internal',
69 | 'parent',
70 | 'sibling',
71 | 'index',
72 | ],
73 | pathGroups: [
74 | {
75 | pattern: '~/**',
76 | group: 'external',
77 | position: 'after',
78 | },
79 | ],
80 | 'newlines-between': 'always',
81 | },
82 | ],
83 | 'react/self-closing-comp': 'warn',
84 | 'react/jsx-sort-props': [
85 | 'warn',
86 | {
87 | callbacksLast: true,
88 | shorthandFirst: true,
89 | noSortAlphabetically: false,
90 | reservedFirst: true,
91 | },
92 | ],
93 | 'padding-line-between-statements': [
94 | 'warn',
95 | { blankLine: 'always', prev: '*', next: 'return' },
96 | { blankLine: 'always', prev: ['const', 'let', 'var'], next: '*' },
97 | {
98 | blankLine: 'any',
99 | prev: ['const', 'let', 'var'],
100 | next: ['const', 'let', 'var'],
101 | },
102 | ],
103 | },
104 | }
105 |
106 | module.exports = [
107 | {
108 | ignores: [
109 | '.now/*',
110 | '*.css',
111 | '.changeset',
112 | 'dist',
113 | 'esm/*',
114 | 'public/*',
115 | 'tests/*',
116 | 'scripts/*',
117 | '*.config.js',
118 | '.DS_Store',
119 | 'node_modules',
120 | 'coverage',
121 | '.next',
122 | 'build',
123 | '!.commitlintrc.cjs',
124 | '!.lintstagedrc.cjs',
125 | '!jest.config.js',
126 | '!plopfile.js',
127 | '!react-shim.js',
128 | '!tsup.config.ts',
129 | ],
130 | },
131 | ...compat.config(legacyConfig),
132 | ]
133 |
--------------------------------------------------------------------------------
/frontend/hooks/useResizeObserver.ts:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useCallback, useEffect, useState } from 'react'
4 |
5 | interface Size {
6 | width: number | undefined
7 | height: number | undefined
8 | }
9 |
10 | export const useResizeObserver = (ref: React.RefObject) => {
11 | const [size, setSize] = useState({
12 | width: undefined,
13 | height: undefined,
14 | })
15 |
16 | const handleResize = useCallback((entries: ResizeObserverEntry[]) => {
17 | const entry = entries[0]
18 |
19 | if (entry) {
20 | const { width, height } = entry.contentRect
21 |
22 | setSize({ width, height })
23 | }
24 | }, [])
25 |
26 | useEffect(() => {
27 | if (!ref.current) {
28 | return
29 | }
30 |
31 | const observer = new ResizeObserver(handleResize)
32 |
33 | observer.observe(ref.current)
34 |
35 | return () => {
36 | observer.disconnect()
37 | }
38 | }, [ref, handleResize])
39 |
40 | return size
41 | }
42 |
--------------------------------------------------------------------------------
/frontend/i18n/en-us.ts:
--------------------------------------------------------------------------------
1 | const messages = {
2 | websiteName: 'Shinnku',
3 | siteDescription:
4 | 'This is Shinnku (Lost Station), a visual novel resource site (including visual novels, adult games, PSP, krkr, ons resources, etc.), collecting most translated visual novels and raw resources.',
5 | navDocs: 'Docs',
6 | navAbout: 'About',
7 | navFiles: 'Files',
8 | indexForum: 'Forum',
9 | indexOldWin: 'Old Win releases',
10 | indexNewWin: 'New Win releases',
11 | indexApk: 'APK collection',
12 | indexOns: 'ONS collection',
13 | indexKrkr: 'Krkr collection',
14 | indexTools: 'Visual novel tools',
15 | indexRaw: 'Raw collection',
16 | indexOldWinShort: 'Old Win',
17 | indexNewWinShort: 'New Win',
18 | indexApkShort: 'APK',
19 | indexOnsShort: 'ONS',
20 | indexKrkrShort: 'Krkr',
21 | indexToolsShort: 'Tools',
22 | indexRawShort: 'Raw',
23 | docsTitle: 'Docs | Blog',
24 | allGames: 'All games',
25 | aboutUnderConstruction: 'Under construction',
26 | searchPlaceholder: 'Search game name or keyword',
27 | searchIntroFromGemini: 'Intro powered by gemini 2.5 pro',
28 | navMenuDirectory: 'Directory',
29 | pageWelcomeDescription:
30 | 'Shinnku (formerly Lost Station) is a visual novel resource site collecting most translated visual novels, raw resources, krkr resources, and more.',
31 | browseAllGames: 'Browse all games',
32 | advertisement: 'Congyu VPN',
33 | fileFolder: 'Folder',
34 | path: 'Path:',
35 | size: 'Size:',
36 | clickToDownload: 'Click here to download',
37 | fastDownload: 'Accelerated download',
38 | tableOfContents: 'Contents',
39 | readMore: 'Read more →',
40 | characters: 'characters',
41 | docNotice: 'Site notice',
42 | seconds: 'seconds',
43 | minutes: 'minutes',
44 | hours: 'hours',
45 | days: 'days',
46 | weeks: 'weeks',
47 | months: 'months',
48 | years: 'years',
49 | fewSeconds: 'a few seconds ago',
50 | agoSuffix: ' ago',
51 | errorSomethingWrong: 'Something went wrong!',
52 | errorTryAgain: 'Try again',
53 | }
54 |
55 | export default messages
56 |
--------------------------------------------------------------------------------
/frontend/i18n/index.ts:
--------------------------------------------------------------------------------
1 | import zhCn from './zh-cn'
2 | import zhTw from './zh-tw'
3 | import enUs from './en-us'
4 |
5 | const locales = {
6 | 'zh-cn': zhCn,
7 | 'zh-tw': zhTw,
8 | 'en-us': enUs,
9 | } as const
10 |
11 | export const t = (key: keyof typeof zhCn) => {
12 | const messages = locales['zh-cn']
13 |
14 | return messages[key] ?? zhCn[key]
15 | }
16 |
--------------------------------------------------------------------------------
/frontend/i18n/zh-cn.ts:
--------------------------------------------------------------------------------
1 | const messages = {
2 | websiteName: '真红小站',
3 | siteDescription:
4 | '这里是真红小站(失落小站), 一个galgame资源站, (包括visual novel, 黄油, psp, krkr, ons gal资源 等), 收录了大部分的汉化galgame, 大部分的生肉galgame资源。',
5 | navDocs: '文档',
6 | navAbout: '关于',
7 | navFiles: '目录',
8 | indexForum: '真红论坛',
9 | indexOldWin: '前win汉化集合',
10 | indexNewWin: '新win汉化集合',
11 | indexApk: 'apk集合',
12 | indexOns: 'ons集合',
13 | indexKrkr: 'krkr集合',
14 | indexTools: 'galgame工具',
15 | indexRaw: '生肉硬盘集合',
16 | indexOldWinShort: '前win汉化',
17 | indexNewWinShort: '新win汉化',
18 | indexApkShort: 'apk集合',
19 | indexOnsShort: 'ons集合',
20 | indexKrkrShort: 'krkr集合',
21 | indexToolsShort: 'gal工具',
22 | indexRawShort: '生肉集合',
23 | docsTitle: '文档 | 网站博客',
24 | allGames: '全部游戏',
25 | aboutUnderConstruction: '仍在施工中',
26 | searchPlaceholder: '请搜索游戏名称或关键词',
27 | searchIntroFromGemini: '简介来自gemini 2.5 pro的支持',
28 | navMenuDirectory: '目录',
29 | pageWelcomeDescription:
30 | '真红小站(原 失落小站)一个galgame资源站, 收录了大部分的汉化galgame, 大部分的生肉galgame资源,krkr资源,visual novel,等等。',
31 | browseAllGames: '浏览全部游戏',
32 | advertisement: '丛雨云 VPN',
33 | fileFolder: '文件夹',
34 | path: '路径:',
35 | size: '大小:',
36 | clickToDownload: '点击此处下载',
37 | fastDownload: '加速下载',
38 | tableOfContents: '本页面索引',
39 | readMore: '点击阅读更多 →',
40 | characters: '字',
41 | docNotice: '网站公告',
42 | seconds: '秒',
43 | minutes: '分钟',
44 | hours: '小时',
45 | days: '天',
46 | weeks: '周',
47 | months: '月',
48 | years: '年',
49 | fewSeconds: '数秒前',
50 | agoSuffix: '前',
51 | errorSomethingWrong: 'Something went wrong!',
52 | errorTryAgain: 'Try again',
53 | }
54 |
55 | export default messages
56 |
--------------------------------------------------------------------------------
/frontend/i18n/zh-tw.ts:
--------------------------------------------------------------------------------
1 | const messages = {
2 | websiteName: '真紅小站',
3 | siteDescription:
4 | '這裏是真紅小站(失落小站), 一個galgame資源站, (包括visual novel, 黃油, psp, krkr, ons gal資源 等), 收錄了大部分的漢化galgame, 大部分的生肉galgame資源。',
5 | navDocs: '文檔',
6 | navAbout: '關於',
7 | navFiles: '目錄',
8 | indexForum: '真紅論壇',
9 | indexOldWin: '前win漢化集合',
10 | indexNewWin: '新win漢化集合',
11 | indexApk: 'apk集合',
12 | indexOns: 'ons集合',
13 | indexKrkr: 'krkr集合',
14 | indexTools: 'galgame工具',
15 | indexRaw: '生肉硬盤集合',
16 | indexOldWinShort: '前win漢化',
17 | indexNewWinShort: '新win漢化',
18 | indexApkShort: 'apk集合',
19 | indexOnsShort: 'ons集合',
20 | indexKrkrShort: 'krkr集合',
21 | indexToolsShort: 'gal工具',
22 | indexRawShort: '生肉集合',
23 | docsTitle: '文檔 | 網站博客',
24 | allGames: '全部遊戲',
25 | aboutUnderConstruction: '仍在施工中',
26 | searchPlaceholder: '請搜索遊戲名稱或關鍵詞',
27 | searchIntroFromGemini: '簡介來自gemini 2.5 pro的支持',
28 | navMenuDirectory: '目錄',
29 | pageWelcomeDescription:
30 | '真紅小站(原 失落小站)一個galgame資源站, 收錄了大部分的漢化galgame, 大部分的生肉galgame資源,krkr資源,visual novel,等等。',
31 | browseAllGames: '瀏覽全部遊戲',
32 | advertisement: '叢雨雲 VPN',
33 | fileFolder: '文件夾',
34 | path: '路徑:',
35 | size: '大小:',
36 | clickToDownload: '點擊此處下載',
37 | fastDownload: '加速下載',
38 | tableOfContents: '本頁面索引',
39 | readMore: '點擊閱讀更多 →',
40 | characters: '字',
41 | docNotice: '網站公告',
42 | seconds: '秒',
43 | minutes: '分鐘',
44 | hours: '小時',
45 | days: '天',
46 | weeks: '周',
47 | months: '月',
48 | years: '年',
49 | fewSeconds: '數秒前',
50 | agoSuffix: '前',
51 | errorSomethingWrong: 'Something went wrong!',
52 | errorTryAgain: 'Try again',
53 | }
54 |
55 | export default messages
56 |
--------------------------------------------------------------------------------
/frontend/lib/mdx/CustomMDX.tsx:
--------------------------------------------------------------------------------
1 | import { MDXRemoteProps, compileMDX } from 'next-mdx-remote/rsc'
2 | import rehypeKatex from 'rehype-katex'
3 | import remarkMath from 'remark-math'
4 |
5 | import { KunLink } from './element/KunLink'
6 | import { KunTable } from './element/KunTable'
7 | import { KunCode } from './element/KunCode'
8 | import { createKunHeading } from './element/kunHeading'
9 |
10 | const components = {
11 | h1: createKunHeading(1),
12 | h2: createKunHeading(2),
13 | h3: createKunHeading(3),
14 | h4: createKunHeading(4),
15 | h5: createKunHeading(5),
16 | h6: createKunHeading(6),
17 | a: KunLink,
18 | code: KunCode,
19 | Table: KunTable,
20 | }
21 |
22 | export const CustomMDX = async (props: MDXRemoteProps) => {
23 | const { content } = await compileMDX({
24 | source: props.source,
25 | options: {
26 | mdxOptions: {
27 | rehypePlugins: [[rehypeKatex, { output: 'mathml' }], remarkMath],
28 | },
29 | },
30 | components: { ...components, ...(props.components || {}) } as any,
31 | })
32 |
33 | return content
34 | }
35 |
--------------------------------------------------------------------------------
/frontend/lib/mdx/directoryTree.ts:
--------------------------------------------------------------------------------
1 | import type { KunTreeNode } from './types'
2 |
3 | import fs from 'fs'
4 | import path from 'path'
5 |
6 | import matter from 'gray-matter'
7 |
8 | import { docDirectoryLabelMap } from '@/constants/doc'
9 |
10 | const POSTS_PATH = path.join(process.cwd(), 'posts')
11 |
12 | export const getDirectoryTree = (): KunTreeNode => {
13 | const buildTree = (
14 | currentPath: string,
15 | baseName: string,
16 | ): KunTreeNode | null => {
17 | const stats = fs.statSync(currentPath)
18 |
19 | if (stats.isFile() && currentPath.endsWith('.mdx')) {
20 | const fileContents = fs.readFileSync(currentPath, 'utf8')
21 | const { data } = matter(fileContents)
22 |
23 | return {
24 | name: baseName.replace(/\.mdx$/, ''),
25 | label: data.title,
26 | path: path
27 | .relative(POSTS_PATH, currentPath)
28 | .replace(/\.mdx$/, '')
29 | .replace(/\\/g, '/'),
30 | type: 'file',
31 | }
32 | }
33 |
34 | if (stats.isDirectory()) {
35 | const children = fs
36 | .readdirSync(currentPath)
37 | .map((child) => buildTree(path.join(currentPath, child), child))
38 | .filter((child): child is KunTreeNode => child !== null)
39 |
40 | return {
41 | name: baseName,
42 | label: docDirectoryLabelMap[baseName],
43 | path: path.relative(POSTS_PATH, currentPath).replace(/\\/g, '/'),
44 | children,
45 | type: 'directory',
46 | }
47 | }
48 |
49 | return null
50 | }
51 |
52 | return buildTree(POSTS_PATH, 'doc') as KunTreeNode
53 | }
54 |
--------------------------------------------------------------------------------
/frontend/lib/mdx/element/KunCode.tsx:
--------------------------------------------------------------------------------
1 | import { highlight } from 'sugar-high'
2 | import React, { FC } from 'react'
3 |
4 | interface CodeProps extends React.HTMLAttributes {
5 | children: string
6 | }
7 |
8 | export const KunCode: FC = ({ children, ...props }) => {
9 | const codeHTML = highlight(children)
10 |
11 | return
12 | }
13 |
--------------------------------------------------------------------------------
/frontend/lib/mdx/element/KunLink.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link'
2 | import React, { FC } from 'react'
3 |
4 | interface CustomLinkProps
5 | extends React.AnchorHTMLAttributes {
6 | href: string
7 | }
8 |
9 | export const KunLink: FC = ({ href, children, ...props }) => {
10 | if (href.startsWith('/')) {
11 | return (
12 |
13 | {children}
14 |
15 | )
16 | }
17 |
18 | if (href.startsWith('#')) {
19 | return (
20 |
21 | {children}
22 |
23 | )
24 | }
25 |
26 | return (
27 |
28 | {children}
29 |
30 | )
31 | }
32 |
--------------------------------------------------------------------------------
/frontend/lib/mdx/element/KunTable.tsx:
--------------------------------------------------------------------------------
1 | interface TableProps {
2 | data: {
3 | headers: string[]
4 | rows: string[][]
5 | }
6 | }
7 |
8 | export const KunTable = ({ data }: TableProps) => {
9 | const headers = data.headers.map((header, index) => (
10 | {header} |
11 | ))
12 | const rows = data.rows.map((row, index) => (
13 |
14 | {row.map((cell, cellIndex) => (
15 | {cell} |
16 | ))}
17 |
18 | ))
19 |
20 | return (
21 |
22 |
23 | {headers}
24 |
25 | {rows}
26 |
27 | )
28 | }
29 |
--------------------------------------------------------------------------------
/frontend/lib/mdx/element/kunHeading.ts:
--------------------------------------------------------------------------------
1 | import React, { ReactNode } from 'react'
2 |
3 | const slugify = (str: string): string => {
4 | return str
5 | .toString()
6 | .toLowerCase()
7 | .trim()
8 | .replace(/\s+/g, '-')
9 | .replace(/&/g, '-and-')
10 | .replace(/[^\p{L}\p{N}]+/gu, '')
11 | .replace(/--+/g, '-')
12 | .replace(/^-+|-+$/g, '')
13 | }
14 |
15 | export const createKunHeading = (level: number) => {
16 | const Heading = ({ children }: { children: ReactNode }) => {
17 | const slug = slugify(children?.toString() || '')
18 |
19 | return React.createElement(
20 | `h${level}`,
21 | { id: slug },
22 | [
23 | React.createElement('a', {
24 | href: `#${slug}`,
25 | key: `kun-link-${slug}`,
26 | className: 'kun-anchor',
27 | 'aria-label': slug,
28 | }),
29 | ],
30 | children,
31 | )
32 | }
33 |
34 | Heading.displayName = `KunHeading${level}`
35 |
36 | return Heading
37 | }
38 |
--------------------------------------------------------------------------------
/frontend/lib/mdx/getPosts.ts:
--------------------------------------------------------------------------------
1 | import type { KunPostMetadata } from './types'
2 | import type { KunBlog, KunFrontmatter } from './types'
3 |
4 | import fs from 'fs'
5 | import path from 'path'
6 |
7 | import matter from 'gray-matter'
8 |
9 | import { markdownToText } from '@/utils/markdownToText'
10 |
11 | const POSTS_PATH = path.join(process.cwd(), 'posts')
12 |
13 | export const getAllPosts = () => {
14 | const posts: KunPostMetadata[] = []
15 |
16 | const traverseDirectory = (currentPath: string, basePath: string = '') => {
17 | const files = fs.readdirSync(currentPath)
18 |
19 | files.forEach((file) => {
20 | const filePath = path.join(currentPath, file)
21 | const stat = fs.statSync(filePath)
22 |
23 | if (stat.isDirectory()) {
24 | traverseDirectory(filePath, path.join(basePath, file))
25 | } else if (file.endsWith('.mdx')) {
26 | const fileContents = fs.readFileSync(filePath, 'utf8')
27 | const { data } = matter(fileContents)
28 |
29 | const slug = path
30 | .join(basePath, file.replace(/\.mdx$/, ''))
31 | .replace(/\\/g, '/')
32 |
33 | posts.push({
34 | title: data.title,
35 | banner: data.banner,
36 | date: data.date ? new Date(data.date).toISOString() : '',
37 | description: data.description || '',
38 | textCount: markdownToText(fileContents).length - 300,
39 | slug,
40 | path: slug,
41 | })
42 | }
43 | })
44 | }
45 |
46 | traverseDirectory(POSTS_PATH)
47 |
48 | return posts.sort((a, b) => (a.date > b.date ? -1 : 1))
49 | }
50 |
51 | export const getPostBySlug = (slug: string): KunBlog => {
52 | const realSlug = slug.replace(/\.mdx$/, '')
53 | const fullPath = path.join(POSTS_PATH, `${realSlug}.mdx`)
54 | const fileContents = fs.readFileSync(fullPath, 'utf8')
55 | const { data, content } = matter(fileContents)
56 |
57 | return {
58 | slug: realSlug,
59 | content,
60 | frontmatter: data as KunFrontmatter,
61 | }
62 | }
63 |
64 | export const getAdjacentPosts = (currentSlug: string) => {
65 | const posts = getAllPosts()
66 | const currentIndex = posts.findIndex((post) => post.slug === currentSlug)
67 |
68 | return {
69 | prev: currentIndex > 0 ? posts[currentIndex - 1] : null,
70 | next: currentIndex < posts.length - 1 ? posts[currentIndex + 1] : null,
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/frontend/lib/mdx/types.d.ts:
--------------------------------------------------------------------------------
1 | export interface KunPostMetadata {
2 | title: string
3 | banner: string
4 | date: string
5 | description: string
6 | textCount: number
7 | slug: string
8 | path: string
9 | }
10 |
11 | export interface KunTreeNode {
12 | name: string
13 | label: string
14 | path: string
15 | children?: KunTreeNode[]
16 | type: 'file' | 'directory'
17 | }
18 |
19 | export interface KunFrontmatter {
20 | title: string
21 | banner: string
22 | description: string
23 | date: string
24 | authorUid: number
25 | authorName: string
26 | authorAvatar: string
27 | authorHomepage: string
28 | }
29 |
30 | export interface KunBlog {
31 | slug: string
32 | content: string
33 | frontmatter: KunFrontmatter
34 | }
35 |
--------------------------------------------------------------------------------
/frontend/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | transpilePackages: ['next-mdx-remote'],
4 | i18n: {
5 | locales: ['zh-cn'],
6 | defaultLocale: 'zh-cn',
7 | },
8 | }
9 |
10 | module.exports = nextConfig
11 |
--------------------------------------------------------------------------------
/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "shinnku-com",
3 | "version": "3.2.1",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev --turbopack",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "eslint . --ext .ts,.tsx --fix",
10 | "format": "prettier \"**/*.{ts,tsx,js,jsx,json,css,md}\" --write"
11 | },
12 | "dependencies": {
13 | "@ant-design/icons": "^6.0.0",
14 | "@bprogress/next": "^3.2.12",
15 | "@heroui/button": "2.2.13",
16 | "@heroui/code": "2.2.10",
17 | "@heroui/input": "2.4.13",
18 | "@heroui/kbd": "2.2.10",
19 | "@heroui/link": "2.2.11",
20 | "@heroui/listbox": "2.3.13",
21 | "@heroui/navbar": "2.2.12",
22 | "@heroui/react": "^2.7.9",
23 | "@heroui/snippet": "2.2.14",
24 | "@heroui/switch": "2.2.12",
25 | "@heroui/system": "2.4.10",
26 | "@heroui/theme": "2.4.9",
27 | "@react-aria/ssr": "3.9.7",
28 | "@react-aria/visually-hidden": "3.8.19",
29 | "clsx": "2.1.1",
30 | "dayjs": "^1.11.13",
31 | "framer-motion": "11.13.1",
32 | "fuse.js": "^7.1.0",
33 | "gray-matter": "^4.0.3",
34 | "intl-messageformat": "^10.7.16",
35 | "ioredis": "^5.6.1",
36 | "lucide-react": "^0.477.0",
37 | "next": "15.0.4",
38 | "next-mdx-remote": "^5.0.0",
39 | "next-themes": "^0.4.6",
40 | "opencc-js": "^1.0.5",
41 | "react": "19.0.0",
42 | "react-dom": "19.0.0",
43 | "react-markdown": "^10.1.0",
44 | "rehype-katex": "^7.0.1",
45 | "remark-breaks": "^4.0.0",
46 | "remark-math": "^6.0.0",
47 | "sugar-high": "^0.9.3",
48 | "tailwind-merge": "^3.3.0",
49 | "toml": "^3.0.0"
50 | },
51 | "devDependencies": {
52 | "@next/eslint-plugin-next": "15.0.4",
53 | "@react-types/shared": "3.25.0",
54 | "@tailwindcss/typography": "^0.5.16",
55 | "@types/node": "20.5.7",
56 | "@types/opencc-js": "^1.0.3",
57 | "@types/react": "18.3.3",
58 | "@types/react-dom": "18.3.0",
59 | "@typescript-eslint/eslint-plugin": "8.11.0",
60 | "@typescript-eslint/parser": "8.11.0",
61 | "autoprefixer": "10.4.19",
62 | "eslint": "^9.28.0",
63 | "eslint-config-next": "15.0.4",
64 | "eslint-config-prettier": "10.1.5",
65 | "eslint-plugin-import": "^2.31.0",
66 | "eslint-plugin-jsx-a11y": "^6.10.2",
67 | "eslint-plugin-node": "^11.1.0",
68 | "eslint-plugin-prettier": "5.2.1",
69 | "eslint-plugin-react": "^7.37.5",
70 | "eslint-plugin-react-hooks": "^5.2.0",
71 | "eslint-plugin-unused-imports": "4.1.4",
72 | "postcss": "8.4.49",
73 | "prettier": "3.3.3",
74 | "prettier-plugin-tailwindcss": "^0.6.12",
75 | "tailwind-variants": "0.3.0",
76 | "tailwindcss": "3.4.16",
77 | "typescript": "5.6.3"
78 | },
79 | "pnpm": {
80 | "onlyBuiltDependencies": [
81 | "@heroui/shared-utils",
82 | "sharp",
83 | "unrs-resolver"
84 | ]
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/frontend/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/frontend/posts/notice/about.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: 真红小站简介
3 | banner: '/assets/81320307_p0.jpg'
4 | description: '真红小站提供丰富的 galgame 资源,包括汉化与原版游戏,并致力于打造清晰易用的分享平台。'
5 | date: 2025-03-04
6 | authorUid: 1
7 | authorName: 'shinnku'
8 | authorAvatar: 'https://avatars.githubusercontent.com/u/74663709?v=4'
9 | authorHomepage: 'https://github.com/shinnku-nikaidou'
10 | pin: true
11 | ---
12 |
13 | # 欢迎来到真红小站
14 |
15 | 真红小站(又名失落小站)创建的初衷是为了整合散落在网络中的 **galgame** 资源,方便玩家快速找到自己感兴趣的作品。本站收录了大量汉化与生肉作品,覆盖 PC、PSP、krkr、ONS 等不同平台。
16 |
17 | ## 网站特点
18 |
19 | - **资源齐全**:持续整理并更新各类视觉小说与黄油资源,力求覆盖主流及冷门作品。
20 | - **分类清晰**:按平台、语言及发行年代等维度进行归档,方便检索。
21 | - **界面简洁**:采用自研的前端框架,支持暗色模式及移动端浏览。
22 | - **社区互动**:欢迎用户在评论区分享体验或提供补档与汉化建议。
23 |
24 | ## 使用说明
25 |
26 | 1. 在页面顶部的搜索栏输入游戏名称,可快速定位资源页面。
27 | 2. 进入游戏详情后,可查看截图、简介及下载链接。
28 | 3. 若下载链接失效,请在评论区反馈,我们会尽快补档。
29 |
30 | ## 更新与维护
31 |
32 | 本站由个人维护,更新频率取决于站长的空闲时间和网络状况。为了保证下载体验,我们会定期检查链接、修复失效的文件,并根据社区反馈补充新资源。
33 |
34 | ## 声明
35 |
36 | 所有资源均来自互联网收集,仅供学习与交流使用。请在下载后的 24 小时内删除文件,若您喜欢某个作品,请支持正版,购买官方渠道的游戏或周边。
37 |
38 | 如果您是版权方,认为本站内容侵权,请通过作者主页提供的联系方式告知,我们将立即处理。
39 |
40 | ## 结语
41 |
42 | 感谢您的访问和支持,希望真红小站能帮助您找到心仪的作品。若有任何建议或问题,欢迎留言交流。
43 |
44 |
--------------------------------------------------------------------------------
/frontend/posts/notice/tech-stack.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: 真红小站前端技术栈
3 | banner: '/assets/81320307_p0.jpg'
4 | description: '介绍本站使用的 Next.js 及相关前端技术,涵盖框架、组件库、样式和工具等内容。'
5 | date: 2025-03-05
6 | authorUid: 1
7 | authorName: 'shinnku'
8 | authorAvatar: 'https://avatars.githubusercontent.com/u/74663709?v=4'
9 | authorHomepage: 'https://github.com/shinnku-nikaidou'
10 | pin: false
11 | ---
12 |
13 | # 项目技术栈
14 |
15 | 真红小站基于 **Next.js 15** 构建,采用 App Router 目录结构,实现服务器组件和客户端组件的灵活组合。项目全面使用 **TypeScript** 保证类型安全,并配合 **pnpm** 进行依赖管理。以下内容将详细介绍主要依赖的库和工具。
16 |
17 | ## 框架与基础库
18 |
19 | - **React 19**:视图库,用于构建交互式用户界面。
20 | - **Next.js 15**:提供文件路由、数据获取以及服务器端渲染等能力,同时借助 `next-mdx-remote` 支持 MDX 内容。
21 | - **TypeScript**:在编译阶段捕获潜在错误,提升代码质量。
22 | - **pnpm**:高效的包管理工具,利用软链接和缓存减少安装时间与磁盘占用。
23 |
24 | ## 组件与样式
25 |
26 | - **HeroUI v2**:主要的组件库,涵盖按钮、输入框、导航栏等常用组件,默认与 Tailwind CSS 风格一致。
27 | - **Tailwind CSS**:原子化 CSS 框架,结合 `@tailwindcss/typography` 和 `tailwind-variants` 进行主题扩展与变体管理。
28 | - **next-themes**:提供暗色和亮色模式切换,用户可根据偏好选择主题。
29 | - **Framer Motion**:在页面交互和元素切换中引入流畅的动画效果。
30 |
31 | ## Markdown 与内容管理
32 |
33 | - **next-mdx-remote**:解析和渲染 MDX 文件,使文章内容可以在构建时或运行时加载。
34 | - **gray-matter**:读取文章 Frontmatter 元数据,如标题、日期等信息。
35 | - 自定义的 `lib/mdx` 模块负责从 `posts` 目录读取文件、生成目录树以及封装 MDX 组件。
36 |
37 | ## 工具链
38 |
39 | - **ESLint** 与 **Prettier**:统一项目的代码风格,并在 `pnpm run lint` 脚本中自动修复大部分问题。
40 | - **PostCSS** 与 **Autoprefixer**:在构建阶段处理 CSS,兼容不同浏览器。
41 | - **Tailwind CSS CLI**:配合 `postcss.config.js` 和 `tailwind.config.js` 完成样式生成。
42 |
43 | ## 总结
44 |
45 | 依托以上技术栈,真红小站在保持界面美观的同时,也兼顾了性能和开发效率。Next.js 的服务器渲染能力使内容加载更快,Tailwind CSS 与 HeroUI 提供了丰富的 UI 组件,而 TypeScript、ESLint 等工具保证了代码的可维护性。未来项目也会持续跟进社区的更新与最佳实践,不断完善站点体验。
46 |
--------------------------------------------------------------------------------
/frontend/public/assets/81320307_p0.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shinnku-nikaidou/shinnku-com/f81dc487ab5396bc507f643c84c8b26f967cdde7/frontend/public/assets/81320307_p0.jpg
--------------------------------------------------------------------------------
/frontend/public/assets/GT5Bjdba4AAbCkU.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shinnku-nikaidou/shinnku-com/f81dc487ab5396bc507f643c84c8b26f967cdde7/frontend/public/assets/GT5Bjdba4AAbCkU.jpeg
--------------------------------------------------------------------------------
/frontend/public/assets/shinnku-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shinnku-nikaidou/shinnku-com/f81dc487ab5396bc507f643c84c8b26f967cdde7/frontend/public/assets/shinnku-logo.png
--------------------------------------------------------------------------------
/frontend/public/assets/upsetgal-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shinnku-nikaidou/shinnku-com/f81dc487ab5396bc507f643c84c8b26f967cdde7/frontend/public/assets/upsetgal-logo.png
--------------------------------------------------------------------------------
/frontend/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shinnku-nikaidou/shinnku-com/f81dc487ab5396bc507f643c84c8b26f967cdde7/frontend/public/favicon.ico
--------------------------------------------------------------------------------
/frontend/public/japan.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/frontend/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend/styles/blog.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --sh-class: hsl(var(--heroui-primary-500));
3 | --sh-identifier: hsl(var(--heroui-default-800));
4 | --sh-sign: hsl(var(--heroui-default-500));
5 | --sh-property: hsl(var(--heroui-primary-700));
6 | --sh-entity: hsl(var(--heroui-success-600));
7 | --sh-jsxliterals: hsl(var(--heroui-secondary-600));
8 | --sh-string: hsl(var(--heroui-success-500));
9 | --sh-keyword: hsl(var(--heroui-warning-600));
10 | --sh-comment: hsl(var(--heroui-default-400));
11 | }
12 |
--------------------------------------------------------------------------------
/frontend/styles/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | .my-icon-small {
6 | font-size: 16px;
7 | }
8 |
9 | .my-icon-medium {
10 | font-size: 24px;
11 | }
12 |
13 | .my-icon-large {
14 | font-size: 32px;
15 | }
16 |
17 | .box {
18 | position: fixed;
19 | z-index: 20;
20 | width: 100%;
21 | height: 100%;
22 | pointer-events: none;
23 | background-repeat: no-repeat;
24 | background-position: center;
25 | background-size: cover;
26 | opacity: 0.24;
27 | vertical-align: middle;
28 | border-style: none;
29 | }
30 |
--------------------------------------------------------------------------------
/frontend/styles/index.css:
--------------------------------------------------------------------------------
1 | @import './globals.css';
2 | @import './blog.css';
3 | @import './prose.css';
4 |
--------------------------------------------------------------------------------
/frontend/styles/prose.css:
--------------------------------------------------------------------------------
1 | .kun-prose {
2 | @apply leading-7 text-default-800;
3 | }
4 |
5 | /* Paragraphs */
6 | .kun-prose p {
7 | @apply leading-6;
8 | }
9 |
10 | /* Links */
11 | .kun-prose a {
12 | @apply font-medium text-primary-500 underline decoration-primary-200 underline-offset-2 transition-colors duration-200 hover:text-primary-600 hover:decoration-primary-400;
13 | }
14 |
15 | .kun-prose .anchor {
16 | @apply text-default-800 no-underline hover:text-primary-500;
17 | }
18 |
19 | /* Headings with improved spacing and refined typography */
20 | .kun-prose h1 {
21 | @apply mb-8 mt-0 scroll-m-20 text-4xl font-extrabold leading-tight tracking-tight text-default-900;
22 | }
23 |
24 | .kun-prose h2 {
25 | @apply mb-4 mt-16 scroll-m-20 border-t border-default-200 pt-8 text-2xl font-bold leading-tight tracking-tight text-default-900;
26 | }
27 |
28 | .kun-prose h3 {
29 | @apply mb-4 mt-8 scroll-m-20 text-xl font-semibold leading-snug tracking-tight text-default-900;
30 | }
31 |
32 | .kun-prose h4 {
33 | @apply mb-4 mt-6 scroll-m-20 text-lg font-semibold leading-snug tracking-tight text-default-900;
34 | }
35 |
36 | /* Lists with improved spacing and bullets */
37 | .kun-prose ul {
38 | @apply my-6 list-outside list-disc space-y-3 pl-8 marker:text-default-400;
39 | }
40 |
41 | .kun-prose ol {
42 | @apply my-6 list-outside list-decimal space-y-3 pl-8 marker:text-default-400;
43 | }
44 |
45 | .kun-prose li {
46 | @apply mt-2 leading-relaxed;
47 | }
48 |
49 | /* Nested lists */
50 | .kun-prose li > ul,
51 | .kun-prose li > ol {
52 | @apply my-2 space-y-2;
53 | }
54 |
55 | /* Code blocks with improved styling */
56 | .kun-prose code {
57 | @apply rounded-md border border-default-200 bg-default-100 px-1.5 py-0.5 font-mono text-sm text-default-800;
58 | }
59 |
60 | .kun-prose pre {
61 | @apply my-6 overflow-x-auto rounded-xl border border-default-200 bg-default-50 p-4 shadow-sm ring-1 ring-default-200/50;
62 | }
63 |
64 | .kun-prose pre code {
65 | @apply border-0 bg-transparent p-0 text-default-800 shadow-none;
66 | }
67 |
68 | /* Quotes with refined styling */
69 | .kun-prose blockquote {
70 | @apply my-8 rounded-r-lg border-l-4 border-primary-200 bg-primary-50/50 py-1 pl-6 pr-4 font-medium text-default-800 shadow-sm;
71 | }
72 |
73 | /* Tables with heroui-inspired design */
74 | .kun-prose table {
75 | @apply my-8 w-full overflow-hidden rounded-lg border border-default-200 bg-content1 shadow-sm;
76 | }
77 |
78 | .kun-prose thead {
79 | @apply bg-default-50;
80 | }
81 |
82 | .kun-prose th {
83 | @apply border-b border-default-200 px-4 py-3 text-left text-sm font-semibold uppercase tracking-wide text-default-700;
84 | }
85 |
86 | .kun-prose td {
87 | @apply border-b border-default-200 px-4 py-3 text-default-600;
88 | }
89 |
90 | .kun-prose tr:last-child td {
91 | @apply border-b-0;
92 | }
93 |
94 | .kun-prose tr:hover {
95 | @apply bg-default-50/50 transition-colors duration-200;
96 | }
97 |
98 | /* Additional refinements */
99 | .kun-prose hr {
100 | @apply my-12 border-default-200;
101 | }
102 |
103 | .kun-prose img {
104 | @apply my-8 rounded-lg border border-default-200 shadow-md;
105 | }
106 |
107 | .kun-prose figure {
108 | @apply my-8;
109 | }
110 |
111 | .kun-prose figcaption {
112 | @apply mt-3 text-center text-sm text-default-500;
113 | }
114 |
115 | /* Definition lists */
116 | .kun-prose dt {
117 | @apply mt-4 font-semibold text-default-900;
118 | }
119 |
120 | .kun-prose dd {
121 | @apply mt-2 border-l-2 border-default-200 pl-4;
122 | }
123 |
124 | /* Keyboard shortcuts */
125 | .kun-prose kbd {
126 | @apply rounded border border-default-300 bg-default-100 px-2 py-1 font-mono text-sm text-default-700 shadow-sm;
127 | }
128 |
129 | /* Mark text */
130 | .kun-prose mark {
131 | @apply rounded bg-warning-100 px-1 text-warning-800;
132 | }
133 |
134 | /* Katex */
135 | .kun-prose math {
136 | @apply my-6;
137 | }
138 |
--------------------------------------------------------------------------------
/frontend/tailwind.config.js:
--------------------------------------------------------------------------------
1 | import { heroui } from '@heroui/theme'
2 |
3 | /** @type {import('tailwindcss').Config} */
4 | module.exports = {
5 | content: [
6 | './components/**/*.{js,ts,jsx,tsx,mdx}',
7 | './app/**/*.{js,ts,jsx,tsx,mdx}',
8 | './node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}',
9 | ],
10 | theme: {
11 | extend: {
12 | fontFamily: {
13 | sans: ['var(--font-sans)'],
14 | mono: ['var(--font-mono)'],
15 | },
16 | },
17 | },
18 | darkMode: 'class',
19 | plugins: [heroui(), require('@tailwindcss/typography')],
20 | }
21 |
--------------------------------------------------------------------------------
/frontend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true,
17 | "plugins": [
18 | {
19 | "name": "next"
20 | }
21 | ],
22 | "paths": {
23 | "@/*": ["./*"]
24 | }
25 | },
26 | "include": [
27 | "next-env.d.ts",
28 | "**/*.ts",
29 | "**/*.tsx",
30 | ".next/types/**/*.ts",
31 | "eslint.config.js"
32 | ],
33 | "exclude": ["node_modules"]
34 | }
35 |
--------------------------------------------------------------------------------
/frontend/types/index.ts:
--------------------------------------------------------------------------------
1 | import { SVGProps } from 'react'
2 |
3 | export type IconSvgProps = SVGProps & {
4 | size?: number
5 | }
6 |
7 | export type FileInfo = {
8 | file_path: string
9 | upload_timestamp: number
10 | file_size: number
11 | }
12 |
13 | export type BucketFiles = FileInfo[]
14 |
15 | export interface TreeNode {
16 | [key: string]: TreeNode | FileInfo
17 | }
18 |
19 | export type SearchList = SearchItem[]
20 |
21 | export type SearchItem = {
22 | id: string
23 | info: FileInfo
24 | }
25 |
26 | export type Node =
27 | | { type: 'file'; name: string; info: FileInfo }
28 | | { type: 'folder'; name: string }
29 |
30 | export type Inode = Node[]
31 |
32 | export type Variety = '404' | 'file' | 'folder'
33 |
34 | export type RedisConfig = {
35 | host: string
36 | port: number
37 | password?: string
38 | database: number
39 | }
40 |
41 | export type Config = {
42 | redis: RedisConfig
43 | }
44 |
45 | export type GameType = '熟肉' | '生肉' | '手机'
46 |
--------------------------------------------------------------------------------
/frontend/types/wiki.ts:
--------------------------------------------------------------------------------
1 | export type WikipediaAnswer = {
2 | title: string
3 | text: string
4 | bg?: string
5 | }
6 |
--------------------------------------------------------------------------------
/frontend/utils/cn.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from 'clsx'
2 | import { twMerge } from 'tailwind-merge'
3 |
4 | export const cn = (...inputs: ClassValue[]) => {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/frontend/utils/formatDistanceToNow.ts:
--------------------------------------------------------------------------------
1 | import dayjs from 'dayjs'
2 | import relativeTime from 'dayjs/plugin/relativeTime'
3 |
4 | import { t } from '@/i18n'
5 |
6 | dayjs.extend(relativeTime)
7 |
8 | const replaceTimeUnits = (input: string) => {
9 | const replacements: Record = {
10 | an: '1',
11 | a: '1',
12 | second: t('seconds'),
13 | seconds: t('seconds'),
14 | minute: t('minutes'),
15 | minutes: t('minutes'),
16 | hour: t('hours'),
17 | hours: t('hours'),
18 | day: t('days'),
19 | days: t('days'),
20 | week: t('weeks'),
21 | weeks: t('weeks'),
22 | month: t('months'),
23 | months: t('months'),
24 | year: t('years'),
25 | years: t('years'),
26 | }
27 |
28 | const regex = new RegExp(Object.keys(replacements).join('|'), 'g')
29 |
30 | return input.replace(regex, (matched) => replacements[matched])
31 | }
32 |
33 | export const formatDistanceToNow = (pastTime: number | Date | string) => {
34 | const now = dayjs()
35 | const diffInSeconds = now.diff(pastTime, 'second')
36 |
37 | const time = () => {
38 | if (diffInSeconds < 60) {
39 | return now.to(pastTime, true)
40 | } else if (diffInSeconds < 3600) {
41 | return now.to(pastTime, true)
42 | } else if (diffInSeconds < 86400) {
43 | return now.to(pastTime, true)
44 | } else if (diffInSeconds < 2592000) {
45 | return now.to(pastTime, true)
46 | } else if (diffInSeconds < 31536000) {
47 | return now.to(pastTime, true)
48 | } else {
49 | return now.to(pastTime, true)
50 | }
51 | }
52 |
53 | if (time() === 'a few seconds') {
54 | return t('fewSeconds')
55 | }
56 |
57 | const localizedTime = replaceTimeUnits(time()).replace(/s\b/g, '')
58 |
59 | return `${localizedTime}${t('agoSuffix')}`
60 | }
61 |
--------------------------------------------------------------------------------
/frontend/utils/markdownToText.ts:
--------------------------------------------------------------------------------
1 | export const markdownToText = (markdown: string) => {
2 | return markdown
3 | .replace(/\[([^\]]+)\]\([^\)]+\)/g, '$1')
4 | .replace(/!\[([^\]]*)\]\([^\)]+\)/g, '$1')
5 | .replace(/(\*\*|__)(.*?)\1/g, '$2')
6 | .replace(/(\*|_)(.*?)\1/g, '$2')
7 | .replace(/^\s*(#{1,6})\s+(.*)/gm, '$2')
8 | .replace(/```[\s\S]*?```|`([^`]*)`/g, '$1')
9 | .replace(/^(-{3,}|\*{3,})$/gm, '')
10 | .replace(/^\s*([-*+]|\d+\.)\s+/gm, '')
11 | .replace(/\n{2,}/g, '\n')
12 | .trim()
13 | }
14 |
--------------------------------------------------------------------------------
/frontend/utils/time.ts:
--------------------------------------------------------------------------------
1 | import dayjs from 'dayjs'
2 |
3 | export const hourDiff = (upvoteTime: number, hours: number) => {
4 | if (upvoteTime === 0 || upvoteTime === undefined) {
5 | return false
6 | }
7 |
8 | const currentTime = dayjs()
9 |
10 | const time = dayjs(upvoteTime)
11 |
12 | return currentTime.diff(time, 'hour') <= hours
13 | }
14 |
15 | export const formatDate = (
16 | time: Date | number | string,
17 | config?: { isShowYear?: boolean; isPrecise?: boolean },
18 | ): string => {
19 | let formatString = 'MM-DD'
20 |
21 | if (config?.isShowYear) {
22 | formatString = 'YYYY-MM-DD'
23 | }
24 |
25 | if (config?.isPrecise) {
26 | formatString = `${formatString} - HH:mm`
27 | }
28 |
29 | return dayjs(time).format(formatString)
30 | }
31 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | langchain
2 | langchain_chroma
3 | langchain_ollama
4 | langchain_huggingface
5 | langchain_community
6 | tensorboardX
7 | google-genai
8 | google-generativeai
9 | google-cloud-aiplatform
10 | pipx
11 | maturin
12 | falcon
13 | uvicorn
14 |
--------------------------------------------------------------------------------