Skip to content

Commit 6981be9

Browse files
committed
feat(ssr): implement custom 404 page and enhance error handling
- Replaced the existing NotFound component with a new 404 page featuring animations and improved user experience. - Updated routing to utilize the new 404 component for better error handling. - Introduced a NotFoundError class and a callNotFound function to streamline error management across the application. - Refactored metadata fetching to handle not found scenarios gracefully. Signed-off-by: Innei <[email protected]>
1 parent 3a5c668 commit 6981be9

File tree

9 files changed

+332
-109
lines changed

9 files changed

+332
-109
lines changed
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
import { Header } from "@client/components/layout/header"
2+
import { openInFollowApp } from "@client/lib/helper"
3+
import { MemoedDangerousHTMLStyle } from "@follow/components/common/MemoedDangerousHTMLStyle.jsx"
4+
import { PoweredByFooter } from "@follow/components/common/PoweredByFooter.jsx"
5+
import { Button } from "@follow/components/ui/button/index.jsx"
6+
import { useTitle } from "@follow/hooks"
7+
import { m as motion } from "motion/react"
8+
import { Fragment, useEffect, useState } from "react"
9+
10+
export const NotFound = () => {
11+
const [isVisible, setIsVisible] = useState(false)
12+
const [glitchText, setGlitchText] = useState("404")
13+
const [isGlitching, setIsGlitching] = useState(false)
14+
15+
useTitle("404 - Page Not Found")
16+
17+
useEffect(() => {
18+
setIsVisible(true)
19+
}, [])
20+
21+
useEffect(() => {
22+
if (!isGlitching) return
23+
24+
const glitchTexts = ["404", "40₄", "4Ø4", "404", "4◯4", "4○4", "4𝟘4"]
25+
26+
const glitchInterval = setInterval(() => {
27+
const randomText = glitchTexts[Math.floor(Math.random() * glitchTexts.length)]
28+
setGlitchText(randomText || "404")
29+
}, 200)
30+
return () => {
31+
setGlitchText("404")
32+
clearInterval(glitchInterval)
33+
}
34+
}, [isGlitching])
35+
const handleGoHome = () => {
36+
window.location.href = "/"
37+
}
38+
39+
const handleOpenInApp = () => {
40+
openInFollowApp({
41+
deeplink: "",
42+
fallbackUrl: "/",
43+
})
44+
}
45+
46+
return (
47+
<div className="flex h-full flex-col">
48+
<MemoedDangerousHTMLStyle>
49+
{`:root {
50+
--container-max-width: 1024px;
51+
}
52+
53+
@keyframes float {
54+
0%, 100% { transform: translateY(0px); }
55+
50% { transform: translateY(-10px); }
56+
}
57+
58+
@keyframes pulse-glow {
59+
0%, 100% { box-shadow: 0 0 20px rgba(168, 162, 158, 0.3); }
60+
50% { box-shadow: 0 0 40px rgba(168, 162, 158, 0.6); }
61+
}
62+
63+
@keyframes shake {
64+
0%, 100% { transform: translateX(0); }
65+
25% { transform: translateX(-2px); }
66+
75% { transform: translateX(2px); }
67+
}
68+
69+
@keyframes glitch {
70+
0% { transform: translate(0); }
71+
20% { transform: translate(-2px, 2px); }
72+
40% { transform: translate(-2px, -2px); }
73+
60% { transform: translate(2px, 2px); }
74+
80% { transform: translate(2px, -2px); }
75+
100% { transform: translate(0); }
76+
}
77+
78+
.float-animation {
79+
animation: float 3s ease-in-out infinite;
80+
}
81+
82+
.pulse-glow-animation {
83+
animation: pulse-glow 2s ease-in-out infinite;
84+
}
85+
86+
.shake-animation {
87+
animation: shake 0.5s ease-in-out;
88+
}
89+
90+
.glitch-animation {
91+
animation: glitch 0.1s linear infinite;
92+
}`}
93+
</MemoedDangerousHTMLStyle>
94+
<Header />
95+
<main className="relative mx-auto flex w-full max-w-[var(--container-max-width)] flex-1 flex-col items-center justify-center pt-20">
96+
<Fragment>
97+
{/* 404 Icon with animations */}
98+
<motion.div
99+
className="mb-8 flex items-center justify-center"
100+
initial={{ scale: 0, rotate: -180 }}
101+
animate={{ scale: isVisible ? 1 : 0, rotate: isVisible ? 0 : -180 }}
102+
transition={{
103+
duration: 0.8,
104+
type: "spring",
105+
stiffness: 100,
106+
delay: 0.2,
107+
}}
108+
>
109+
<motion.div
110+
className="float-animation pulse-glow-animation flex size-32 cursor-pointer items-center justify-center rounded-full bg-zinc-100 dark:bg-zinc-800"
111+
whileHover={{
112+
scale: 1.1,
113+
rotate: [0, -5, 5, -5, 0],
114+
transition: { duration: 0.5 },
115+
}}
116+
whileTap={{ scale: 0.95 }}
117+
onClick={() => {
118+
const element = document.querySelector(".shake-animation")
119+
if (element) {
120+
element.classList.remove("shake-animation")
121+
setTimeout(() => element.classList.add("shake-animation"), 10)
122+
}
123+
}}
124+
onMouseEnter={() => {
125+
setIsGlitching(true)
126+
}}
127+
onMouseLeave={() => {
128+
setIsGlitching(false)
129+
}}
130+
>
131+
<motion.span
132+
className={`select-none text-4xl font-bold text-zinc-400 dark:text-zinc-600 ${isGlitching ? "glitch-animation" : ""}`}
133+
key={glitchText}
134+
initial={{ opacity: 0 }}
135+
animate={{ opacity: 1 }}
136+
transition={{ duration: 0.1 }}
137+
>
138+
{glitchText}
139+
</motion.span>
140+
</motion.div>
141+
</motion.div>
142+
143+
{/* Error Message with stagger animation */}
144+
<motion.div
145+
className="mb-8 flex flex-col items-center text-center"
146+
initial={{ opacity: 0, y: 50 }}
147+
animate={{ opacity: isVisible ? 1 : 0, y: isVisible ? 0 : 50 }}
148+
transition={{ duration: 0.6, delay: 0.4 }}
149+
>
150+
<motion.h1
151+
className="mb-4 text-3xl font-bold text-zinc-900 dark:text-zinc-100"
152+
initial={{ opacity: 0, x: -30 }}
153+
animate={{ opacity: isVisible ? 1 : 0, x: isVisible ? 0 : -30 }}
154+
transition={{ duration: 0.5, delay: 0.6 }}
155+
>
156+
Page Not Found
157+
</motion.h1>
158+
<motion.p
159+
className="max-w-md text-lg text-zinc-500 dark:text-zinc-400"
160+
initial={{ opacity: 0, x: 30 }}
161+
animate={{ opacity: isVisible ? 1 : 0, x: isVisible ? 0 : 30 }}
162+
transition={{ duration: 0.5, delay: 0.8 }}
163+
>
164+
Sorry, the page you are looking for doesn't exist or has been moved. Please check the
165+
URL or return to the homepage to continue browsing.
166+
</motion.p>
167+
</motion.div>
168+
169+
{/* Action Buttons with hover effects */}
170+
<motion.div
171+
className="flex flex-col items-center gap-4 sm:flex-row"
172+
initial={{ opacity: 0, scale: 0.8 }}
173+
animate={{ opacity: isVisible ? 1 : 0, scale: isVisible ? 1 : 0.8 }}
174+
transition={{ duration: 0.5, delay: 1 }}
175+
>
176+
<motion.div whileHover={{ scale: 1.05, y: -2 }} whileTap={{ scale: 0.95 }}>
177+
<Button
178+
onClick={handleGoHome}
179+
buttonClassName="px-6 py-2 transition-all duration-200"
180+
>
181+
Go Home
182+
</Button>
183+
</motion.div>
184+
185+
<motion.div whileHover={{ scale: 1.05, y: -2 }} whileTap={{ scale: 0.95 }}>
186+
<Button
187+
variant="outline"
188+
onClick={handleOpenInApp}
189+
buttonClassName="px-6 py-2 transition-all duration-200"
190+
>
191+
Open {APP_NAME}
192+
</Button>
193+
</motion.div>
194+
</motion.div>
195+
196+
{/* Additional Help with fade in */}
197+
<motion.div
198+
className="mt-12 text-center"
199+
initial={{ opacity: 0 }}
200+
animate={{ opacity: isVisible ? 1 : 0 }}
201+
transition={{ duration: 0.5, delay: 1.2 }}
202+
>
203+
<p className="text-sm text-zinc-400 dark:text-zinc-500">
204+
If you believe this is an error, please submit a issue on{" "}
205+
<motion.a
206+
className="text-accent transition-colors duration-200"
207+
href="https://github.com/rssnext/folo/issues"
208+
target="_blank"
209+
rel="noreferrer"
210+
whileHover={{ scale: 1.05 }}
211+
style={{ display: "inline-block" }}
212+
>
213+
GitHub
214+
</motion.a>
215+
</p>
216+
</motion.div>
217+
218+
{/* Floating particles effect */}
219+
<div className="pointer-events-none absolute inset-0 overflow-hidden">
220+
{Array.from({ length: 6 }).map((_, i) => (
221+
<motion.div
222+
key={i}
223+
className="absolute size-1 rounded-full bg-zinc-300 opacity-30 dark:bg-zinc-600"
224+
initial={{
225+
x: Math.random() * (typeof window !== "undefined" ? window.innerWidth : 1000),
226+
y: Math.random() * (typeof window !== "undefined" ? window.innerHeight : 800),
227+
}}
228+
animate={{
229+
y: [null, -100],
230+
opacity: [0.3, 0],
231+
}}
232+
transition={{
233+
duration: Math.random() * 3 + 2,
234+
repeat: Infinity,
235+
delay: Math.random() * 2,
236+
}}
237+
/>
238+
))}
239+
</div>
240+
</Fragment>
241+
</main>
242+
<PoweredByFooter />
243+
</div>
244+
)
245+
}

apps/ssr/client/components/common/NotFound.tsx

Lines changed: 0 additions & 30 deletions
This file was deleted.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1+
import { NotFound } from "@client/components/common/404"
12
import { useSyncThemeWebApp } from "@follow/hooks"
23
import { Outlet } from "react-router"
34

45
export const Component = () => {
56
useSyncThemeWebApp()
7+
if (document.documentElement.dataset.notFound === "true") {
8+
return <NotFound />
9+
}
610
return <Outlet />
711
}

apps/ssr/client/pages/(main)/share/feeds/[id]/metadata.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
11
import type { GetHydrateData } from "@client/lib/helper"
22
import { APPLE_APP_STORE_ID } from "@follow/constants"
33

4+
import { callNotFound } from "~/lib/not-found"
45
import { defineMetadata } from "~/meta-handler"
56

6-
const meta = defineMetadata(async ({ params, apiClient, origin, throwError }) => {
7+
const meta = defineMetadata(async ({ params, apiClient, origin }) => {
78
const feedId = params.id
89

9-
const feed = await apiClient.feeds.$get({ query: { id: feedId } }).catch((e) => {
10-
throwError(e.response?.status || 500, "Feed not found")
11-
throw e
12-
})
10+
const feed = await apiClient.feeds.$get({ query: { id: feedId } }).catch(callNotFound)
1311

1412
const { title, description } = feed.data.feed
1513

apps/ssr/client/pages/(main)/share/lists/[id]/metadata.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
import { APPLE_APP_STORE_ID } from "@follow/constants"
22

3+
import { callNotFound } from "~/lib/not-found"
34
import { defineMetadata } from "~/meta-handler"
45

5-
export default defineMetadata(async ({ params, apiClient, origin, throwError }) => {
6+
export default defineMetadata(async ({ params, apiClient, origin }) => {
67
const listId = params.id!
7-
const list = await apiClient.lists
8-
.$get({ query: { listId } })
9-
.catch((e) => throwError(e.response?.status || 500, "List not found"))
8+
const list = await apiClient.lists.$get({ query: { listId } }).catch(callNotFound)
109

1110
const { title, description } = list.data.list
1211
return [

0 commit comments

Comments
 (0)