mirror of
https://github.com/trafficlunar/tomodachi-share.git
synced 2026-05-13 13:17:45 +00:00
feat: formatted links in descriptions
This commit is contained in:
parent
913f0ef65a
commit
0e2df242d0
2 changed files with 100 additions and 63 deletions
72
src/app/out/page.tsx
Normal file
72
src/app/out/page.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
import { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { Icon } from "@iconify/react";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Leaving TomodachiShare",
|
||||||
|
description: "Warning: You are leaving TomodachiShare, proceed with caution",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function LinkOutPage({ searchParams }: Props) {
|
||||||
|
const url = (await searchParams).url;
|
||||||
|
if (!url || Array.isArray(url)) redirect("/");
|
||||||
|
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(url);
|
||||||
|
} catch {
|
||||||
|
redirect("/"); // redirect if URL is invalid
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next.js doesn't allow attacks like these but you can never be too safe
|
||||||
|
if (!["http:", "https:"].includes(parsed.protocol)) redirect("/");
|
||||||
|
|
||||||
|
const isSafe = Array.from(SAFE_LINKS).some((domain) => parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`));
|
||||||
|
if (isSafe) redirect(url);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grow flex items-center justify-center">
|
||||||
|
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg py-8 px-6 max-w-md w-full text-center flex flex-col items-center">
|
||||||
|
<h2 className="text-3xl font-black flex items-center gap-2 mb-1">
|
||||||
|
<Icon icon="mingcute:alert-fill" className="text-5xl" />
|
||||||
|
Warning
|
||||||
|
</h2>
|
||||||
|
<p>You're attempting to leave TomodachiShare island! The destination website is potentially dangerous.</p>
|
||||||
|
|
||||||
|
<div className="bg-zinc-100 border border-zinc-300 rounded-md p-2 break-all w-full mt-4">
|
||||||
|
<code className="font-mono text-sm">{url}</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-center gap-2">
|
||||||
|
<Link href="/" className="pill button gap-2 mt-8 w-fit self-center bg-zinc-100! border-zinc-300! hover:bg-zinc-300!">
|
||||||
|
<Icon icon="ic:round-home" fontSize={24} />
|
||||||
|
Travel Back
|
||||||
|
</Link>
|
||||||
|
<Link href={url} target="_blank" rel="noopener noreferrer" className="pill button gap-2 mt-8 w-fit self-center">
|
||||||
|
<Icon icon="ic:round-open-in-new" fontSize={21} />
|
||||||
|
Continue
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAFE_LINKS = new Set([
|
||||||
|
"tomodachishare.com",
|
||||||
|
"trafficlunar.net",
|
||||||
|
"youtube.com",
|
||||||
|
"youtu.be",
|
||||||
|
"twitter.com",
|
||||||
|
"x.com",
|
||||||
|
"reddit.com",
|
||||||
|
"tiktok.com",
|
||||||
|
"tumblr.com",
|
||||||
|
"instagram.com",
|
||||||
|
"wikipedia.org",
|
||||||
|
]);
|
||||||
|
|
@ -1,78 +1,43 @@
|
||||||
import Image from "next/image";
|
import { Icon } from "@iconify/react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
|
|
||||||
import ProfilePicture from "./profile-picture";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
text: string;
|
text: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Adds fancy formatting to links
|
||||||
export default function Description({ text, className }: Props) {
|
export default function Description({ text, className }: Props) {
|
||||||
|
const urlRegex = /(https?:\/\/[^\s]+)/g;
|
||||||
|
const parts = text.split(urlRegex);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<p className={`text-sm mt-2 bg-white/50 p-3 rounded-lg border border-orange-200 whitespace-break-spaces max-h-54 overflow-y-auto ${className}`}>
|
<p className={`text-sm mt-2 bg-white/50 p-3 rounded-lg border border-orange-200 whitespace-break-spaces max-h-54 overflow-y-auto ${className}`}>
|
||||||
{/* Adds fancy formatting when linking to other pages on the site */}
|
{parts.map(async (part, index) => {
|
||||||
{(() => {
|
try {
|
||||||
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://tomodachishare.com";
|
// Check if it's a URL
|
||||||
|
if (!urlRegex.test(part)) throw new Error("Not a URL");
|
||||||
// Match both mii and profile links
|
const url = new URL(part);
|
||||||
const regex = new RegExp(`(${baseUrl.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")}/(?:mii|profile)/\\d+)`, "g");
|
|
||||||
const parts = text.split(regex);
|
|
||||||
|
|
||||||
return parts.map(async (part, index) => {
|
|
||||||
const miiMatch = part.match(new RegExp(`^${baseUrl}/mii/(\\d+)$`));
|
|
||||||
const profileMatch = part.match(new RegExp(`^${baseUrl}/profile/(\\d+)$`));
|
|
||||||
|
|
||||||
if (miiMatch) {
|
|
||||||
const id = Number(miiMatch[1]);
|
|
||||||
const linkedMii = await prisma.mii.findUnique({
|
|
||||||
where: {
|
|
||||||
id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!linkedMii) return;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={index}
|
key={index}
|
||||||
href={`/mii/${id}`}
|
href={`/out?url=${encodeURIComponent(part)}`}
|
||||||
className="inline-flex items-center align-bottom gap-1.5 pr-2 bg-amber-100 border border-amber-400 rounded-lg mx-1 text-amber-800 text-xs"
|
target="_blank"
|
||||||
|
className="text-blue-700 underline break-all ml-1 inline-flex items-center group"
|
||||||
|
title={`Go to ${url.hostname}`}
|
||||||
>
|
>
|
||||||
<Image src={`/mii/${id}/image?type=mii`} alt="mii" width={24} height={24} className="bg-white rounded-lg border-r border-amber-400" />
|
{url.hostname}
|
||||||
{linkedMii.name}
|
{url.pathname !== "/" ? url.pathname : ""}
|
||||||
|
{url.search}
|
||||||
|
<Icon icon="mi:arrow-right-up" fontSize={16} className="transition group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
} catch {
|
||||||
|
// Normal text/Invalid URL fallback
|
||||||
if (profileMatch) {
|
|
||||||
const id = Number(profileMatch[1]);
|
|
||||||
const linkedProfile = await prisma.user.findUnique({
|
|
||||||
where: {
|
|
||||||
id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!linkedProfile) return;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={index}
|
|
||||||
href={`/profile/${id}`}
|
|
||||||
className="inline-flex items-center align-bottom gap-1.5 pr-2 bg-orange-100 border border-orange-400 rounded-lg mx-1 text-orange-800 text-xs"
|
|
||||||
>
|
|
||||||
<ProfilePicture src={linkedProfile.image || "/guest.png"} width={24} height={24} className="bg-white rounded-lg border-r border-orange-400" />
|
|
||||||
{linkedProfile.name}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regular text
|
|
||||||
return <span key={index}>{part}</span>;
|
return <span key={index}>{part}</span>;
|
||||||
});
|
}
|
||||||
})()}
|
})}
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue