mirror of
https://github.com/NohamR/lanyard-profile-readme.git
synced 2026-05-25 04:17:18 +00:00
517 lines
25 KiB
TypeScript
517 lines
25 KiB
TypeScript
"use client";
|
||
import React, { useState, useRef, useMemo, JSX } from "react";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
DialogTrigger,
|
||
} from "@/components/ui/dialog";
|
||
|
||
import { motion } from "framer-motion";
|
||
|
||
import { useSmoothCount } from "use-smooth-count";
|
||
import useSWR from "swr";
|
||
|
||
import { getUserCount, isUserMonitored } from "@/utils/actions";
|
||
import { isSnowflake } from "@/utils/snowflake";
|
||
import Link from "next/link";
|
||
import { parameterInfo } from "@/utils/parameter";
|
||
|
||
import * as Icon from "lucide-react";
|
||
import { Checkbox } from "@/components/ui/checkbox";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import {
|
||
Tooltip,
|
||
TooltipContent,
|
||
TooltipProvider,
|
||
TooltipTrigger,
|
||
} from "@/components/ui/tooltip";
|
||
import { Input } from "@/components/ui/input";
|
||
import { filterLetters } from "@/lib/utils";
|
||
import { Button } from "@/components/ui/button";
|
||
|
||
export default function Home() {
|
||
const originUrl = useMemo(
|
||
() =>
|
||
typeof window !== "undefined"
|
||
? window.location.origin
|
||
: "https://lanyard.cnrad.dev",
|
||
[],
|
||
);
|
||
const [userId, setUserId] = useState<null | string>(null);
|
||
const [userError, setUserError] = useState<string | JSX.Element>();
|
||
const [userData, setUserData] = useState<{ userId: string } | null>(null);
|
||
const [copyState, setCopyState] = useState("Copy");
|
||
const [outputType, setOutputType] = useState<"markdown" | "html" | "url">(
|
||
"markdown",
|
||
);
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const [onImageLoaded, setOnImageLoaded] = useState(false);
|
||
|
||
const [option, setOption] = useState<
|
||
Array<{ name: string; value: string }>
|
||
>([]);
|
||
|
||
const userCount = useSWR("getUserCount", getUserCount);
|
||
const countRef = useRef<HTMLDivElement | null>(null);
|
||
useSmoothCount({
|
||
ref: countRef,
|
||
target: userCount.data || 0,
|
||
duration: 3,
|
||
curve: [0, 1, 0, 1],
|
||
});
|
||
|
||
const url = `${originUrl}/api/${userData?.userId}${option.length > 0 ? `?${option.map((o) => `${o.name}=${o.value}`).join("&")}` : ""}`;
|
||
|
||
function outputText() {
|
||
if (outputType === "html") {
|
||
return `<a href="https://discord.com/users/${userData?.userId}"><img src="${url}" /></a>`;
|
||
} else if (outputType === "url") {
|
||
return `${url}`;
|
||
} else {
|
||
return `[](https://discord.com/users/${userData?.userId})`;
|
||
}
|
||
}
|
||
|
||
function copy() {
|
||
navigator.clipboard.writeText(outputText());
|
||
setCopyState("Copied!");
|
||
|
||
setTimeout(() => setCopyState("Copy"), 1500);
|
||
}
|
||
|
||
async function submitDiscordId() {
|
||
setIsLoading(true);
|
||
setOnImageLoaded(false);
|
||
setUserData(null);
|
||
setUserError(undefined);
|
||
|
||
if (!userId) return setUserError("Please enter a Discord ID");
|
||
|
||
if (!isSnowflake(userId)) return setUserError("Invalid Discord ID");
|
||
|
||
if ((await isUserMonitored(userId)) === false)
|
||
return setUserError(
|
||
<>
|
||
User is not being monitored by Lanyard, please join{" "}
|
||
<Link
|
||
href="https://discord.gg/lanyard"
|
||
target="_blank"
|
||
className="underline"
|
||
>
|
||
this server
|
||
</Link>{" "}
|
||
and try again.
|
||
</>,
|
||
);
|
||
|
||
setUserData({ userId });
|
||
setIsLoading(false);
|
||
}
|
||
|
||
function modifyOption(
|
||
data:
|
||
| {
|
||
type: "string";
|
||
name: string;
|
||
data: string;
|
||
event: React.ChangeEvent<HTMLInputElement>;
|
||
}
|
||
| {
|
||
type: "list";
|
||
name: string;
|
||
data: string;
|
||
}
|
||
| {
|
||
type: "boolean";
|
||
name: string;
|
||
data: string | boolean;
|
||
},
|
||
) {
|
||
if (data.type === "string") {
|
||
const filteredValue = encodeURIComponent(
|
||
filterLetters(
|
||
data.data,
|
||
(
|
||
parameterInfo.find(
|
||
(p) =>
|
||
p.type === "string" &&
|
||
p.parameter === data.name,
|
||
) as any
|
||
).options.omit,
|
||
),
|
||
);
|
||
|
||
setOption((prev) => {
|
||
if (data.data === "") {
|
||
return prev?.filter((o) => o.name !== data.name) || [];
|
||
} else {
|
||
if (prev?.find((o) => o.name === data.name)) {
|
||
return prev.map((o) => {
|
||
if (o.name === data.name) {
|
||
o.value = filteredValue;
|
||
}
|
||
return o;
|
||
});
|
||
} else {
|
||
return prev
|
||
? [
|
||
...prev,
|
||
{
|
||
name: data.name,
|
||
value: filteredValue,
|
||
},
|
||
]
|
||
: [
|
||
{
|
||
name: data.name,
|
||
value: filteredValue,
|
||
},
|
||
];
|
||
}
|
||
}
|
||
});
|
||
} else if (data.type === "list") {
|
||
setOption((prev) => {
|
||
if (prev?.find((o) => o.name === data.name)) {
|
||
return prev.map((o) => {
|
||
if (o.name === data.name) {
|
||
o.value = data.data;
|
||
}
|
||
return o;
|
||
});
|
||
} else {
|
||
return prev
|
||
? [...prev, { name: data.name, value: data.data }]
|
||
: [{ name: data.name, value: data.data }];
|
||
}
|
||
});
|
||
} else if (data.type === "boolean") {
|
||
setOption((prev) => {
|
||
if (prev?.find((o) => o.name === data.name)) {
|
||
return prev
|
||
.map((opt) => {
|
||
const options = parameterInfo.find(
|
||
(p) =>
|
||
p.type === "boolean" &&
|
||
p.parameter === data.name,
|
||
)?.options as { defaultBool?: boolean };
|
||
|
||
if (
|
||
data.data === (options?.defaultBool! || false)
|
||
) {
|
||
return null;
|
||
} else {
|
||
if (opt.name === data.name) {
|
||
opt.value = data.data.toString();
|
||
}
|
||
return opt;
|
||
}
|
||
})
|
||
.filter((opt) => opt !== null);
|
||
} else {
|
||
return prev
|
||
? [
|
||
...prev,
|
||
{ name: data.name, value: data.data.toString() },
|
||
]
|
||
: [{ name: data.name, value: data.data.toString() }];
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<main className="flex min-h-screen max-w-[100vw] flex-col items-center">
|
||
<div className="mt-16 w-[80%] max-w-[28rem] rounded-md">
|
||
<p className="my-2 text-left text-3xl font-semibold text-[#cecece]">
|
||
lanyard profile readme 🏷️
|
||
</p>
|
||
<p className="text-base text-[#aaabaf]">
|
||
Utilize Lanyard to display your Discord Presence in your
|
||
GitHub Profile
|
||
</p>
|
||
<br />
|
||
<form
|
||
className="flex w-full gap-2"
|
||
onSubmit={(e) => {
|
||
e.preventDefault();
|
||
|
||
submitDiscordId();
|
||
}}
|
||
>
|
||
<input
|
||
className="input"
|
||
onChange={(e) => setUserId(e.target.value)}
|
||
value={userId || ""}
|
||
placeholder="Enter your Discord ID"
|
||
/>
|
||
<button className="action" type="submit">
|
||
{">>"}
|
||
</button>
|
||
</form>
|
||
<motion.p
|
||
variants={{
|
||
open: { opacity: 1 },
|
||
closed: { opacity: 0 },
|
||
}}
|
||
initial="closed"
|
||
animate={userError ? "open" : "closed"}
|
||
className="mt-1 text-sm text-red-500"
|
||
>
|
||
* {userError}
|
||
</motion.p>
|
||
<motion.div
|
||
variants={{
|
||
open: {
|
||
opacity: 1,
|
||
},
|
||
closed: {
|
||
opacity: 0,
|
||
},
|
||
}}
|
||
initial="closed"
|
||
animate={!isLoading ? "open" : "closed"}
|
||
transition={{ duration: 0.5 }}
|
||
>
|
||
<div className="mb-1 mt-4 flex gap-1">
|
||
<button
|
||
className={`action ${outputType === "markdown" ? "active" : ""}`}
|
||
onClick={() => setOutputType("markdown")}
|
||
>
|
||
Markdown
|
||
</button>
|
||
<button
|
||
className={`action ${outputType === "html" ? "active" : ""}`}
|
||
onClick={() => setOutputType("html")}
|
||
>
|
||
HTML
|
||
</button>
|
||
<button
|
||
className={`action ${outputType === "url" ? "active" : ""}`}
|
||
onClick={() => setOutputType("url")}
|
||
>
|
||
URL
|
||
</button>
|
||
</div>
|
||
<div
|
||
className="output bg-black"
|
||
suppressHydrationWarning
|
||
>
|
||
{outputText()}
|
||
</div>
|
||
<div className="mt-4 flex gap-2">
|
||
<button className="action" onClick={copy}>
|
||
{copyState}
|
||
</button>
|
||
<Dialog>
|
||
<DialogTrigger className="action">
|
||
Option
|
||
</DialogTrigger>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>Option</DialogTitle>
|
||
<DialogDescription>
|
||
Select an option to enable/disable
|
||
features to your Lanyard Profile
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<div className="flex max-h-[75dvh] flex-col gap-4 overflow-x-hidden overflow-y-scroll rounded-xl bg-black/50 p-4 px-6 text-[#cecece]">
|
||
{parameterInfo.map((item, idx) => {
|
||
return (
|
||
<div
|
||
key={item.parameter}
|
||
className="flex flex-col gap-1"
|
||
>
|
||
<div className="flex items-center gap-1">
|
||
<p>{item.title}</p>
|
||
<TooltipProvider
|
||
delayDuration={100}
|
||
>
|
||
<Tooltip>
|
||
<TooltipTrigger
|
||
onFocus={(
|
||
e,
|
||
) => {
|
||
e.preventDefault();
|
||
}}
|
||
>
|
||
<Icon.HelpCircleIcon
|
||
size={
|
||
18
|
||
}
|
||
/>
|
||
</TooltipTrigger>
|
||
<TooltipContent>
|
||
<p>
|
||
{
|
||
item.description
|
||
}
|
||
</p>
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
</TooltipProvider>
|
||
</div>
|
||
{item.type === "string" && (
|
||
<Input
|
||
placeholder={
|
||
item.options
|
||
?.placeholder ||
|
||
"..."
|
||
}
|
||
onChange={(e) =>
|
||
modifyOption({
|
||
type: "string",
|
||
name: item.parameter,
|
||
data: e
|
||
.target
|
||
.value,
|
||
event: e,
|
||
})
|
||
}
|
||
value={decodeURIComponent(
|
||
option?.find(
|
||
(o) =>
|
||
o.name ===
|
||
item.parameter,
|
||
)?.value || "",
|
||
)}
|
||
/>
|
||
)}
|
||
{item.type ===
|
||
"boolean" && (
|
||
<Checkbox
|
||
onCheckedChange={(
|
||
bool,
|
||
) =>
|
||
modifyOption({
|
||
type: "boolean",
|
||
name: item.parameter,
|
||
data: bool,
|
||
})
|
||
}
|
||
checked={
|
||
option?.find(
|
||
(o) =>
|
||
o.name ===
|
||
item.parameter,
|
||
)?.value ===
|
||
"true"
|
||
? true
|
||
: option?.find(
|
||
(
|
||
o,
|
||
) =>
|
||
o.name ===
|
||
item.parameter,
|
||
)
|
||
?.value ===
|
||
"false"
|
||
? false
|
||
: item
|
||
.options
|
||
?.defaultBool ||
|
||
false
|
||
}
|
||
/>
|
||
)}
|
||
{item.type === "list" && (
|
||
<Select
|
||
onValueChange={(
|
||
val,
|
||
) =>
|
||
modifyOption({
|
||
type: "list",
|
||
name: item.parameter,
|
||
data: val,
|
||
})
|
||
}
|
||
value={
|
||
option?.find(
|
||
(o) =>
|
||
o.name ===
|
||
item.parameter,
|
||
)?.value || ""
|
||
}
|
||
>
|
||
<SelectTrigger className="w-[180px]">
|
||
<SelectValue placeholder="Theme" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{item.options.list.map(
|
||
(
|
||
option,
|
||
) => (
|
||
<SelectItem
|
||
value={
|
||
option.value
|
||
}
|
||
key={
|
||
option.value
|
||
}
|
||
>
|
||
{
|
||
option.name
|
||
}
|
||
</SelectItem>
|
||
),
|
||
)}
|
||
</SelectContent>
|
||
</Select>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
<DialogFooter>
|
||
<Button onClick={() => setOption([])}>
|
||
Reset
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
<div className="mt-2">
|
||
<motion.img
|
||
className={`${onImageLoaded ? "" : "animate-pulse rounded-md bg-[#3d3d43]"}`}
|
||
initial={{
|
||
opacity: 0,
|
||
}}
|
||
animate={{
|
||
opacity: onImageLoaded ? 1 : 0,
|
||
}}
|
||
transition={{ duration: 0.5 }}
|
||
src={url}
|
||
height={280}
|
||
width={500}
|
||
alt="Your Lanyard Banner"
|
||
onLoad={() => setOnImageLoaded(true)}
|
||
suppressHydrationWarning
|
||
/>
|
||
</div>
|
||
</motion.div>
|
||
</div>
|
||
</main>
|
||
<footer className="stat">
|
||
Lanyard Profile Readme has{" "}
|
||
<div
|
||
style={{ fontWeight: "bold", width: "3.2rem" }}
|
||
ref={countRef}
|
||
/>{" "}
|
||
total users!
|
||
</footer>
|
||
</>
|
||
);
|
||
}
|