My personal site. theclashfruit.me
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: auth

+666 -50
+6 -5
app/(admin)/admin/posts/edit/page.tsx
··· 8 8 import { db } from '@/lib/db/drizzle'; 9 9 import { commentsTable, postsTable, usersTable } from '@/lib/db/schema'; 10 10 11 + import { getUser } from '@/lib/auth'; 12 + 11 13 import { eq } from 'drizzle-orm'; 12 14 import { notFound, redirect, RedirectType } from 'next/navigation'; 13 15 ··· 30 32 <> 31 33 <h1>{action === 'new' ? 'Create' : 'Edit'} Post</h1> 32 34 33 - <NewPostForm 34 - initialData={post} 35 - action={action} 36 - /> 35 + <NewPostForm initialData={post} action={action} /> 37 36 </> 38 37 ); 39 38 } else if (params.action === 'new') { ··· 55 54 }; 56 55 57 56 const createPost = async () => { 57 + const user = await getUser(); 58 58 const rnd = createHash('sha1') 59 59 .update(new Date().toISOString()) 60 60 .digest('hex') ··· 64 64 title: `New Post ${rnd}`, 65 65 slug: `new-post-${rnd}`, 66 66 excerpt: 'Hello, World!', 67 - content: '# Hello, World!' 67 + content: '# Hello, World!', 68 + author: user!.id 68 69 }; 69 70 70 71 const insert = await db.insert(postsTable).values(newPost).returning();
+5 -2
app/(admin)/admin/posts/page.tsx
··· 6 6 import Link from 'next/link'; 7 7 8 8 import PostsTable from '@/components/admin/PostsTable'; 9 + import { Suspense } from 'react'; 9 10 10 11 export const metadata: Metadata = { 11 12 title: 'Admin > Posts' ··· 15 16 const posts = await db 16 17 .select() 17 18 .from(postsTable) 18 - .orderBy(postsTable.publishedAt); 19 + .orderBy(postsTable.id, postsTable.publishedAt); 19 20 20 21 return ( 21 22 <> ··· 23 24 24 25 <Link href={'/admin/posts/edit?action=new'}>New Post</Link> 25 26 26 - <PostsTable posts={posts} /> 27 + <Suspense> 28 + <PostsTable posts={posts} /> 29 + </Suspense> 27 30 </> 28 31 ); 29 32 }
+8 -1
app/(admin)/layout.tsx
··· 10 10 ImageIcon 11 11 } from 'lucide-react'; 12 12 13 + import { notFound } from 'next/navigation'; 14 + 15 + import { userAuthorized } from '@/lib/auth'; 16 + import { Permission } from '@/lib/enums'; 17 + 13 18 import '@/styles/layout/Admin.module.scss'; 14 19 15 - export default function AdminLayout({ 20 + export default async function AdminLayout({ 16 21 children 17 22 }: Readonly<{ 18 23 children: React.ReactNode; 19 24 }>) { 25 + if (!(await userAuthorized([Permission.Admin]))) notFound(); 26 + 20 27 return ( 21 28 <> 22 29 <aside>
+54
app/(auth)/auth/login/page.tsx
··· 1 + 'use client'; 2 + 3 + import Turnstile from '@/components/util/Turnstile'; 4 + 5 + import { loginAction } from '@/lib/actions'; 6 + 7 + import { LogIn } from 'lucide-react'; 8 + 9 + import Form from 'next/form'; 10 + import Link from 'next/link'; 11 + 12 + export default function Login() { 13 + return ( 14 + <div 15 + style={{ 16 + display: 'flex', 17 + flexDirection: 'column', 18 + alignItems: 'center', 19 + justifyContent: 'center', 20 + height: 'calc(100svh - (2 * 16px)' 21 + }} 22 + > 23 + <h1>Log In</h1> 24 + 25 + <Form 26 + action={loginAction} 27 + style={{ 28 + padding: '8px', 29 + display: 'flex', 30 + flexDirection: 'column', 31 + justifyContent: 'center', 32 + width: '70%', 33 + gap: '8px', 34 + marginBottom: '8px' 35 + }} 36 + > 37 + <input type="email" name="email" placeholder="E-Mail" /> 38 + <input type="password" name="password" placeholder="Password" /> 39 + 40 + <Turnstile siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE!} /> 41 + 42 + <button type="submit"> 43 + <LogIn /> 44 + Log In 45 + </button> 46 + </Form> 47 + 48 + <p> 49 + Copyright &copy; {new Date().getFullYear()}{' '} 50 + <Link href="https://theclashfruit.me">TheClashFruit</Link>. 51 + </p> 52 + </div> 53 + ); 54 + }
app/(auth)/login/page.tsx

This is a binary file and will not be displayed.

+10 -2
app/layout.tsx
··· 1 1 import type { Metadata, Viewport } from 'next'; 2 2 3 + import Script from 'next/script'; 3 4 import localFont from 'next/font/local'; 4 5 5 6 import '@wooorm/starry-night/style/both'; ··· 21 22 default: 'TheClashFruit', 22 23 template: 'TheClashFruit • %s' 23 24 }, 24 - description: 'A fluffy dragon smashing buttons on a keyboard and drawing lines on paper.', 25 + description: 26 + 'A fluffy dragon smashing buttons on a keyboard and drawing lines on paper.', 25 27 openGraph: { 26 28 type: 'website', 27 29 siteName: 'TheClashFruit', 28 - description: 'A fluffy dragon smashing buttons on a keyboard and drawing lines on paper.', 30 + description: 31 + 'A fluffy dragon smashing buttons on a keyboard and drawing lines on paper.', 29 32 url: 'https://theclashfruit.me' 30 33 } 31 34 }; ··· 48 51 <html lang="en"> 49 52 <body className={`${cantarell.variable} ${monaspaceRadon.variable}`}> 50 53 {children} 54 + 55 + <Script 56 + src="https://challenges.cloudflare.com/turnstile/v0/api.js" 57 + strategy="beforeInteractive" 58 + /> 51 59 </body> 52 60 </html> 53 61 );
+14 -20
app/not-found.tsx
··· 1 - import Header from '@/components/Header'; 2 - import NavBar from '@/components/NavBar'; 3 - import Footer from '@/components/Footer'; 4 - import Container from '@/components/Container'; 5 - 6 1 import styles from '@/styles/layout/Main.module.scss'; 2 + 3 + import { Frown } from 'lucide-react'; 4 + import Link from 'next/link'; 7 5 8 6 export const metadata = { 9 7 title: 'Not Found' ··· 11 9 12 10 export default function NotFound() { 13 11 return ( 14 - <> 15 - <Header /> 12 + <div className={styles.error}> 13 + <Frown size={48} /> 14 + 15 + <div className={styles.vr} /> 16 + 17 + <div> 18 + <h2>404</h2> 16 19 17 - <Container as="main" className={styles.main}> 20 + <p>The page you&apos;re looking for was not found.</p> 18 21 19 - 20 - <div className={styles.error}> 21 - <div> 22 - <h2>404</h2> 23 - 24 - <p>The page you&apos;re looking for was not found.</p> 25 - </div> 26 - </div> 27 - </Container> 28 - 29 - <Footer /> 30 - </> 22 + <Link href="/">Go Home</Link> 23 + </div> 24 + </div> 31 25 ); 32 26 }
+159
app/robots.ts
··· 1 + import type { MetadataRoute } from 'next'; 2 + 3 + export default function robots(): MetadataRoute.Robots { 4 + return { 5 + rules: [ 6 + { 7 + userAgent: [ 8 + 'AddSearchBot', 9 + 'AI2Bot', 10 + 'AI2Bot-DeepResearchEval', 11 + 'Ai2Bot-Dolma', 12 + 'aiHitBot', 13 + 'amazon-kendra', 14 + 'Amazonbot', 15 + 'AmazonBuyForMe', 16 + 'Amzn-SearchBot', 17 + 'Amzn-User', 18 + 'Andibot', 19 + 'Anomura', 20 + 'anthropic-ai', 21 + 'ApifyBot', 22 + 'ApifyWebsiteContentCrawler', 23 + 'Applebot', 24 + 'Applebot-Extended', 25 + 'Aranet-SearchBot', 26 + 'atlassian-bot', 27 + 'Awario', 28 + 'AzureAI-SearchBot', 29 + 'bedrockbot', 30 + 'bigsur.ai', 31 + 'Bravebot', 32 + 'Brightbot 1.0', 33 + 'BuddyBot', 34 + 'Bytespider', 35 + 'CCBot', 36 + 'Channel3Bot', 37 + 'ChatGLM-Spider', 38 + 'ChatGPT Agent', 39 + 'ChatGPT-User', 40 + 'Claude-SearchBot', 41 + 'Claude-User', 42 + 'Claude-Web', 43 + 'ClaudeBot', 44 + 'Cloudflare-AutoRAG', 45 + 'CloudVertexBot', 46 + 'cohere-ai', 47 + 'cohere-training-data-crawler', 48 + 'Cotoyogi', 49 + 'Crawl4AI', 50 + 'Crawlspace', 51 + 'Datenbank Crawler', 52 + 'DeepSeekBot', 53 + 'Devin', 54 + 'Diffbot', 55 + 'DuckAssistBot', 56 + 'Echobot Bot', 57 + 'EchoboxBot', 58 + 'ExaBot', 59 + 'FacebookBot', 60 + 'facebookexternalhit', 61 + 'Factset_spyderbot', 62 + 'FirecrawlAgent', 63 + 'FriendlyCrawler', 64 + 'Gemini-Deep-Research', 65 + 'Google-Agent', 66 + 'Google-CloudVertexBot', 67 + 'Google-Extended', 68 + 'Google-Firebase', 69 + 'Google-NotebookLM', 70 + 'GoogleAgent-Mariner', 71 + 'GoogleOther', 72 + 'GoogleOther-Image', 73 + 'GoogleOther-Video', 74 + 'GPTBot', 75 + 'iAskBot', 76 + 'iaskspider', 77 + 'iaskspider/2.0', 78 + 'IbouBot', 79 + 'ICC-Crawler', 80 + 'ImagesiftBot', 81 + 'imageSpider', 82 + 'img2dataset', 83 + 'ISSCyberRiskCrawler', 84 + 'kagi-fetcher', 85 + 'Kangaroo Bot', 86 + 'KlaviyoAIBot', 87 + 'KunatoCrawler', 88 + 'laion-huggingface-processor', 89 + 'LAIONDownloader', 90 + 'LCC', 91 + 'LinerBot', 92 + 'Linguee Bot', 93 + 'LinkupBot', 94 + 'Manus-User', 95 + 'meta-externalagent', 96 + 'Meta-ExternalAgent', 97 + 'meta-externalfetcher', 98 + 'Meta-ExternalFetcher', 99 + 'meta-webindexer', 100 + 'MistralAI-User', 101 + 'MistralAI-User/1.0', 102 + 'MyCentralAIScraperBot', 103 + 'netEstate Imprint Crawler', 104 + 'NotebookLM', 105 + 'NovaAct', 106 + 'OAI-SearchBot', 107 + 'omgili', 108 + 'omgilibot', 109 + 'OpenAI', 110 + 'Operator', 111 + 'PanguBot', 112 + 'Panscient', 113 + 'panscient.com', 114 + 'Perplexity-User', 115 + 'PerplexityBot', 116 + 'PetalBot', 117 + 'PhindBot', 118 + 'Poggio-Citations', 119 + 'Poseidon Research Crawler', 120 + 'QualifiedBot', 121 + 'QuillBot', 122 + 'quillbot.com', 123 + 'SBIntuitionsBot', 124 + 'Scrapy', 125 + 'SemrushBot-OCOB', 126 + 'SemrushBot-SWA', 127 + 'ShapBot', 128 + 'Sidetrade indexer bot', 129 + 'Spider', 130 + 'TavilyBot', 131 + 'TerraCotta', 132 + 'Thinkbot', 133 + 'TikTokSpider', 134 + 'Timpibot', 135 + 'TwinAgent', 136 + 'VelenPublicWebCrawler', 137 + 'WARDBot', 138 + 'Webzio-Extended', 139 + 'webzio-extended', 140 + 'wpbot', 141 + 'WRTNBot', 142 + 'YaK', 143 + 'YandexAdditional', 144 + 'YandexAdditionalBot', 145 + 'YouBot', 146 + 'ZanistaBot' 147 + ], 148 + disallow: '/' 149 + }, 150 + { 151 + userAgent: '*', 152 + allow: '/', 153 + disallow: ['/admin/', '/auth/', '/api/'], 154 + crawlDelay: 1 155 + } 156 + ], 157 + sitemap: 'https://theclashfruit.me/sitemap.xml' 158 + }; 159 + }
+37
components/util/Turnstile.tsx
··· 1 + 'use client'; 2 + 3 + import { useEffect, useRef } from 'react'; 4 + 5 + declare global { 6 + interface Window { 7 + turnstile?: { 8 + render: ( 9 + container: string | HTMLElement, 10 + options: { sitekey: string } 11 + ) => string; 12 + remove: (widgetId: string) => void; 13 + }; 14 + } 15 + } 16 + 17 + export default function Turnstile({ siteKey }: { siteKey: string }) { 18 + const containerRef = useRef<HTMLDivElement>(null); 19 + const widgetIdRef = useRef<string | null>(null); 20 + 21 + useEffect(() => { 22 + if (window.turnstile && containerRef.current && !widgetIdRef.current) { 23 + widgetIdRef.current = window.turnstile.render(containerRef.current, { 24 + sitekey: siteKey 25 + }); 26 + } 27 + 28 + return () => { 29 + if (widgetIdRef.current && window.turnstile) { 30 + window.turnstile.remove(widgetIdRef.current); 31 + widgetIdRef.current = null; 32 + } 33 + }; 34 + }, [siteKey]); 35 + 36 + return <div ref={containerRef} className="turnstile" data-size="flexible" />; 37 + }
+76 -16
lib/actions.ts
··· 1 1 'use server'; 2 2 3 3 import { db } from '@/lib/db/drizzle'; 4 - import { commentsTable, postsTable } from './db/schema'; 4 + import { commentsTable, postsTable, usersTable } from './db/schema'; 5 5 import { refresh } from 'next/cache'; 6 6 import { eq } from 'drizzle-orm'; 7 + import { verify } from './turnstile'; 8 + import { headers } from 'next/headers'; 9 + import { getClientIp } from 'next-request-ip'; 10 + import bcrypt from 'bcrypt'; 11 + import { sign } from 'jsonwebtoken'; 12 + import { addDays } from 'date-fns'; 13 + import { cookies } from 'next/headers'; 14 + import { redirect } from 'next/navigation'; 15 + import { userAuthorized } from './auth'; 16 + import { Permission } from './enums'; 7 17 8 18 export const createCommentAction = async (formData: FormData) => { 9 19 const reply = formData.get('reply') ?? false; ··· 28 38 post: data.post 29 39 }); 30 40 } 41 + 42 + refresh(); 31 43 }; 32 44 33 45 export const editPostAction = async (formData: FormData) => { ··· 40 52 content: formData.get('content')!.toString() 41 53 }; 42 54 43 - await db.update(postsTable).set(data).where(eq(postsTable.id, id)); 55 + if (await userAuthorized([Permission.Admin])) 56 + await db.update(postsTable).set(data).where(eq(postsTable.id, id)); 44 57 refresh(); 45 58 }; 46 59 47 60 export const publishPostAction = async (id: bigint) => { 48 - await db 49 - .update(postsTable) 50 - .set({ 51 - draft: false, 52 - publishedAt: new Date() 53 - }) 54 - .where(eq(postsTable.id, id)); 61 + if (await userAuthorized([Permission.Admin])) 62 + await db 63 + .update(postsTable) 64 + .set({ 65 + draft: false, 66 + publishedAt: new Date() 67 + }) 68 + .where(eq(postsTable.id, id)); 55 69 refresh(); 56 70 }; 57 71 58 72 export const unPublishPostAction = async (id: bigint) => { 59 - await db 60 - .update(postsTable) 61 - .set({ 62 - draft: true 63 - }) 64 - .where(eq(postsTable.id, id)); 73 + if (await userAuthorized([Permission.Admin])) 74 + await db 75 + .update(postsTable) 76 + .set({ 77 + draft: true 78 + }) 79 + .where(eq(postsTable.id, id)); 65 80 refresh(); 66 81 }; 67 82 68 83 export const deletePostAction = async (id: bigint) => { 69 - await db.delete(postsTable).where(eq(postsTable.id, id)); 84 + if (await userAuthorized([Permission.Admin])) 85 + await db.delete(postsTable).where(eq(postsTable.id, id)); 70 86 refresh(); 71 87 }; 88 + 89 + export const loginAction = async (formData: FormData) => { 90 + const turnstile = 91 + formData.get('cf-turnstile-response') ?? 'obv-invalid-token'; 92 + const ip = getClientIp(await headers()) || '::1'; 93 + 94 + if (await verify(turnstile.toString(), ip)) { 95 + const email = formData.get('email')!.toString(); 96 + const password = formData.get('password')!.toString(); 97 + 98 + const users = await db 99 + .select({ 100 + id: usersTable.id, 101 + email: usersTable.email, 102 + password: usersTable.password 103 + }) 104 + .from(usersTable) 105 + .where(eq(usersTable.email, email)) 106 + .limit(1); 107 + 108 + if (users.length !== 1) throw new Error('invalid email'); 109 + const user = users[0]; 110 + 111 + const valid = await bcrypt.compare(password, user.password); 112 + if (!valid) throw new Error('invalid password'); 113 + 114 + const token = sign( 115 + { 116 + id: user.id.toString() 117 + }, 118 + process.env.JWT_SECRET!, 119 + { expiresIn: '30d' } 120 + ); 121 + 122 + (await cookies()).set( 123 + process.env.NEXT_PUBLIC_COOKIE || 'tcf_token', 124 + token, 125 + { 126 + expires: addDays(new Date(), 30) 127 + } 128 + ); 129 + redirect('/admin'); 130 + } 131 + };
+70
lib/auth.ts
··· 1 + import 'server-only'; 2 + 3 + import { cookies } from 'next/headers'; 4 + 5 + import { JwtPayload, verify } from 'jsonwebtoken'; 6 + import { eq, InferSelectModel } from 'drizzle-orm'; 7 + 8 + import { db } from '@/lib/db/drizzle'; 9 + import { usersTable } from '@/lib/db/schema'; 10 + import { Permission } from '@/lib/enums'; 11 + 12 + interface TokenData extends JwtPayload { 13 + id: string; 14 + } 15 + 16 + export const getSessionToken = async () => { 17 + const cookie = (await cookies()).get( 18 + process.env.NEXT_PUBLIC_COOKIE || 'tcf_token' 19 + )?.value; 20 + 21 + if (!cookie) return undefined; 22 + return cookie; 23 + }; 24 + 25 + export const userAuthorized = async ( 26 + permissions: Permission[] 27 + ): Promise<boolean> => { 28 + const token = await getSessionToken(); 29 + if (!token) return false; 30 + 31 + try { 32 + const data = verify(token, process.env.JWT_SECRET!) as TokenData; 33 + const user = await db 34 + .select({ 35 + permissions: usersTable.permissions 36 + }) 37 + .from(usersTable) 38 + .where(eq(usersTable.id, BigInt(data.id))) 39 + .limit(1); 40 + 41 + if (user.length !== 1) return false; 42 + 43 + const required = permissions.reduce((acc, p) => acc | p, 0); 44 + return (user[0].permissions & required) === required; 45 + } catch (_: unknown) { 46 + return false; 47 + } 48 + }; 49 + 50 + export const getUser = async (): Promise< 51 + InferSelectModel<typeof usersTable> | undefined 52 + > => { 53 + const token = await getSessionToken(); 54 + if (!token) return undefined; 55 + 56 + try { 57 + const data = verify(token, process.env.JWT_SECRET!) as TokenData; 58 + const user = await db 59 + .select() 60 + .from(usersTable) 61 + .where(eq(usersTable.id, BigInt(data.id))) 62 + .limit(1); 63 + 64 + if (user.length !== 1) return undefined; 65 + 66 + return user[0]; 67 + } catch (_: unknown) { 68 + return undefined; 69 + } 70 + };
+1 -1
lib/enums.ts
··· 1 - export enum Permissions { 1 + export enum Permission { 2 2 None = 0, 3 3 Admin = 1 << 0 4 4 }
+24
lib/turnstile.ts
··· 1 + const url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; 2 + 3 + export const verify = async ( 4 + token: string, 5 + ip: string, 6 + idempotencyKey?: string 7 + ) => { 8 + const res = await fetch(url, { 9 + method: 'POST', 10 + headers: { 11 + 'Content-Type': 'application/json' 12 + }, 13 + body: JSON.stringify({ 14 + secret: process.env.TURNSTILE_SECRET!, 15 + response: token, 16 + remoteip: ip, 17 + idempotency_key: idempotencyKey 18 + }) 19 + }); 20 + 21 + const json = await res.json(); 22 + 23 + return json.success; 24 + };
+2 -1
next.config.ts
··· 13 13 } 14 14 }, 15 15 experimental: { 16 - viewTransition: true 16 + viewTransition: true, 17 + authInterrupts: true 17 18 } 18 19 }; 19 20
+5
package.json
··· 19 19 "@types/mdx": "^2.0.13", 20 20 "@wooorm/starry-night": "^3.9.0", 21 21 "bcrypt": "^6.0.0", 22 + "date-fns": "^4.1.0", 22 23 "drizzle-orm": "^0.45.1", 23 24 "feed": "^5.2.0", 25 + "jsonwebtoken": "^9.0.3", 24 26 "lucide-react": "^0.577.0", 25 27 "next": "16.1.6", 26 28 "next-mdx-remote-client": "^2.1.9", 29 + "next-request-ip": "^1.0.7", 27 30 "pg": "^8.20.0", 28 31 "react": "19.2.3", 29 32 "react-dom": "19.2.3", ··· 32 35 "sass": "^1.98.0" 33 36 }, 34 37 "devDependencies": { 38 + "@types/bcrypt": "^6.0.0", 39 + "@types/jsonwebtoken": "^9.0.10", 35 40 "@types/node": "^20", 36 41 "@types/react": "^19", 37 42 "@types/react-dom": "^19",
+127
pnpm-lock.yaml
··· 38 38 bcrypt: 39 39 specifier: ^6.0.0 40 40 version: 6.0.0 41 + date-fns: 42 + specifier: ^4.1.0 43 + version: 4.1.0 41 44 drizzle-orm: 42 45 specifier: ^0.45.1 43 46 version: 0.45.1(pg@8.20.0) 44 47 feed: 45 48 specifier: ^5.2.0 46 49 version: 5.2.0 50 + jsonwebtoken: 51 + specifier: ^9.0.3 52 + version: 9.0.3 47 53 lucide-react: 48 54 specifier: ^0.577.0 49 55 version: 0.577.0(react@19.2.3) ··· 53 59 next-mdx-remote-client: 54 60 specifier: ^2.1.9 55 61 version: 2.1.9(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(unified@11.0.5) 62 + next-request-ip: 63 + specifier: ^1.0.7 64 + version: 1.0.7 56 65 pg: 57 66 specifier: ^8.20.0 58 67 version: 8.20.0 ··· 72 81 specifier: ^1.98.0 73 82 version: 1.98.0 74 83 devDependencies: 84 + '@types/bcrypt': 85 + specifier: ^6.0.0 86 + version: 6.0.0 87 + '@types/jsonwebtoken': 88 + specifier: ^9.0.10 89 + version: 9.0.10 75 90 '@types/node': 76 91 specifier: ^20 77 92 version: 20.19.37 ··· 1632 1647 '@tybys/wasm-util@0.10.1': 1633 1648 resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} 1634 1649 1650 + '@types/bcrypt@6.0.0': 1651 + resolution: {integrity: sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==} 1652 + 1635 1653 '@types/debug@4.1.12': 1636 1654 resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} 1637 1655 ··· 1649 1667 1650 1668 '@types/json5@0.0.29': 1651 1669 resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} 1670 + 1671 + '@types/jsonwebtoken@9.0.10': 1672 + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} 1652 1673 1653 1674 '@types/mdast@4.0.4': 1654 1675 resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} ··· 1975 1996 resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} 1976 1997 engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} 1977 1998 hasBin: true 1999 + 2000 + buffer-equal-constant-time@1.0.1: 2001 + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} 1978 2002 1979 2003 buffer-from@1.1.2: 1980 2004 resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} ··· 2104 2128 resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} 2105 2129 engines: {node: '>= 0.4'} 2106 2130 2131 + date-fns@4.1.0: 2132 + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} 2133 + 2107 2134 debug@3.2.7: 2108 2135 resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} 2109 2136 peerDependencies: ··· 2269 2296 dunder-proto@1.0.1: 2270 2297 resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} 2271 2298 engines: {node: '>= 0.4'} 2299 + 2300 + ecdsa-sig-formatter@1.0.11: 2301 + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} 2272 2302 2273 2303 electron-to-chromium@1.5.313: 2274 2304 resolution: {integrity: sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==} ··· 2851 2881 engines: {node: '>=6'} 2852 2882 hasBin: true 2853 2883 2884 + jsonwebtoken@9.0.3: 2885 + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} 2886 + engines: {node: '>=12', npm: '>=6'} 2887 + 2854 2888 jsx-ast-utils@3.3.5: 2855 2889 resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} 2856 2890 engines: {node: '>=4.0'} 2891 + 2892 + jwa@2.0.1: 2893 + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} 2894 + 2895 + jws@4.0.1: 2896 + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} 2857 2897 2858 2898 keyv@4.5.4: 2859 2899 resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} ··· 2883 2923 lodash.debounce@4.0.8: 2884 2924 resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} 2885 2925 2926 + lodash.includes@4.3.0: 2927 + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} 2928 + 2929 + lodash.isboolean@3.0.3: 2930 + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} 2931 + 2932 + lodash.isinteger@4.0.4: 2933 + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} 2934 + 2935 + lodash.isnumber@3.0.3: 2936 + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} 2937 + 2938 + lodash.isplainobject@4.0.6: 2939 + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} 2940 + 2941 + lodash.isstring@4.0.1: 2942 + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} 2943 + 2886 2944 lodash.merge@4.6.2: 2887 2945 resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} 2946 + 2947 + lodash.once@4.1.1: 2948 + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} 2888 2949 2889 2950 longest-streak@3.1.0: 2890 2951 resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} ··· 3115 3176 react: '>= 19.1.0' 3116 3177 react-dom: '>= 19.1.0' 3117 3178 3179 + next-request-ip@1.0.7: 3180 + resolution: {integrity: sha512-kpc35W35MCk4wNeStSMu2b2vB74q8e5YRYbmHY/bCQMq1LiQ53oQ0wqhnwD4a/KOyc7hPp7g0cjK3ccOFvm6DQ==} 3181 + engines: {node: '>=20'} 3182 + 3118 3183 next@16.1.6: 3119 3184 resolution: {integrity: sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==} 3120 3185 engines: {node: '>=20.9.0'} ··· 3438 3503 safe-array-concat@1.1.3: 3439 3504 resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} 3440 3505 engines: {node: '>=0.4'} 3506 + 3507 + safe-buffer@5.2.1: 3508 + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} 3441 3509 3442 3510 safe-push-apply@1.0.0: 3443 3511 resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} ··· 5244 5312 tslib: 2.8.1 5245 5313 optional: true 5246 5314 5315 + '@types/bcrypt@6.0.0': 5316 + dependencies: 5317 + '@types/node': 20.19.37 5318 + 5247 5319 '@types/debug@4.1.12': 5248 5320 dependencies: 5249 5321 '@types/ms': 2.1.0 ··· 5261 5333 '@types/json-schema@7.0.15': {} 5262 5334 5263 5335 '@types/json5@0.0.29': {} 5336 + 5337 + '@types/jsonwebtoken@9.0.10': 5338 + dependencies: 5339 + '@types/ms': 2.1.0 5340 + '@types/node': 20.19.37 5264 5341 5265 5342 '@types/mdast@4.0.4': 5266 5343 dependencies: ··· 5607 5684 node-releases: 2.0.36 5608 5685 update-browserslist-db: 1.2.3(browserslist@4.28.1) 5609 5686 5687 + buffer-equal-constant-time@1.0.1: {} 5688 + 5610 5689 buffer-from@1.1.2: {} 5611 5690 5612 5691 call-bind-apply-helpers@1.0.2: ··· 5734 5813 es-errors: 1.3.0 5735 5814 is-data-view: 1.0.2 5736 5815 5816 + date-fns@4.1.0: {} 5817 + 5737 5818 debug@3.2.7: 5738 5819 dependencies: 5739 5820 ms: 2.1.3 ··· 5814 5895 call-bind-apply-helpers: 1.0.2 5815 5896 es-errors: 1.3.0 5816 5897 gopd: 1.2.0 5898 + 5899 + ecdsa-sig-formatter@1.0.11: 5900 + dependencies: 5901 + safe-buffer: 5.2.1 5817 5902 5818 5903 electron-to-chromium@1.5.313: {} 5819 5904 ··· 6651 6736 6652 6737 json5@2.2.3: {} 6653 6738 6739 + jsonwebtoken@9.0.3: 6740 + dependencies: 6741 + jws: 4.0.1 6742 + lodash.includes: 4.3.0 6743 + lodash.isboolean: 3.0.3 6744 + lodash.isinteger: 4.0.4 6745 + lodash.isnumber: 3.0.3 6746 + lodash.isplainobject: 4.0.6 6747 + lodash.isstring: 4.0.1 6748 + lodash.once: 4.1.1 6749 + ms: 2.1.3 6750 + semver: 7.7.4 6751 + 6654 6752 jsx-ast-utils@3.3.5: 6655 6753 dependencies: 6656 6754 array-includes: 3.1.9 6657 6755 array.prototype.flat: 1.3.3 6658 6756 object.assign: 4.1.7 6659 6757 object.values: 1.2.1 6758 + 6759 + jwa@2.0.1: 6760 + dependencies: 6761 + buffer-equal-constant-time: 1.0.1 6762 + ecdsa-sig-formatter: 1.0.11 6763 + safe-buffer: 5.2.1 6764 + 6765 + jws@4.0.1: 6766 + dependencies: 6767 + jwa: 2.0.1 6768 + safe-buffer: 5.2.1 6660 6769 6661 6770 keyv@4.5.4: 6662 6771 dependencies: ··· 6683 6792 6684 6793 lodash.debounce@4.0.8: {} 6685 6794 6795 + lodash.includes@4.3.0: {} 6796 + 6797 + lodash.isboolean@3.0.3: {} 6798 + 6799 + lodash.isinteger@4.0.4: {} 6800 + 6801 + lodash.isnumber@3.0.3: {} 6802 + 6803 + lodash.isplainobject@4.0.6: {} 6804 + 6805 + lodash.isstring@4.0.1: {} 6806 + 6686 6807 lodash.merge@4.6.2: {} 6808 + 6809 + lodash.once@4.1.1: {} 6687 6810 6688 6811 longest-streak@3.1.0: {} 6689 6812 ··· 7181 7304 - supports-color 7182 7305 - unified 7183 7306 7307 + next-request-ip@1.0.7: {} 7308 + 7184 7309 next@16.1.6(@babel/core@7.29.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.98.0): 7185 7310 dependencies: 7186 7311 '@next/env': 16.1.6 ··· 7582 7707 get-intrinsic: 1.3.0 7583 7708 has-symbols: 1.1.0 7584 7709 isarray: 2.0.5 7710 + 7711 + safe-buffer@5.2.1: {} 7585 7712 7586 7713 safe-push-apply@1.0.0: 7587 7714 dependencies:
+9
styles/globals.scss
··· 53 53 } 54 54 } 55 55 56 + .turnstile { 57 + width: 100%; 58 + 59 + border: 1px solid var(--outlineVariant); 60 + border-radius: 16px; 61 + 62 + overflow: hidden; 63 + } 64 + 56 65 // View Transition 57 66 // TODO: Add some fancy anim 58 67
+19 -2
styles/layout/Main.module.scss
··· 11 11 } 12 12 13 13 .error { 14 - h2 { 15 - font-weight: 600; 14 + display: flex; 15 + 16 + align-items: center; 17 + justify-content: center; 18 + 19 + gap: 32px; 20 + 21 + height: calc(100svh - (2 * 16px)); 22 + 23 + > div { 24 + h2 { 25 + margin: 0; 26 + } 16 27 } 17 28 } 29 + 30 + .vr { 31 + border-left: 1px solid var(--outlineVariant); 32 + 33 + height: 64px; 34 + }
+40
styles/typography.module.scss
··· 122 122 img { 123 123 max-width: 100%; 124 124 } 125 + 126 + button { 127 + display: flex; 128 + 129 + align-items: center; 130 + justify-content: center; 131 + 132 + gap: 12px; 133 + 134 + font-weight: 400; 135 + 136 + background: var(--primary); 137 + color: var(--onPrimary); 138 + 139 + border: 1px solid transparent; 140 + border-radius: 12px; 141 + 142 + padding: 8px 12px; 143 + 144 + cursor: pointer; 145 + 146 + transition: all .15s cubic-bezier(0.34, 1.56, 0.64, 1); 147 + 148 + &:hover, &:focus { 149 + scale: 1.025; 150 + } 151 + 152 + &:active { 153 + scale: 1.01; 154 + } 155 + 156 + &:disabled { 157 + background: var(--surfaceDim); 158 + color: var(--onSurface); 159 + 160 + &:hover { 161 + scale: 1; 162 + } 163 + } 164 + }