Skip to content

Commit 4ac9e92

Browse files
committed
feat: adding a resource detail page
1 parent ee30b08 commit 4ac9e92

File tree

9 files changed

+746
-2
lines changed

9 files changed

+746
-2
lines changed

app/api/resource/detail/route.ts

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
import { z } from 'zod'
2+
import { NextRequest, NextResponse } from 'next/server'
3+
import { kunParseGetQuery } from '~/app/api/utils/parseQuery'
4+
import { getNSFWHeader } from '~/app/api/utils/getNSFWHeader'
5+
import { markdownToHtml } from '~/app/api/utils/markdownToHtml'
6+
import { prisma } from '~/prisma/index'
7+
import { verifyHeaderCookie } from '~/middleware/_verifyHeaderCookie'
8+
import type { PatchResource, PatchResourceDetail } from '~/types/api/resource'
9+
10+
const resourcePreviewInclude = {
11+
patch: {
12+
select: {
13+
id: true,
14+
name_en_us: true,
15+
name_ja_jp: true,
16+
name_zh_cn: true
17+
}
18+
},
19+
user: {
20+
include: {
21+
_count: {
22+
select: { patch_resource: true }
23+
}
24+
}
25+
},
26+
_count: {
27+
select: { like_by: true }
28+
}
29+
} satisfies Parameters<typeof prisma.patch_resource.findMany>[0]['include']
30+
31+
const mapPreviewResource = (resource: {
32+
id: number
33+
storage: string
34+
name: string
35+
model_name: string
36+
size: string
37+
type: string[]
38+
language: string[]
39+
platform: string[]
40+
note: string
41+
download: number
42+
patch_id: number
43+
created: Date
44+
patch: {
45+
name_en_us: string
46+
name_ja_jp: string
47+
name_zh_cn: string
48+
}
49+
user: {
50+
id: number
51+
name: string
52+
avatar: string
53+
_count: {
54+
patch_resource: number
55+
}
56+
}
57+
_count: {
58+
like_by: number
59+
}
60+
}): PatchResource => ({
61+
id: resource.id,
62+
storage: resource.storage,
63+
name: resource.name,
64+
modelName: resource.model_name,
65+
size: resource.size,
66+
type: resource.type,
67+
language: resource.language,
68+
platform: resource.platform,
69+
note: resource.note,
70+
likeCount: resource._count.like_by,
71+
download: resource.download,
72+
patchId: resource.patch_id,
73+
patchName: {
74+
'zh-cn': resource.patch.name_zh_cn,
75+
'ja-jp': resource.patch.name_ja_jp,
76+
'en-us': resource.patch.name_en_us
77+
},
78+
created: String(resource.created),
79+
user: {
80+
id: resource.user.id,
81+
name: resource.user.name,
82+
avatar: resource.user.avatar,
83+
patchCount: resource.user._count.patch_resource
84+
}
85+
})
86+
87+
const resourceIdSchema = z.object({
88+
resourceId: z.coerce
89+
.number({ message: '资源 ID 必须为数字' })
90+
.min(1)
91+
.max(9999999)
92+
})
93+
94+
export const getPatchResourceDetail = async (
95+
input: z.infer<typeof resourceIdSchema>,
96+
uid: number,
97+
nsfwEnable: Record<string, string | undefined>
98+
) => {
99+
const { resourceId } = input
100+
101+
const resource = await prisma.patch_resource.findFirst({
102+
where: { id: resourceId, patch: nsfwEnable },
103+
include: {
104+
user: {
105+
include: {
106+
_count: {
107+
select: { patch_resource: true }
108+
}
109+
}
110+
},
111+
_count: {
112+
select: { like_by: true }
113+
},
114+
like_by: {
115+
where: {
116+
user_id: uid
117+
}
118+
},
119+
patch: {
120+
include: {
121+
alias: true,
122+
company: {
123+
include: {
124+
company: true
125+
}
126+
},
127+
_count: {
128+
select: {
129+
favorite_by: true,
130+
contribute_by: true,
131+
resource: true,
132+
comment: true
133+
}
134+
}
135+
}
136+
}
137+
}
138+
})
139+
140+
if (!resource || !resource.patch) {
141+
return '未找到对应的资源'
142+
}
143+
144+
const samePatchResources = await prisma.patch_resource.findMany({
145+
where: {
146+
patch_id: resource.patch_id,
147+
id: { not: resourceId },
148+
status: 0
149+
},
150+
orderBy: { download: 'desc' },
151+
take: 5,
152+
include: resourcePreviewInclude
153+
})
154+
155+
let recommendationsRaw = samePatchResources
156+
157+
if (recommendationsRaw.length < 5) {
158+
const extraCandidates = await prisma.patch_resource.findMany({
159+
where: {
160+
id: { not: resourceId },
161+
patch_id: { not: resource.patch_id },
162+
status: 0,
163+
download: { gt: 500 },
164+
patch: nsfwEnable
165+
},
166+
take: 20,
167+
include: resourcePreviewInclude
168+
})
169+
170+
const uniqueExtras = extraCandidates.filter(
171+
(candidate) =>
172+
!recommendationsRaw.some((existing) => existing.id === candidate.id)
173+
)
174+
const needed = 5 - recommendationsRaw.length
175+
const shuffledExtras = uniqueExtras
176+
.sort(() => Math.random() - 0.5)
177+
.slice(0, needed)
178+
recommendationsRaw = [...recommendationsRaw, ...shuffledExtras]
179+
}
180+
181+
const recommendations = recommendationsRaw.slice(0, 5).map(mapPreviewResource)
182+
183+
const detail: PatchResourceDetail = {
184+
resource: {
185+
id: resource.id,
186+
storage: resource.storage,
187+
name: resource.name,
188+
modelName: resource.model_name,
189+
size: resource.size,
190+
type: resource.type,
191+
language: resource.language,
192+
note: resource.note,
193+
noteHtml: await markdownToHtml(resource.note),
194+
hash: resource.hash,
195+
content: resource.content,
196+
code: resource.code,
197+
password: resource.password,
198+
platform: resource.platform,
199+
likeCount: resource._count.like_by,
200+
isLike: resource.like_by.length > 0,
201+
download: resource.download,
202+
status: resource.status,
203+
userId: resource.user_id,
204+
patchId: resource.patch_id,
205+
created: String(resource.created),
206+
updateTime: resource.update_time,
207+
user: {
208+
id: resource.user.id,
209+
name: resource.user.name,
210+
avatar: resource.user.avatar,
211+
patchCount: resource.user._count.patch_resource
212+
}
213+
},
214+
patch: {
215+
id: resource.patch.id,
216+
name: {
217+
'zh-cn': resource.patch.name_zh_cn,
218+
'ja-jp': resource.patch.name_ja_jp,
219+
'en-us': resource.patch.name_en_us
220+
},
221+
banner: resource.patch.banner,
222+
view: resource.patch.view,
223+
download: resource.patch.download,
224+
type: resource.patch.type,
225+
language: resource.patch.language,
226+
platform: resource.patch.platform,
227+
content_limit: resource.patch.content_limit,
228+
released: resource.patch.released,
229+
alias: resource.patch.alias.map((alias) => alias.name),
230+
company: resource.patch.company.map((companyRelation) => ({
231+
id: companyRelation.company.id,
232+
name: companyRelation.company.name,
233+
logo: companyRelation.company.logo,
234+
count: companyRelation.company.count
235+
})),
236+
_count: resource.patch._count
237+
},
238+
recommendations
239+
}
240+
241+
return detail
242+
}
243+
244+
export const GET = async (req: NextRequest) => {
245+
const input = kunParseGetQuery(req, resourceIdSchema)
246+
if (typeof input === 'string') {
247+
return NextResponse.json(input)
248+
}
249+
250+
const nsfwEnable = getNSFWHeader(req)
251+
const payload = await verifyHeaderCookie(req)
252+
253+
const response = await getPatchResourceDetail(
254+
input,
255+
payload?.uid ?? 0,
256+
nsfwEnable
257+
)
258+
return NextResponse.json(response)
259+
}

app/resource/[id]/actions.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
'use server'
2+
3+
import { cache } from 'react'
4+
import { z } from 'zod'
5+
import { safeParseSchema } from '~/utils/actions/safeParseSchema'
6+
import { verifyHeaderCookie } from '~/utils/actions/verifyHeaderCookie'
7+
import { getNSFWHeader } from '~/utils/actions/getNSFWHeader'
8+
import { getPatchResourceDetail } from '~/app/api/resource/detail/route'
9+
10+
const resourceIdSchema = z.object({
11+
resourceId: z.coerce.number().min(1).max(9999999)
12+
})
13+
14+
export const kunGetResourceDetailActions = cache(
15+
async (params: z.infer<typeof resourceIdSchema>) => {
16+
const input = safeParseSchema(resourceIdSchema, params)
17+
if (typeof input === 'string') {
18+
return input
19+
}
20+
21+
const [payload, nsfwEnable] = await Promise.all([
22+
verifyHeaderCookie(),
23+
getNSFWHeader()
24+
])
25+
26+
const response = await getPatchResourceDetail(
27+
input,
28+
payload?.uid ?? 0,
29+
nsfwEnable
30+
)
31+
return response
32+
}
33+
)

app/resource/[id]/metadata.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { convert } from 'html-to-text'
2+
import type { Metadata } from 'next'
3+
import { kunMoyuMoe } from '~/config/moyu-moe'
4+
import type { PatchResourceDetail } from '~/types/api/resource'
5+
import { generateNullMetadata } from '~/utils/noIndex'
6+
import { getPreferredLanguageText } from '~/utils/getPreferredLanguageText'
7+
8+
export const generateKunResourceMetadata = (
9+
detail: PatchResourceDetail
10+
): Metadata => {
11+
const patchName = getPreferredLanguageText(detail.patch.name)
12+
const resourceTitle =
13+
detail.resource.name || detail.resource.modelName || patchName
14+
const pageTitle = `${resourceTitle} | ${patchName}`
15+
const descriptionSource =
16+
detail.resource.noteHtml && detail.resource.noteHtml.trim().length > 0
17+
? convert(detail.resource.noteHtml, {
18+
wordwrap: false,
19+
selectors: [{ selector: 'p', format: 'inline' }]
20+
})
21+
: `${resourceTitle} · ${patchName}`
22+
23+
if (detail.patch.content_limit === 'nsfw') {
24+
return generateNullMetadata(pageTitle)
25+
}
26+
27+
return {
28+
title: pageTitle,
29+
keywords: [resourceTitle, patchName, ...detail.patch.alias],
30+
description: descriptionSource.slice(0, 170),
31+
openGraph: {
32+
title: pageTitle,
33+
description: descriptionSource.slice(0, 170),
34+
type: 'article',
35+
images: [
36+
{
37+
url: detail.patch.banner,
38+
width: 1920,
39+
height: 1080,
40+
alt: patchName
41+
}
42+
]
43+
},
44+
twitter: {
45+
card: 'summary',
46+
title: pageTitle,
47+
description: descriptionSource.slice(0, 170),
48+
images: [detail.patch.banner]
49+
},
50+
alternates: {
51+
canonical: `${kunMoyuMoe.domain.main}/resource/${detail.resource.id}`
52+
}
53+
}
54+
}

app/resource/[id]/page.tsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import type { Metadata } from 'next'
2+
import { KunResourceDetail } from '~/components/resource/detail/ResourceDetail'
3+
import { ErrorComponent } from '~/components/error/ErrorComponent'
4+
import { kunGetResourceDetailActions } from './actions'
5+
import { generateKunResourceMetadata } from './metadata'
6+
7+
export const revalidate = 5
8+
9+
interface Props {
10+
params: Promise<{ id: string }>
11+
}
12+
13+
export const generateMetadata = async ({
14+
params
15+
}: Props): Promise<Metadata> => {
16+
const { id } = await params
17+
const detail = await kunGetResourceDetailActions({
18+
resourceId: Number(id)
19+
})
20+
if (typeof detail === 'string') {
21+
return {}
22+
}
23+
24+
return generateKunResourceMetadata(detail)
25+
}
26+
27+
export default async function Kun({ params }: Props) {
28+
const { id } = await params
29+
30+
const detail = await kunGetResourceDetailActions({
31+
resourceId: Number(id)
32+
})
33+
if (typeof detail === 'string') {
34+
return <ErrorComponent error={detail} />
35+
}
36+
37+
return (
38+
<div className="mx-auto w-full max-w-6xl space-y-10 py-6">
39+
<KunResourceDetail detail={detail} />
40+
</div>
41+
)
42+
}

0 commit comments

Comments
 (0)