feat: add share and edit links to preset modal, sync URL preset param

This commit is contained in:
2026-04-28 09:32:18 +08:00
parent f502d120a7
commit a2ff39c1b0
3 changed files with 78 additions and 14 deletions
+31 -3
View File
@@ -1,7 +1,7 @@
"use client" "use client"
import { useCallback, useState, useEffect, useMemo, useRef } from "react" import { useCallback, useState, useEffect, useMemo, useRef } from "react"
import { useRouter } from "next/navigation" import { useRouter, useSearchParams } from "next/navigation"
import Image from "next/image" import Image from "next/image"
import { import {
Gamepad2Icon, Gamepad2Icon,
@@ -201,6 +201,7 @@ export function GamePageClient({
gameId, gameId,
}: Props) { }: Props) {
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams()
const { data: session } = useSession() const { data: session } = useSession()
const [imgError, setImgError] = useState(false) const [imgError, setImgError] = useState(false)
const [stats, setStats] = useState<StatsResponse | null>(null) const [stats, setStats] = useState<StatsResponse | null>(null)
@@ -216,6 +217,32 @@ export function GamePageClient({
const [reportedPresets, setReportedPresets] = useState<Set<string>>(new Set()) const [reportedPresets, setReportedPresets] = useState<Set<string>>(new Set())
const presetsRef = useRef<HTMLDivElement>(null) const presetsRef = useRef<HTMLDivElement>(null)
// On mount, auto-open preset from URL
useEffect(() => {
const presetId = searchParams.get("preset")
queueMicrotask(() => {
if (presetId) {
setSelectedPresetId(presetId)
} else {
setSelectedPresetId(null)
}
})
}, [searchParams])
const handlePresetOpen = (presetId: string) => {
setSelectedPresetId(presetId)
const url = new URL(window.location.href)
url.searchParams.set("preset", presetId)
router.replace(url.toString(), { scroll: false })
}
const handlePresetClose = () => {
setSelectedPresetId(null)
const url = new URL(window.location.href)
url.searchParams.delete("preset")
router.replace(url.toString(), { scroll: false })
}
const handleDeletePreset = async (presetId: string) => { const handleDeletePreset = async (presetId: string) => {
try { try {
const res = await fetch(`/api/performance/${presetId}/user-delete`, { const res = await fetch(`/api/performance/${presetId}/user-delete`, {
@@ -751,7 +778,7 @@ export function GamePageClient({
return ( return (
<motion.div <motion.div
key={preset.id} key={preset.id}
onClick={() => setSelectedPresetId(preset.id)} onClick={() => handlePresetOpen(preset.id)}
className={`shrink-0 w-72 flex flex-col gap-3 p-4 rounded-xl border transition-colors cursor-pointer hover:border-primary/30 ${ className={`shrink-0 w-72 flex flex-col gap-3 p-4 rounded-xl border transition-colors cursor-pointer hover:border-primary/30 ${
raw raw
? "border-green-500/30 bg-green-500/5" ? "border-green-500/30 bg-green-500/5"
@@ -992,7 +1019,8 @@ export function GamePageClient({
return ( return (
<PresetDetailModal <PresetDetailModal
preset={preset} preset={preset}
onClose={() => setSelectedPresetId(null)} gameId={gameId}
onClose={handlePresetClose}
onDelete={handleDeletePreset} onDelete={handleDeletePreset}
onReport={handleReportPreset} onReport={handleReportPreset}
hasReported={reportedPresets.has(preset.id)} hasReported={reportedPresets.has(preset.id)}
+14 -11
View File
@@ -1,4 +1,5 @@
import type { Metadata } from "next" import type { Metadata } from "next"
import { Suspense } from "react"
import { notFound } from "next/navigation" import { notFound } from "next/navigation"
import { after } from "next/server" import { after } from "next/server"
import { db } from "@/lib/db/index" import { db } from "@/lib/db/index"
@@ -326,17 +327,19 @@ export default async function GamePage({
}), }),
}} }}
/> />
<GamePageClient <Suspense fallback={<div className="min-h-screen" />}>
game={serializedGame} <GamePageClient
counts={{ game={serializedGame}
benchmarks: benchmarkCount, counts={{
presets: presetCount, benchmarks: benchmarkCount,
comments: commentCount, presets: presetCount,
}} comments: commentCount,
platformSupport={platformSupport} }}
presets={serializedPresets} platformSupport={platformSupport}
gameId={game.id} presets={serializedPresets}
/> gameId={game.id}
/>
</Suspense>
</> </>
) )
} }
+33
View File
@@ -2,6 +2,7 @@
import { useState } from "react" import { useState } from "react"
import Image from "next/image" import Image from "next/image"
import { useRouter } from "next/navigation"
import { AnimatePresence, motion } from "motion/react" import { AnimatePresence, motion } from "motion/react"
import { useSession } from "@/lib/auth-client" import { useSession } from "@/lib/auth-client"
import type { GameSettingCategory } from "@/lib/db/schema/performanceEntries" import type { GameSettingCategory } from "@/lib/db/schema/performanceEntries"
@@ -15,11 +16,14 @@ import {
ShieldCheckIcon, ShieldCheckIcon,
XIcon, XIcon,
UserIcon, UserIcon,
ShareIcon,
PencilIcon,
} from "lucide-react" } from "lucide-react"
import { TiptapRenderer } from "@/components/tiptap-renderer" import { TiptapRenderer } from "@/components/tiptap-renderer"
interface Preset { interface Preset {
id: string id: string
gameId?: string
hardwareSlug: string hardwareSlug: string
hardwareName: string hardwareName: string
upvotes: number upvotes: number
@@ -45,6 +49,7 @@ interface Preset {
interface PresetDetailModalProps { interface PresetDetailModalProps {
preset: Preset preset: Preset
gameId: string
onClose: () => void onClose: () => void
onDelete: (presetId: string) => void onDelete: (presetId: string) => void
onReport: ( onReport: (
@@ -67,11 +72,13 @@ function formatValue(value: string | number | boolean): string {
export function PresetDetailModal({ export function PresetDetailModal({
preset, preset,
gameId,
onClose, onClose,
onDelete, onDelete,
onReport, onReport,
hasReported, hasReported,
}: PresetDetailModalProps) { }: PresetDetailModalProps) {
const router = useRouter()
const { data: session } = useSession() const { data: session } = useSession()
const [activeCategoryIndex, setActiveCategoryIndex] = useState(0) const [activeCategoryIndex, setActiveCategoryIndex] = useState(0)
@@ -81,6 +88,7 @@ export function PresetDetailModal({
"inaccurate" | "spam" | "inappropriate" | "other" "inaccurate" | "spam" | "inappropriate" | "other"
>("inaccurate") >("inaccurate")
const [reportDetails, setReportDetails] = useState("") const [reportDetails, setReportDetails] = useState("")
const [copied, setCopied] = useState(false)
const [userVote, setUserVote] = useState<"up" | "down" | null>(null) const [userVote, setUserVote] = useState<"up" | "down" | null>(null)
const [localUpvotes, setLocalUpvotes] = useState(preset.upvotes) const [localUpvotes, setLocalUpvotes] = useState(preset.upvotes)
@@ -90,6 +98,13 @@ export function PresetDetailModal({
const isAdmin = session?.user?.role === "admin" const isAdmin = session?.user?.role === "admin"
const isAuthenticated = !!session?.user const isAuthenticated = !!session?.user
const handleShare = () => {
const url = `${window.location.origin}/game/${gameId}?preset=${preset.id}`
navigator.clipboard.writeText(url)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const categories = preset.settingsJson ?? [] const categories = preset.settingsJson ?? []
const hasCategories = categories.length > 0 const hasCategories = categories.length > 0
const currentCategory = hasCategories ? categories[activeCategoryIndex] : null const currentCategory = hasCategories ? categories[activeCategoryIndex] : null
@@ -349,6 +364,24 @@ export function PresetDetailModal({
</> </>
)} )}
{(isOwner || isAdmin) && (
<button
onClick={() => router.push(`/game/${gameId}/submit?edit=${preset.id}`)}
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium text-primary border border-primary/20 hover:bg-primary/10 transition-colors cursor-pointer"
>
<PencilIcon className="h-4 w-4" />
Edit
</button>
)}
<button
onClick={handleShare}
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium text-primary border border-primary/20 hover:bg-primary/10 transition-colors cursor-pointer"
>
<ShareIcon className="h-4 w-4" />
{copied ? "Link copied!" : "Share"}
</button>
{session && !hasReported && ( {session && !hasReported && (
<> <>
{!showReportForm ? ( {!showReportForm ? (