Compare commits
28 Commits
3c90cbbe2e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 88acf47ed5 | |||
| 9211ba9446 | |||
| 95368d156d | |||
| 8b7cf681b4 | |||
| 5b675c21ba | |||
| 281325c9db | |||
| f3e176f6c7 | |||
| 8a9c9517cb | |||
| 90f6e840f3 | |||
| 598fbf9d4a | |||
| 15e4fd556c | |||
| 50461f1644 | |||
| 7cd5113ef0 | |||
| 46fdd77353 | |||
| 3866e8ade0 | |||
| 107ee8794d | |||
| 1215f2f987 | |||
| 32e609ad71 | |||
| 29d251459a | |||
| 4760f51095 | |||
| 6510022716 | |||
| 573b6202be | |||
| 15eb1ea46b | |||
| 76e2455889 | |||
| 6d9e5ae56f | |||
| 0fcefcbbe5 | |||
| b7c69fb781 | |||
| 3a5e1f07cf |
11
Dockerfile
11
Dockerfile
@@ -1,4 +1,4 @@
|
|||||||
FROM node:18-alpine AS builder
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
RUN apk add --no-cache yarn
|
RUN apk add --no-cache yarn
|
||||||
|
|
||||||
@@ -13,14 +13,17 @@ COPY ./src/app/favicon.ico ./public/favicon.ico
|
|||||||
|
|
||||||
RUN yarn build
|
RUN yarn build
|
||||||
|
|
||||||
FROM node:18-alpine AS runner
|
FROM node:20-alpine AS runner
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN addgroup --system --gid 1001 nodejs
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
RUN adduser --system --uid 1001 nextjs
|
RUN adduser --system --uid 1001 --ingroup nodejs nextjs
|
||||||
|
|
||||||
ENV NODE_ENV production
|
RUN mkdir -p ./.next/cache
|
||||||
|
RUN chown nextjs:nodejs ./.next/cache
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
|
|||||||
28
amp.Dockerfile
Normal file
28
amp.Dockerfile
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Install dependencies
|
||||||
|
FROM node:22-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json yarn.lock* ./
|
||||||
|
RUN yarn install --frozen-lockfile
|
||||||
|
|
||||||
|
# Build
|
||||||
|
FROM node:22-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
RUN yarn build
|
||||||
|
|
||||||
|
# Production image
|
||||||
|
FROM node:22-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
# Only copy necessary files
|
||||||
|
COPY --from=builder /app/.next ./.next
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder /app/package.json ./package.json
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["yarn", "start"]
|
||||||
@@ -6,8 +6,7 @@ const nextConfig = {
|
|||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{
|
{
|
||||||
protocol: 'https',
|
protocol: 'https',
|
||||||
hostname: 'media.tenor.com',
|
hostname: 'media.tenor.com'
|
||||||
pathname: '/1BCeG1aTiBAAAAAd/temptation-stairway-ena.gif'
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
6218
package-lock.json
generated
6218
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
19
package.json
19
package.json
@@ -13,20 +13,29 @@
|
|||||||
"@mdx-js/react": "^3.1.0",
|
"@mdx-js/react": "^3.1.0",
|
||||||
"@next/mdx": "^15.3.4",
|
"@next/mdx": "^15.3.4",
|
||||||
"@types/mdx": "^2.0.13",
|
"@types/mdx": "^2.0.13",
|
||||||
|
"axios": "^1.13.2",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.13",
|
"date-fns": "^4.1.0",
|
||||||
"next": "14.2.24",
|
"dompurify": "^3.3.1",
|
||||||
|
"ioredis": "^5.8.2",
|
||||||
|
"jsdom": "^27.4.0",
|
||||||
|
"next": "^14.2.35",
|
||||||
|
"nodemailer": "^7.0.12",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18"
|
"react-dom": "^18",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
|
"validator": "^13.15.26"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify/react": "^5.2.0",
|
"@iconify/react": "^5.2.0",
|
||||||
|
"@types/jsdom": "^27.0.0",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/nodemailer": "^7.0.4",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
|
"@types/validator": "^13.15.10",
|
||||||
"add": "^2.0.6",
|
"add": "^2.0.6",
|
||||||
"eslint": "^8",
|
"eslint-config-next": "^16.0.10",
|
||||||
"eslint-config-next": "14.2.24",
|
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
|
|||||||
BIN
public/images/event_santahat1.png
Normal file
BIN
public/images/event_santahat1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
BIN
public/images/event_snowflake.png
Normal file
BIN
public/images/event_snowflake.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
102
src/app/api/contact/route.ts
Normal file
102
src/app/api/contact/route.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { rateLimited, sendEmail, validateTurnstile } from "@/lib/server-utils";
|
||||||
|
import { trimTooLong } from "@/lib/strings";
|
||||||
|
|
||||||
|
import validator from "validator";
|
||||||
|
|
||||||
|
const validateInput = (data: any) => {
|
||||||
|
return (
|
||||||
|
typeof data !== "object" ||
|
||||||
|
(
|
||||||
|
!data.anon &&
|
||||||
|
(
|
||||||
|
typeof data.name !== "string" ||
|
||||||
|
typeof data.email !== "string"
|
||||||
|
) ||
|
||||||
|
typeof data.message !== "string" ||
|
||||||
|
typeof data["cf-turnstile-response"] !== "string"
|
||||||
|
) ||
|
||||||
|
!data.anon &&
|
||||||
|
(
|
||||||
|
!data.name.trim() ||
|
||||||
|
!data.email.trim() ||
|
||||||
|
!validator.isEmail(data.email) ||
|
||||||
|
data.email.length > 50
|
||||||
|
) ||
|
||||||
|
!data.message.trim(),
|
||||||
|
!data["cf-turnstile-response"].trim()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const agent = req.headers.get("cf-connecting-ip") ?? req.headers.get("x-forwarded-for") ?? "damn";
|
||||||
|
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = await req.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid input" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cf_token = data["cf-turnstile-response"];
|
||||||
|
|
||||||
|
console.log(`[${agent}] Verify captcha`);
|
||||||
|
|
||||||
|
if (typeof cf_token !== 'string' || !cf_token.trim()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Captcha Failed" },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const captchaVerified = await validateTurnstile(cf_token, agent);
|
||||||
|
|
||||||
|
if (!captchaVerified) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Captcha Failed" },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[${agent}] Verify rate limit`);
|
||||||
|
|
||||||
|
const isRateLimited = await rateLimited(agent);
|
||||||
|
|
||||||
|
if (isRateLimited) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Too many requests" },
|
||||||
|
{ status: 429 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validateInput(data)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid input" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[${agent}] Sending email...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const email = data.anon || !data.email ? process.env.SMTP_USER : data.email;
|
||||||
|
const name = trimTooLong(data.anon || !data.name ? 'Anonymous' : data.name, 20);
|
||||||
|
|
||||||
|
await sendEmail(name, email, data.message);
|
||||||
|
|
||||||
|
console.log(`[${agent}] Email sended...`);
|
||||||
|
|
||||||
|
return NextResponse.json({ status: "ok" });
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
return NextResponse.json({
|
||||||
|
status: "failed",
|
||||||
|
message: err instanceof Error ? err.message : ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
:root {
|
:root {
|
||||||
--background: #0F0A1F;
|
--background: #0F0A1F;
|
||||||
--primary: #F48120;
|
--primary: #F48120;
|
||||||
|
--primary-darker: #cc6d1f;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes click-bounce {
|
@keyframes click-bounce {
|
||||||
@@ -22,7 +23,7 @@
|
|||||||
animation-timing-function: linear(0.2, 0.8, 1);
|
animation-timing-function: linear(0.2, 0.8, 1);
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
transform: translate(0px, -180px) scale(1.18, 0.9);
|
transform: translate(0px, -120px) scale(1.18, 0.9);
|
||||||
animation-timing-function: cubic-bezier(0.8, 0, 1, 1);
|
animation-timing-function: cubic-bezier(0.8, 0, 1, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Roboto_Mono } from 'next/font/google'
|
import { Roboto_Mono } from 'next/font/google'
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import Script from "next/script";
|
||||||
|
|
||||||
const roboto_mono = Roboto_Mono({
|
const roboto_mono = Roboto_Mono({
|
||||||
subsets: ['latin']
|
subsets: ['latin']
|
||||||
@@ -22,6 +23,7 @@ export default function RootLayout({
|
|||||||
className={`bg-background ${roboto_mono.className} antialiased text-white`}
|
className={`bg-background ${roboto_mono.className} antialiased text-white`}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
<Script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,38 +5,41 @@ import HomeText from "@/components/texts/home.mdx"
|
|||||||
|
|
||||||
import Link from "@/components/link";
|
import Link from "@/components/link";
|
||||||
import { FakeWindow, HomeWindows } from "@/components/windows";
|
import { FakeWindow, HomeWindows } from "@/components/windows";
|
||||||
|
import { SnowfallBackground } from "@/components/events/christmas";
|
||||||
|
|
||||||
import { WindowManagerProvider } from "@/hooks/window-manager";
|
import { WindowManagerProvider } from "@/hooks/window-manager";
|
||||||
|
import { ThemeEventsProvider } from "@/hooks/theme-events";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (<>
|
return (<>
|
||||||
<main className="flex items-center pt-16 md:pt-24 pb-12 px-8 md:px-0 overflow-x-hidden">
|
<main className="flex items-center pt-16 md:pt-24 pb-12 px-8 md:px-0 overflow-x-hidden">
|
||||||
<WindowManagerProvider>
|
<ThemeEventsProvider>
|
||||||
<FakeWindow windowText="Homepage">
|
<SnowfallBackground />
|
||||||
<HomeWindows />
|
<WindowManagerProvider>
|
||||||
<header className="text-center mb-8">
|
<FakeWindow windowText="Homepage">
|
||||||
<h1 className="font-bold text-3xl leading-normal">
|
<HomeWindows />
|
||||||
Nonszy Work<span className="text-primary">space</span>
|
<header className="text-center mb-8">
|
||||||
</h1>
|
<h1 className="font-bold text-3xl leading-normal">
|
||||||
</header>
|
Nonszy Work<span className="text-primary">space</span>
|
||||||
<noscript>
|
</h1>
|
||||||
<LandingImage />
|
</header>
|
||||||
</noscript>
|
<noscript>
|
||||||
<NolaGlitchClientOnly />
|
<LandingImage />
|
||||||
<Sosmed />
|
</noscript>
|
||||||
<article className="space-y-5 leading-relaxed relative text-sm">
|
<NolaGlitchClientOnly />
|
||||||
<HomeText />
|
<Sosmed />
|
||||||
</article>
|
<article className="space-y-5 leading-relaxed relative text-sm">
|
||||||
<section className="my-8">
|
<HomeText />
|
||||||
<p>⚡ Powered with <Link href="https://www.cloudflare.com/" target="_blank">Cloudflare</Link> ☁️</p>
|
</article>
|
||||||
</section>
|
<footer className="mt-20 text-center">
|
||||||
<footer className="mt-20 text-center">
|
<p>© <span className="text-sm">2025 Nomi Nonszy</span></p>
|
||||||
<p>© <span className="text-sm">2025 Nomi Nonszy</span></p>
|
<p className="text-sm">
|
||||||
<p className="text-sm">
|
<Link href={"/terms"}>Terms</Link> and <Link href={"/privacy"}>Privacy</Link>
|
||||||
<Link href={"/terms"}>Terms</Link> and <Link href={"/privacy"}>Privacy</Link>
|
</p>
|
||||||
</p>
|
</footer>
|
||||||
</footer>
|
</FakeWindow>
|
||||||
</FakeWindow>
|
</WindowManagerProvider>
|
||||||
</WindowManagerProvider>
|
</ThemeEventsProvider>
|
||||||
</main>
|
</main>
|
||||||
</>);
|
</>);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,11 +67,9 @@ export const FakeRelativeWindow = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!windowManager.isLocalDataExists) {
|
populateWindow();
|
||||||
if (!currentWindow) populateWindow();
|
return () => {
|
||||||
return () => {
|
windowManager.remove(windowName);
|
||||||
windowManager.remove(windowName);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -191,7 +189,7 @@ export const FakeRelativeWindow = ({
|
|||||||
|
|
||||||
{/* Main window */}
|
{/* Main window */}
|
||||||
<div
|
<div
|
||||||
className={clsx("md:border bg-background border-primary", withAnim && animation.window)}
|
className={clsx("md:border bg-background bg-opacity-50 border-primary", withAnim && animation.window)}
|
||||||
style={{
|
style={{
|
||||||
transform: `translate(${currentWindow.offset.x}px, ${currentWindow.offset.y}px)`
|
transform: `translate(${currentWindow.offset.x}px, ${currentWindow.offset.y}px)`
|
||||||
}}
|
}}
|
||||||
|
|||||||
204
src/components/events/christmas.tsx
Normal file
204
src/components/events/christmas.tsx
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useThemeEvents } from '@/hooks/theme-events';
|
||||||
|
import { getEvent } from '@/lib/utils';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import NextImage from 'next/image';
|
||||||
|
import { ReactNode, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
interface Snowflake {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
radius: number;
|
||||||
|
speed: number;
|
||||||
|
sway: number;
|
||||||
|
swayOffset: number;
|
||||||
|
opacity: number;
|
||||||
|
rotation: number;
|
||||||
|
rotationSpeed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChristmasProps {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
size: number;
|
||||||
|
img: string;
|
||||||
|
className?: string;
|
||||||
|
flip?: boolean;
|
||||||
|
absolute?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChristmasExclusive = ({ children }: { children: ReactNode }) => {
|
||||||
|
const eventNow = useThemeEvents()?.event;
|
||||||
|
|
||||||
|
if (eventNow?.name == 'christmas') return children;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChristmasProperty: React.FC<ChristmasProps> = ({
|
||||||
|
left,
|
||||||
|
top,
|
||||||
|
img,
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
flip
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<ChristmasExclusive>
|
||||||
|
<div
|
||||||
|
className={clsx('absolute md:block hidden', flip && '-scale-x-100', className)}
|
||||||
|
style={{
|
||||||
|
left, top
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<NextImage
|
||||||
|
className='pointer-events-none'
|
||||||
|
src={img}
|
||||||
|
alt='Christmas prop'
|
||||||
|
height={size}
|
||||||
|
width={size}
|
||||||
|
unoptimized={img.slice(img.length - 3, img.length) == 'gif'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ChristmasExclusive>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SnowfallRawBackground() {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
|
||||||
|
const snowflakesRef = useRef<Snowflake[]>([]);
|
||||||
|
const animationFrameRef = useRef<number>(0);
|
||||||
|
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateDimensions = () => {
|
||||||
|
setDimensions({
|
||||||
|
width: window.innerWidth,
|
||||||
|
height: window.innerHeight,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
updateDimensions();
|
||||||
|
window.addEventListener('resize', updateDimensions);
|
||||||
|
|
||||||
|
return () => window.removeEventListener('resize', updateDimensions);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
canvas.width = dimensions.width;
|
||||||
|
canvas.height = dimensions.height;
|
||||||
|
|
||||||
|
// Load the snowflake image using a plain Image so we can draw it to canvas reliably
|
||||||
|
const img = new Image();
|
||||||
|
img.src = '/images/event_snowflake.png';
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
const start = () => {
|
||||||
|
if (!mounted) return;
|
||||||
|
imageRef.current = img;
|
||||||
|
|
||||||
|
const createSnowflake = (init?: boolean): Snowflake => {
|
||||||
|
let posy = -10;
|
||||||
|
let radius = Math.random() * 18 + 10;
|
||||||
|
let speed = Math.random() * 1 + 0.5;
|
||||||
|
let sway = Math.random() * 0.5 + 0.2;
|
||||||
|
|
||||||
|
if (init) {
|
||||||
|
const initHeight = (dimensions.height / 1.1);
|
||||||
|
posy = Math.random() * initHeight + posy;
|
||||||
|
radius = (posy / (initHeight + posy) - 1) * -radius;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.innerWidth < 768) {
|
||||||
|
radius /= 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: Math.random() * dimensions.width,
|
||||||
|
y: posy,
|
||||||
|
radius,
|
||||||
|
speed,
|
||||||
|
sway,
|
||||||
|
swayOffset: Math.random() * Math.PI * 2,
|
||||||
|
opacity: Math.random() * 0.5 + 0.2,
|
||||||
|
rotation: Math.random() * Math.PI * 2,
|
||||||
|
rotationSpeed: (Math.random() - 0.5) * 0.02,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const snowflakes: Snowflake[] = [];
|
||||||
|
for (let i = 0; i < 80; i++) {
|
||||||
|
snowflakes.push(createSnowflake(true));
|
||||||
|
}
|
||||||
|
snowflakesRef.current = snowflakes;
|
||||||
|
|
||||||
|
const animate = () => {
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
snowflakesRef.current.forEach((flake, index) => {
|
||||||
|
flake.y += flake.speed;
|
||||||
|
flake.x += Math.sin(flake.swayOffset + flake.y * 0.02) * flake.sway;
|
||||||
|
flake.rotation += flake.rotationSpeed;
|
||||||
|
|
||||||
|
const shrink = dimensions.height < 768 ? 0.005 : 0.01;
|
||||||
|
flake.radius = Math.max(2, flake.radius - shrink);
|
||||||
|
|
||||||
|
if (flake.y > dimensions.height || flake.radius <= 0.5) {
|
||||||
|
snowflakesRef.current[index] = createSnowflake();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalAlpha = flake.opacity;
|
||||||
|
ctx.translate(flake.x, flake.y);
|
||||||
|
ctx.rotate(flake.rotation);
|
||||||
|
|
||||||
|
ctx.drawImage(
|
||||||
|
img,
|
||||||
|
-flake.radius / 2,
|
||||||
|
-flake.radius / 2,
|
||||||
|
flake.radius,
|
||||||
|
flake.radius
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
animationFrameRef.current = requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
|
||||||
|
animationFrameRef.current = requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (img.complete) start();
|
||||||
|
else img.onload = start;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
img.onload = null;
|
||||||
|
imageRef.current = null;
|
||||||
|
};
|
||||||
|
}, [dimensions]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="fixed top-0 left-0 w-full h-full pointer-events-none"
|
||||||
|
style={{ zIndex: 0 }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SnowfallBackground = () => (
|
||||||
|
<ChristmasExclusive>
|
||||||
|
<SnowfallRawBackground />
|
||||||
|
</ChristmasExclusive>
|
||||||
|
)
|
||||||
1
src/components/events/eventprops.tsx
Normal file
1
src/components/events/eventprops.tsx
Normal file
@@ -0,0 +1 @@
|
|||||||
|
'use client'
|
||||||
114
src/components/form/contact-form.tsx
Normal file
114
src/components/form/contact-form.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import axios, { AxiosError } from "axios";
|
||||||
|
|
||||||
|
import { CheckboxInput, Input, Submit } from "./form";
|
||||||
|
import { FloatingLabel } from "../floating-label";
|
||||||
|
import { CloudflareTurnstile } from "../turnstile";
|
||||||
|
|
||||||
|
export default function ContactForm() {
|
||||||
|
const [anon, setAnon] = useState(false);
|
||||||
|
const formRef = useRef<HTMLFormElement | null>(null);
|
||||||
|
const [status, setStatus] = useState<'success' | 'loading' | 'failed' | 'idle'>('idle');
|
||||||
|
const [errorMsg, setErrorMsg] = useState("");
|
||||||
|
|
||||||
|
const statusMsg = {
|
||||||
|
success: 'Sended!',
|
||||||
|
loading: 'Sending...',
|
||||||
|
failed: 'Failed :(',
|
||||||
|
idle: 'Send!'
|
||||||
|
}
|
||||||
|
|
||||||
|
const send = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setStatus('idle');
|
||||||
|
setErrorMsg('');
|
||||||
|
|
||||||
|
if (status == 'loading' || !formRef.current) return;
|
||||||
|
|
||||||
|
const formData = new FormData(formRef.current);
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
anon,
|
||||||
|
name: formData.get('name')?.toString(),
|
||||||
|
email: formData.get('email')?.toString(),
|
||||||
|
message: formData.get('message')!.toString(),
|
||||||
|
"cf-turnstile-response": formData.get('cf-turnstile-response')?.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((!data.name || !data.email) && !data.anon) {
|
||||||
|
setErrorMsg("Name and email are required. Unless you check the box to send anonymously");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.message.length < 1) {
|
||||||
|
setErrorMsg("What do you want to tell me???");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data["cf-turnstile-response"]) {
|
||||||
|
setErrorMsg("Please fill in the captcha");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus('loading');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axios.post('/api/contact', data);
|
||||||
|
setStatus('success');
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
setStatus('failed');
|
||||||
|
setErrorMsg("Something went wrong, Sorry...");
|
||||||
|
|
||||||
|
if (err instanceof AxiosError) {
|
||||||
|
if (err.status == 429) setErrorMsg("Limit reached, please try again later...");
|
||||||
|
if (err.status == 403) setErrorMsg("Captcha failed or expired, please try again later");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action="POST" className="space-y-4" ref={formRef} onSubmit={send}>
|
||||||
|
<FloatingLabel placeholder="Leave an anonymous message, but I can't reply to you">
|
||||||
|
<CheckboxInput
|
||||||
|
name="anon"
|
||||||
|
placeholder="Send anonymously🤫"
|
||||||
|
onChange={setAnon}
|
||||||
|
/>
|
||||||
|
</FloatingLabel>
|
||||||
|
<label className={anon ? "hidden" : "block"} htmlFor="contact-name">
|
||||||
|
<div className="mb-2">Name</div>
|
||||||
|
<Input
|
||||||
|
id="contact-name"
|
||||||
|
className="w-full"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
placeholder="Any name you want me to know"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className={anon ? "hidden" : "block"} htmlFor="contact-email">
|
||||||
|
<div className="mb-2">Email</div>
|
||||||
|
<Input
|
||||||
|
id="contact-email"
|
||||||
|
className="w-full"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block" htmlFor="contact-msg">
|
||||||
|
<div className="mb-2">Message</div>
|
||||||
|
<textarea
|
||||||
|
name="message"
|
||||||
|
id="contact-msg"
|
||||||
|
className="max-h-96 h-32 w-full p-3 bg-background border border-primary"
|
||||||
|
placeholder="Tell me something cool, or ask question"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<CloudflareTurnstile />
|
||||||
|
<div className="text-primary">{errorMsg}</div>
|
||||||
|
<Submit disabled={status === "loading"} className="w-full">{statusMsg[status]}</Submit>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
src/components/form/form.tsx
Normal file
49
src/components/form/form.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { Icon } from "@iconify/react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export function Input ({ className, type, ...props }: React.ComponentProps<'input'>) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={clsx("block py-3 px-4 bg-background text-white border border-primary", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Submit ({ className, type, ...props }: React.ComponentProps<'button'>) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type={'submit'}
|
||||||
|
className={clsx("block py-3 px-4 bg-primary disabled:bg-primary-darker text-background", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CheckboxInputProps {
|
||||||
|
name: string,
|
||||||
|
placeholder: string,
|
||||||
|
onChange: (value: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CheckboxInput ({ placeholder, name, onChange }: CheckboxInputProps) {
|
||||||
|
const [checked, setCheck] = useState(false);
|
||||||
|
|
||||||
|
const onClick = () => {
|
||||||
|
setCheck(c => !c);
|
||||||
|
onChange(!checked);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="">
|
||||||
|
<button type="button" id={`checkbox-${name}`} className="inline-block bg-none border border-primary w-4 h-4" onClick={onClick}>
|
||||||
|
{checked && <Icon icon="lucide:check" className="text-primary" />}
|
||||||
|
</button>
|
||||||
|
<label htmlFor={`checkbox-${name}`} className="ps-2 inline-block">{placeholder}</label>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,11 +2,11 @@ import Image from "next/image"
|
|||||||
import { FloatingLabel } from "./floating-label"
|
import { FloatingLabel } from "./floating-label"
|
||||||
|
|
||||||
export const LandingImage = () => (
|
export const LandingImage = () => (
|
||||||
<FloatingLabel placeholder="Coral Glasses ❤️. Alert: NOT MY WORK! See the end of the page">
|
<FloatingLabel placeholder="ENA ❤️. Alert: NOT MY WORK!">
|
||||||
<Image
|
<Image
|
||||||
className="mb-8 mx-auto"
|
className="mb-8 mx-auto h-auto"
|
||||||
alt="Coral <3"
|
alt="Coral <3"
|
||||||
src="https://media1.tenor.com/m/RIP2rxKM_FgAAAAC/ena-ena-dream-bbq.gif"
|
src="https://media.tenor.com/1BCeG1aTiBAAAAAd/temptation-stairway-ena.gif"
|
||||||
width={280}
|
width={280}
|
||||||
height={280}
|
height={280}
|
||||||
unoptimized
|
unoptimized
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import Link from "@/components/link";
|
import Link from "@/components/link";
|
||||||
import { FloatingLabel } from "@/components/floating-label";
|
import { FloatingLabel } from "@/components/floating-label";
|
||||||
|
import ContactForm from "@/components/form/contact-form";
|
||||||
|
|
||||||
Welcome!
|
Welcome!
|
||||||
|
|
||||||
@@ -8,6 +9,8 @@ This is our cozy little corner of the internet where we run services and website
|
|||||||
|
|
||||||
We've got tools, resources, game server and other stuff that just works and doesn't burn our wallet, maybe.
|
We've got tools, resources, game server and other stuff that just works and doesn't burn our wallet, maybe.
|
||||||
|
|
||||||
|
I love putting silly favorite characters on this site
|
||||||
|
|
||||||
## About Me
|
## About Me
|
||||||
|
|
||||||
Nomi Nonszy (also known as Nonszy, Nomi Nonsense, whatever).
|
Nomi Nonszy (also known as Nonszy, Nomi Nonsense, whatever).
|
||||||
@@ -15,9 +18,9 @@ I write code, do some art, and make sure our server doesn't catch on fire lol.
|
|||||||
|
|
||||||
I build sick web apps with modern stacks. Big fan of open-source stuff.
|
I build sick web apps with modern stacks. Big fan of open-source stuff.
|
||||||
Hate something that make things overengineered and boilerplate, but still use them anyway.
|
Hate something that make things overengineered and boilerplate, but still use them anyway.
|
||||||
I only play indie games, and cooking up cursed mods just for fun.
|
Love with indie games, and cooking up cursed mods just for fun.
|
||||||
|
|
||||||
Multifandom with Psychopomp, Deltarune, and ENA! I'm currently dedicated to the ENA SERIES!! Especially <Link href="https://enajoelg.fandom.com/wiki/Coral_Glasses" target="_blank">Coral Glasses</Link> my beloved wife ❤️
|
Multifandom with Psychopomp, Deltarune, and ENA! I'm currently dedicated to the ENA SERIES!!
|
||||||
|
|
||||||
<Image
|
<Image
|
||||||
className="mb-12 mx-auto"
|
className="mb-12 mx-auto"
|
||||||
@@ -87,6 +90,7 @@ and published under the ENA Team. She speaks Korean in the game and is voiced by
|
|||||||
|
|
||||||
The game is part of the surreal and artistically distinct ENA universe, which expands upon his earlier animated web series of the same name.
|
The game is part of the surreal and artistically distinct ENA universe, which expands upon his earlier animated web series of the same name.
|
||||||
|
|
||||||
She's supposed to handle business stuff, but she's sweating, faxing out of her hairline, and spiraling into mild panic every five minutes
|
|
||||||
|
|
||||||
She's cute, anxious, awkward, weird, beautiful and i swear, she's literally me at work sweating through every conversation ashdjakwoiqhkaslchmaujqk
|
## Tell me something
|
||||||
|
|
||||||
|
<ContactForm />
|
||||||
9
src/components/turnstile.tsx
Normal file
9
src/components/turnstile.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
export const CloudflareTurnstile = () => (
|
||||||
|
<div
|
||||||
|
className="cf-turnstile [&_#content]:bg-background"
|
||||||
|
data-sitekey={process.env.NEXT_PUBLIC_CF_SITE_KEY}
|
||||||
|
data-theme="dark"
|
||||||
|
/>
|
||||||
|
)
|
||||||
@@ -3,6 +3,7 @@ import { FloatingLabel } from "@/components/floating-label";
|
|||||||
import { FakeRelativeWindow, RestoreWindowsButton } from "./client-windows";
|
import { FakeRelativeWindow, RestoreWindowsButton } from "./client-windows";
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { ChristmasProperty } from "./events/christmas";
|
||||||
|
|
||||||
export const FakeWindow = ({
|
export const FakeWindow = ({
|
||||||
windowText, children
|
windowText, children
|
||||||
@@ -10,7 +11,13 @@ export const FakeWindow = ({
|
|||||||
windowText: string
|
windowText: string
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) => (
|
}) => (
|
||||||
<div className="mx-auto w-[480px] md:w-[520px] md:border border-primary">
|
<div className="relative md:bg-background mx-auto w-[480px] md:w-[520px] md:border border-primary z-10">
|
||||||
|
<ChristmasProperty
|
||||||
|
img="/images/event_santahat1.png"
|
||||||
|
size={180}
|
||||||
|
left={-60}
|
||||||
|
top={-80}
|
||||||
|
/>
|
||||||
<div className="p-1 pb-0">
|
<div className="p-1 pb-0">
|
||||||
<div className="hidden md:flex bg-primary p-2 justify-between text-background">
|
<div className="hidden md:flex bg-primary p-2 justify-between text-background">
|
||||||
<div className="ms-1 pointer-events-none">
|
<div className="ms-1 pointer-events-none">
|
||||||
@@ -45,13 +52,16 @@ export const HomeWindows = () => (
|
|||||||
draggable
|
draggable
|
||||||
>
|
>
|
||||||
<FloatingLabel placeholder="This is Nola, my OC :3">
|
<FloatingLabel placeholder="This is Nola, my OC :3">
|
||||||
<Image
|
<div className="relative">
|
||||||
className=""
|
<ChristmasProperty img="/images/event_santahat1.png" size={150} top={-40} left={70} flip />
|
||||||
alt="Nola"
|
<Image
|
||||||
src="/images/nola.png"
|
className=""
|
||||||
width={200}
|
alt="Nola"
|
||||||
height={200}
|
src="/images/nola.png"
|
||||||
/>
|
width={200}
|
||||||
|
height={200}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</FloatingLabel>
|
</FloatingLabel>
|
||||||
</FakeRelativeWindow>
|
</FakeRelativeWindow>
|
||||||
<FakeRelativeWindow
|
<FakeRelativeWindow
|
||||||
@@ -74,7 +84,7 @@ export const HomeWindows = () => (
|
|||||||
</Link>
|
</Link>
|
||||||
</FakeRelativeWindow>
|
</FakeRelativeWindow>
|
||||||
<FakeRelativeWindow
|
<FakeRelativeWindow
|
||||||
windowText="coral-1.exe"
|
windowText="cube_coral.exe"
|
||||||
className='-left-[75%] top-[1980px] z-10'
|
className='-left-[75%] top-[1980px] z-10'
|
||||||
draggable
|
draggable
|
||||||
>
|
>
|
||||||
@@ -88,13 +98,13 @@ export const HomeWindows = () => (
|
|||||||
/>
|
/>
|
||||||
</FakeRelativeWindow>
|
</FakeRelativeWindow>
|
||||||
<FakeRelativeWindow
|
<FakeRelativeWindow
|
||||||
windowText="coral_cupcake.mkv"
|
windowText="ena_spin.exe"
|
||||||
className="-right-[85%] top-[440px] z-10"
|
className="-right-[85%] top-[440px] z-10"
|
||||||
draggable
|
draggable
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
alt="Coral Cupcake"
|
alt="Coral Cupcake"
|
||||||
src="https://media1.tenor.com/m/N5K-4AWj8QcAAAAC/coral-glasses-cupcake.gif"
|
src="https://media.tenor.com/Uv-PLe5GIe0AAAAi/gyaruface.gif"
|
||||||
width={240}
|
width={240}
|
||||||
height={200}
|
height={200}
|
||||||
quality={10}
|
quality={10}
|
||||||
|
|||||||
@@ -13,6 +13,26 @@ interface WobblingImageInterface {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ImageClipInterface {
|
||||||
|
name: string;
|
||||||
|
src: string;
|
||||||
|
size: number;
|
||||||
|
visible?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ImageClip: React.FC<ImageClipInterface> = ({
|
||||||
|
name, src, size, visible
|
||||||
|
}) => (
|
||||||
|
<Image
|
||||||
|
className={clsx("absolute left-1/2 -translate-x-1/2 top-0 pointer-events-none", visible ? "opacity-100" : "opacity-0")}
|
||||||
|
alt={name}
|
||||||
|
src={src}
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
function WobblingImage ({
|
function WobblingImage ({
|
||||||
images
|
images
|
||||||
}: WobblingImageInterface) {
|
}: WobblingImageInterface) {
|
||||||
@@ -128,21 +148,23 @@ function WobblingImage ({
|
|||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onPointerDown={(e) => e.preventDefault()}
|
onPointerDown={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
<Image
|
<ImageClip
|
||||||
className={clsx("absolute left-1/2 -translate-x-1/2 top-0 pointer-events-none", isPoked ? "opacity-0" : "opacity-100")}
|
name="idle"
|
||||||
alt="clip1"
|
src={images.idle}
|
||||||
src={isAware ? (images.aware || images.idle) : images.idle}
|
size={size}
|
||||||
width={size}
|
visible={!isPoked && !isAware}
|
||||||
height={size}
|
|
||||||
draggable={false}
|
|
||||||
/>
|
/>
|
||||||
<Image
|
<ImageClip
|
||||||
className={clsx("absolute left-1/2 -translate-x-1/2 top-0 pointer-events-none", isPoked ? "opacity-100" : "opacity-0")}
|
name="aware"
|
||||||
alt="clip2"
|
src={images.aware || images.idle}
|
||||||
|
size={size}
|
||||||
|
visible={!isPoked && isAware}
|
||||||
|
/>
|
||||||
|
<ImageClip
|
||||||
|
name="poked"
|
||||||
src={images.poked || images.idle}
|
src={images.poked || images.idle}
|
||||||
width={size}
|
size={size}
|
||||||
height={size}
|
visible={isPoked}
|
||||||
draggable={false}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
21
src/hooks/theme-events.tsx
Normal file
21
src/hooks/theme-events.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { EventsDate } from "@/lib/types";
|
||||||
|
import { getEvent } from "@/lib/utils";
|
||||||
|
import { createContext, useContext } from "react";
|
||||||
|
|
||||||
|
interface ThemeEventsContextType {
|
||||||
|
event: EventsDate | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeEventsContext = createContext<ThemeEventsContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const ThemeEventsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const eventNow = getEvent();
|
||||||
|
|
||||||
|
return <ThemeEventsContext.Provider value={{ event: eventNow }}>
|
||||||
|
{children}
|
||||||
|
</ThemeEventsContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useThemeEvents = () => useContext(ThemeEventsContext);
|
||||||
12
src/lib/mailer.ts
Normal file
12
src/lib/mailer.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import nodemailer from "nodemailer";
|
||||||
|
|
||||||
|
export const transporter = nodemailer.createTransport({
|
||||||
|
// @ts-ignore
|
||||||
|
host: process.env.SMTP_HOST,
|
||||||
|
port: Number(process.env.SMTP_PORT),
|
||||||
|
secure: process.env.SMTP_SECURE == "true",
|
||||||
|
auth: {
|
||||||
|
user: process.env.SMTP_USER,
|
||||||
|
pass: process.env.SMTP_PASS
|
||||||
|
}
|
||||||
|
});
|
||||||
5
src/lib/redis.ts
Normal file
5
src/lib/redis.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import Redis from "ioredis";
|
||||||
|
|
||||||
|
export const redis = new Redis(process.env.REDIS_URL!, {
|
||||||
|
maxRetriesPerRequest: 3,
|
||||||
|
});
|
||||||
53
src/lib/server-utils.ts
Normal file
53
src/lib/server-utils.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import { JSDOM } from 'jsdom';
|
||||||
|
import DOMPurify from "dompurify";
|
||||||
|
|
||||||
|
import { redis } from "./redis";
|
||||||
|
import { transporter } from "./mailer";
|
||||||
|
import { trimTooLong } from "./strings";
|
||||||
|
import { escape } from "validator";
|
||||||
|
|
||||||
|
export async function rateLimited(clientId: string) {
|
||||||
|
const key = `contact:${clientId}`;
|
||||||
|
const count = await redis.incr(key);
|
||||||
|
if (count === 1) {
|
||||||
|
await redis.expire(key, 600);
|
||||||
|
}
|
||||||
|
return count > 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateTurnstile(token: string, remoteip: string) {
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
secret: process.env.CF_SECRET_KEY!,
|
||||||
|
response: token,
|
||||||
|
remoteip
|
||||||
|
})
|
||||||
|
|
||||||
|
const res = await axios.post("https://challenges.cloudflare.com/turnstile/v0/siteverify", body, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await res.data;
|
||||||
|
return result.success === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendEmail(name: string, email: string, message: string) {
|
||||||
|
const rawMessage = `From: ${name}\nEmail: ${email}\n\nMessage:\n${trimTooLong(message, 5000)}`;
|
||||||
|
const messageHTML = sanitize(escape(rawMessage));
|
||||||
|
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: `Nonszy Contact Form <${process.env.SMTP_USER}>`,
|
||||||
|
replyTo: email,
|
||||||
|
to: process.env.SMTP_REPLY,
|
||||||
|
subject: `[CONTACT_FORM] from ${name}`,
|
||||||
|
text: rawMessage,
|
||||||
|
// html: messageHTML
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitize(dirty: string) {
|
||||||
|
const window = new JSDOM('').window;
|
||||||
|
return DOMPurify(window).sanitize(dirty);
|
||||||
|
}
|
||||||
3
src/lib/strings.ts
Normal file
3
src/lib/strings.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export const trimTooLong = (text: string, limit: number, endsWith?: string) => {
|
||||||
|
return text.length > limit ? text.slice(0, limit) + (endsWith ?? "...") : text;
|
||||||
|
}
|
||||||
5
src/lib/types.ts
Normal file
5
src/lib/types.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export type EventsDate = {
|
||||||
|
name: string,
|
||||||
|
start: number[],
|
||||||
|
end: number[]
|
||||||
|
}
|
||||||
@@ -1,8 +1,29 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import dayjs from 'dayjs';
|
import { format, isWithinInterval } from "date-fns";
|
||||||
|
import type { EventsDate } from "./types";
|
||||||
|
|
||||||
|
const events: EventsDate[] = [
|
||||||
|
{
|
||||||
|
name: 'christmas',
|
||||||
|
start: [12, 20],
|
||||||
|
end: [12, 32]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
export function getExpirationDate () {
|
export function getExpirationDate () {
|
||||||
const expirationDate = new Date("2026-02-15");
|
const expirationDate = new Date("2026-02-15");
|
||||||
return dayjs(expirationDate).format("dddd, D MMMM YYYY");
|
return format(expirationDate, "dddd, D MMMM YYYY");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEvent (): EventsDate | undefined {
|
||||||
|
return events.find((e) => {
|
||||||
|
const today = new Date(Date.now());
|
||||||
|
const year = today.getFullYear();
|
||||||
|
|
||||||
|
const start = new Date(year, e.start[0] - 1, e.start[1]);
|
||||||
|
const end = new Date(year, e.end[0] - 1, e.end[1]);
|
||||||
|
|
||||||
|
return isWithinInterval(today, { start, end })
|
||||||
|
})
|
||||||
}
|
}
|
||||||
@@ -12,6 +12,7 @@ const config: Config = {
|
|||||||
background: "var(--background)",
|
background: "var(--background)",
|
||||||
foreground: "var(--foreground)",
|
foreground: "var(--foreground)",
|
||||||
primary: "var(--primary)",
|
primary: "var(--primary)",
|
||||||
|
'primary-darker': "var(--primary-darker)",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
screens: {
|
screens: {
|
||||||
|
|||||||
Reference in New Issue
Block a user