Compare commits
305 Commits
5217ee4aab
...
renovate/r
Author | SHA1 | Date | |
---|---|---|---|
d58a507331 | |||
96ab998031 | |||
7de2848f45 | |||
63fead44c3 | |||
f8aa41ed05 | |||
50e6506fc4 | |||
2a6488d4e5 | |||
7be126fe00 | |||
0ef741381c | |||
51ac278329 | |||
9996df2f92 | |||
c1c74ffef7 | |||
cf0e595cfc | |||
2efcb4a780 | |||
50037d9090 | |||
8a1263a1dd | |||
137de19133 | |||
c9c79c1f29 | |||
65d0cc7807 | |||
d44ace3f20 | |||
5b251e07ad | |||
636b0dde29 | |||
634cf8877a | |||
6ee34eaf44 | |||
d51215a781 | |||
22dba0bc90 | |||
78ff92eed3 | |||
7e73cc3e8c | |||
49cae6930b | |||
33ab92f579 | |||
db6af18475 | |||
a2cd3d0cf5 | |||
916ee81302 | |||
28aa81036f | |||
5fde0f46c5 | |||
5a4056f113 | |||
08e270da6f | |||
b2fbcb6ebf | |||
7b602d64f8 | |||
4026b08b42 | |||
d5b48e8bc9 | |||
d3886e4a39 | |||
94b81e0d69 | |||
35e786d9a5 | |||
9278a23f5a | |||
2954dd4955 | |||
0ce04fbc37 | |||
9930b8387f | |||
9be11a628b | |||
0afcd08f91 | |||
d1b3b7e1fe | |||
0ae2c2956a | |||
35ad8b458b | |||
c7365df0bd | |||
307551dc05 | |||
fc90fdd59b | |||
829a0afea0 | |||
266ac15bb1 | |||
78987e0c55 | |||
65a154a5dc | |||
179b5eb9d2 | |||
24f34ecd03 | |||
8e655681a7 | |||
91867bb718 | |||
2a17ac2d3d | |||
840711604b | |||
260dadda6d | |||
5370fd7cad | |||
725e0cd25d | |||
649141ece3 | |||
390f1ffd64 | |||
eb32d3786d | |||
bc27ed78b1 | |||
11e2efef2e | |||
68e0599159 | |||
bc04ac8e82 | |||
fe0a0d19f9 | |||
cfdc69a078 | |||
9abf21e890 | |||
2d3db00551 | |||
73441e0898 | |||
7bee4c1611 | |||
5fd3d2f822 | |||
3156916b4f | |||
a29f0c41ac | |||
dbcba6682b | |||
cf1f2bce26 | |||
795f02e0e0 | |||
239d2f2078 | |||
30e55d43c7 | |||
62919e1b93 | |||
0ccc90851a | |||
fb70dd6ab7 | |||
4198176b69 | |||
8001ad7a11 | |||
89805d0442 | |||
58f4a6edf8 | |||
a0b8777a94 | |||
1e96ed1b1c | |||
63e4eedc37 | |||
c2790054db | |||
1ab27f69be | |||
52fea3da68 | |||
4ffd10e9c3 | |||
9e17145e90 | |||
782b5efd4b | |||
e277645333 | |||
9ba13d1160 | |||
40029b9839 | |||
ffa2db120f | |||
525b4981fe | |||
ce55389cba | |||
656eefa2e5 | |||
230e5d6864 | |||
69fd6483de | |||
a6650af640 | |||
f0eaaaad5e | |||
96dc8de92f | |||
00a5febf66 | |||
49daf6f1a4 | |||
c2d7f5f33c | |||
a200fa045c | |||
d26c70f507 | |||
0b9a112c5b | |||
23c4284d8a | |||
5c05493ed1 | |||
32364ab58f | |||
81e6580eed | |||
735d6532c2 | |||
2a06a4d338 | |||
8d35e7ab0f | |||
ec27cd9f29 | |||
738fcb8e24 | |||
ab2716a8c9 | |||
130444e3e2 | |||
e791c20ba7 | |||
96abad164a | |||
e6a28ed268 | |||
3b1872a9dc | |||
4b336e4456 | |||
cb6814d47b | |||
389c004d16 | |||
213cdb1e5c | |||
ebdaf623d9 | |||
e7ef6a1bd8 | |||
a5cab7766e | |||
dbed53efe4 | |||
24170a5a66 | |||
b5198b5807 | |||
025233d837 | |||
f7a3bb00a5 | |||
325fe62569 | |||
fcba215522 | |||
a17ce202d8 | |||
7553a35b79 | |||
74618c8cce | |||
9b0e5bfcf7 | |||
3ca0b5be81 | |||
400f878939 | |||
fe43779a67 | |||
fa530d3168 | |||
18a782243e | |||
6356be7ac3 | |||
c124e5750b | |||
8df02ab62c | |||
a0e08ff437 | |||
4827125f73 | |||
b4076c850a | |||
57a45a4c05 | |||
e8e74a1a8d | |||
9399cf9efd | |||
7edfc31e82 | |||
9a5cc8642d | |||
8169c08faa | |||
d0f926f330 | |||
6e2fc9e13b | |||
31b831f97b | |||
a3e9411c97 | |||
3be59f97c3 | |||
1ed0e72316 | |||
78390a3bc9 | |||
95fef83dcf | |||
97c1b88370 | |||
2b6d8964b3 | |||
b587a1072e | |||
19f52d8575 | |||
03cdef9d23 | |||
93ea24762c | |||
7305714b12 | |||
cdb75aae87 | |||
617a57fb3f | |||
c62ce2bc6f | |||
2eedafb72d | |||
78709ed060 | |||
0708def9a1 | |||
9fb8bc7b1d | |||
0641d6722c | |||
777d7bebd3 | |||
7df6e93f74 | |||
d90a3985cb | |||
69502c7709 | |||
c2ca3beb05 | |||
f06b47823c | |||
6ffdff96d6 | |||
67a8cff0fd | |||
bcf6c4867d | |||
d014f87d07 | |||
cfbc9a082c | |||
7ca580b54e | |||
268a607143 | |||
f92453e1b4 | |||
a82f92f376 | |||
ced3054ec3 | |||
7eebb04e1a | |||
776782287c | |||
328b62718f | |||
685fee9689 | |||
1540daf269 | |||
78dc37f9b7 | |||
cfb0411fc1 | |||
081c0c9f92 | |||
5129e843de | |||
8b4d228c11 | |||
ec984db31a | |||
aefd0d5e3a | |||
f2177b6c2f | |||
d1db008589 | |||
50e9875e9a | |||
2053f7baef | |||
ef1abcf162 | |||
380c50760d | |||
2040710f05 | |||
e245eea213 | |||
15fadd38e5 | |||
6b3851ef16 | |||
a7e8f8a3cc | |||
2945caa2d7 | |||
48db8c4a7a | |||
339f94dcef | |||
c5247806e4 | |||
7c8044e6e7 | |||
b4d3e4002d | |||
b99313e008 | |||
2c80edb56a | |||
57144c5c91 | |||
6e46d613a4 | |||
2a080e28a1 | |||
f61e46cb27 | |||
21e1a0e596 | |||
e793d0eb39 | |||
f69d69373c | |||
ccd0e0851c | |||
3f8204ddd3 | |||
413b52b499 | |||
587c1bca82 | |||
54911efd0d | |||
4739297ba5 | |||
93477561a0 | |||
2c3b938765 | |||
f9257bb184 | |||
211807f494 | |||
0c4a1ebda9 | |||
ec47715c44 | |||
ff017c6bad | |||
895b2f63c8 | |||
49de624a88 | |||
428a95c54d | |||
e0e6a72d92 | |||
6238400ffe | |||
19e386b539 | |||
ea98b2bd5a | |||
269653ed55 | |||
4505a61653 | |||
f1447ebb9d | |||
0f304b22b1 | |||
73fcc708ce | |||
ac1d9b4f82 | |||
3c9709df12 | |||
095f773fb7 | |||
1534175fb7 | |||
9df69068df | |||
af72418772 | |||
6896db0d89 | |||
9830a21476 | |||
b978bc14df | |||
3ebc3c0612 | |||
6f0b3ec252 | |||
9255c40288 | |||
590d9cfaff | |||
582084da88 | |||
f1a446bcf4 | |||
5a1284e8c2 | |||
defdcccdb6 | |||
f5203f9742 | |||
fa7d3c5ea4 | |||
425a2b1464 | |||
3786ff84ad | |||
f533f1a932 | |||
8ef2faee36 | |||
6b96c3aedb | |||
347ee00c18 | |||
1a347108c2 | |||
b57cb41852 | |||
77d03e79e9 | |||
31accee7f7 |
16
.gitignore
vendored
16
.gitignore
vendored
@ -37,3 +37,19 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
# Sentry Config File
|
||||||
|
.sentryclirc
|
||||||
|
|
||||||
|
# Sentry Config File
|
||||||
|
.sentryclirc
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Sitemap & Robots
|
||||||
|
/public/sitemap*
|
||||||
|
/public/robots.txt
|
||||||
|
|
||||||
|
# Sentry Config File
|
||||||
|
.env.sentry-build-plugin
|
||||||
|
12
.prettierrc
Normal file
12
.prettierrc
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"arrowParens": "avoid",
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"htmlWhitespaceSensitivity": "css",
|
||||||
|
"jsxBracketSameLine": false,
|
||||||
|
"printWidth": 120,
|
||||||
|
"proseWrap": "always",
|
||||||
|
"quoteProps": "as-needed",
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"useTabs": false
|
||||||
|
}
|
26
Dockerfile
26
Dockerfile
@ -1,4 +1,4 @@
|
|||||||
FROM node:18-alpine AS base
|
FROM node:21-alpine AS base
|
||||||
|
|
||||||
# Install dependencies only when needed
|
# Install dependencies only when needed
|
||||||
FROM base AS deps
|
FROM base AS deps
|
||||||
@ -22,11 +22,18 @@ WORKDIR /app
|
|||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Next.js collects completely anonymous telemetry data about general usage.
|
# Get the git commit hash
|
||||||
# Learn more here: https://nextjs.org/telemetry
|
ARG GIT_REV
|
||||||
# Uncomment the following line in case you want to disable telemetry during the build.
|
ENV GIT_REV ${GIT_REV}
|
||||||
# ENV NEXT_TELEMETRY_DISABLED 1
|
|
||||||
|
|
||||||
|
# Sentry Auth Token
|
||||||
|
ARG SENTRY_AUTH_TOKEN
|
||||||
|
ENV SENTRY_AUTH_TOKEN ${SENTRY_AUTH_TOKEN}
|
||||||
|
|
||||||
|
# Disable telemetry during build
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
|
|
||||||
|
# Build the frontend
|
||||||
RUN \
|
RUN \
|
||||||
if [ -f yarn.lock ]; then yarn run build; \
|
if [ -f yarn.lock ]; then yarn run build; \
|
||||||
elif [ -f package-lock.json ]; then npm run build; \
|
elif [ -f package-lock.json ]; then npm run build; \
|
||||||
@ -39,12 +46,17 @@ FROM base AS runner
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV NODE_ENV production
|
ENV NODE_ENV production
|
||||||
# Uncomment the following line in case you want to disable telemetry during runtime.
|
|
||||||
# ENV NEXT_TELEMETRY_DISABLED 1
|
# Disable telemetry during runtime
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
|
|
||||||
RUN addgroup --system --gid 1001 nodejs
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
RUN adduser --system --uid 1001 nextjs
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
# Copy the public folder
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder /app/documentation ./documentation
|
||||||
|
|
||||||
# Set the correct permission for prerender cache
|
# Set the correct permission for prerender cache
|
||||||
RUN mkdir .next
|
RUN mkdir .next
|
||||||
RUN chown nextjs:nodejs .next
|
RUN chown nextjs:nodejs .next
|
||||||
|
37
README.md
37
README.md
@ -1,36 +1,3 @@
|
|||||||
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
|
# Minecraft Utilities - Frontend
|
||||||
|
|
||||||
## Getting Started
|
See [The Website](https://mcutils.xyz) or [Minecraft Utilities Documentation](https://mcutils.xyz/docs) for more information.
|
||||||
|
|
||||||
First, run the development server:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run dev
|
|
||||||
# or
|
|
||||||
yarn dev
|
|
||||||
# or
|
|
||||||
pnpm dev
|
|
||||||
# or
|
|
||||||
bun dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
|
||||||
|
|
||||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
|
||||||
|
|
||||||
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
|
|
||||||
|
|
||||||
## Learn More
|
|
||||||
|
|
||||||
To learn more about Next.js, take a look at the following resources:
|
|
||||||
|
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
|
||||||
|
|
||||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
|
|
||||||
|
|
||||||
## Deploy on Vercel
|
|
||||||
|
|
||||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
|
|
@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"siteName": "Minecraft Utilities",
|
"name": "Minecraft Utilities",
|
||||||
"siteDescription": "Minecraft Utilities offers you many endpoints to get information about a minecraft server or a player.",
|
"description": "Minecraft Utilities offers you many endpoints to get information about a minecraft server or a player.",
|
||||||
"siteUrl": "https://mcutils.xyz/"
|
"publicUrl": "https://mcutils.xyz/",
|
||||||
|
"apiEndpoint": "https://api.mcutils.xyz"
|
||||||
}
|
}
|
||||||
|
34
documentation/home.md
Normal file
34
documentation/home.md
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
title: Home
|
||||||
|
summary: Welcome to the Minecraft Utilities documentation!
|
||||||
|
---
|
||||||
|
|
||||||
|
# Getting Started
|
||||||
|
|
||||||
|
Welcome to the Minecraft Utilities documentation! You can find information on how to use the various features of the API.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
See below for a list of features that are currently available in the API.
|
||||||
|
|
||||||
|
| Feature | Description |
|
||||||
|
|----------------------------------------------------------|----------------------------------------|
|
||||||
|
| [Player Lookup](/docs/player/player-lookup) | Get a player's information |
|
||||||
|
| [Player Username To Uuid](/docs/player/username-to-uuid) | Get a player's skin parts |
|
||||||
|
| [Player Skin Parts](/docs/player/skin-parts) | Get a player's skin parts |
|
||||||
|
| [Server Lookup](/docs/server/server-lookup) | Get a server's information |
|
||||||
|
| [Server Icon](/docs/server/favicon) | Get a server's icon |
|
||||||
|
| [Server Preview](/docs/server/preview) | View the server as if you were in-game |
|
||||||
|
| [Mojang Status](/docs/mojang/endpoint-status) | Get the status of Mojang's services |
|
||||||
|
|
||||||
|
|
||||||
|
## Libraries
|
||||||
|
|
||||||
|
We offer a few different libraries for different languages to help you get started with the Minecraft Utilities plugin.
|
||||||
|
|
||||||
|
- [Java](/docs/libraries/java)
|
||||||
|
- [JavaScript](/docs/libraries/javascript)
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
You can view our Postman collection [here](https://www.postman.com/imfascinated/workspace/minecraft-utilities) to see all the available endpoints and how to use them.
|
42
documentation/libraries/java.md
Normal file
42
documentation/libraries/java.md
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
title: Java Library
|
||||||
|
summary: The Java library for Minecraft Utilities is a simple way to interact with the API using Java!
|
||||||
|
---
|
||||||
|
|
||||||
|
# Java Library
|
||||||
|
|
||||||
|
This is the Java library for Minecraft Utilities.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
To use the Java library, you can add the following to your `pom.xml` file:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<repositories>
|
||||||
|
<repository>
|
||||||
|
<id>fascinated-repo-public</id>
|
||||||
|
<name>Fascinated's Repository</name>
|
||||||
|
<url>https://repo.fascinated.cc/public</url>
|
||||||
|
</repository>
|
||||||
|
</repositories>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>xyz.mcutils</groupId>
|
||||||
|
<artifactId>mcutils-java-library</artifactId>
|
||||||
|
<version>1.1.1</version>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
This is a simple example of how to use the Java library to get a player's information.
|
||||||
|
|
||||||
|
```java
|
||||||
|
public class Main {
|
||||||
|
public static void main(String[] args) {
|
||||||
|
System.out.println(McUtilsAPI.getPlayer("Notch"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
28
documentation/libraries/javascript.md
Normal file
28
documentation/libraries/javascript.md
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
title: Javascript Library
|
||||||
|
summary: The Javascript library for Minecraft Utilities is a simple way to interact with the API using Javascript!
|
||||||
|
---
|
||||||
|
|
||||||
|
# Javascript Library
|
||||||
|
|
||||||
|
This is the Javascript library for Minecraft Utilities.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
To use the Javascript library, you can run the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install mcutils-library
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
This is a simple example of how to use the Javascript library to get a player's information.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { getPlayer, CachedPlayer } from "mcutils-library";
|
||||||
|
|
||||||
|
const cachedPlayer: CachedPlayer = await getPlayer(playerId);
|
||||||
|
|
||||||
|
console.log(player);
|
||||||
|
```
|
63
documentation/mojang/endpoint-status.md
Normal file
63
documentation/mojang/endpoint-status.md
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
---
|
||||||
|
title: Mojang Endpoint Status
|
||||||
|
summary: Get the status of the Mojang APIs.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Overview
|
||||||
|
|
||||||
|
The Mojang endpoint status endpoint allows you to get the status of the Mojang APIs.
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /mojang/status
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X GET "https://api.mcutils.xyz/mojang/status" -H "accept: application/json"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cache": {
|
||||||
|
"cached": false,
|
||||||
|
"cachedTime": -1
|
||||||
|
},
|
||||||
|
"endpoints": [
|
||||||
|
{
|
||||||
|
"name": "Minecraft Textures",
|
||||||
|
"hostname": "textures.minecraft.net",
|
||||||
|
"status": "ONLINE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Minecraft Libraries",
|
||||||
|
"hostname": "libraries.minecraft.net",
|
||||||
|
"status": "ONLINE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Minecraft Services",
|
||||||
|
"hostname": "api.minecraftservices.com",
|
||||||
|
"status": "ONLINE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Mojang Assets",
|
||||||
|
"hostname": "assets.mojang.com",
|
||||||
|
"status": "ONLINE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Mojang API",
|
||||||
|
"hostname": "api.mojang.com",
|
||||||
|
"status": "ONLINE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Mojang Session Server",
|
||||||
|
"hostname": "sessionserver.mojang.com",
|
||||||
|
"status": "ONLINE"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
68
documentation/player/player-lookup.md
Normal file
68
documentation/player/player-lookup.md
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
---
|
||||||
|
title: Player Lookup
|
||||||
|
summary: Get information about a player.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Overview
|
||||||
|
|
||||||
|
The player lookup endpoint allows you to get information about a player. This includes their UUID, username, and any other information that is available.
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /player/:query
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
|
||||||
|
| Parameter | Description | Required |
|
||||||
|
|-----------|---------------------------------------------------------|----------|
|
||||||
|
| query | The username or uuid of the player you want to look up. | Yes |
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X GET "https://api.mcutils.xyz/player/imfascinated" -H "accept: application/json"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cache": {
|
||||||
|
"cached": false,
|
||||||
|
"cachedTime": -1
|
||||||
|
},
|
||||||
|
"uniqueId": "eeab5f8a-18dd-4d58-af78-2b3c4543da48",
|
||||||
|
"trimmedUniqueId": "eeab5f8a18dd4d58af782b3c4543da48",
|
||||||
|
"username": "ImFascinated",
|
||||||
|
"skin": {
|
||||||
|
"url": "http://textures.minecraft.net/texture/ba1e0c9e21983c06e45614642316cd7029a297bc246bc6d236a41388c3ff9a09",
|
||||||
|
"model": "SLIM",
|
||||||
|
"legacy": false,
|
||||||
|
"parts": {
|
||||||
|
"head": "https://api.mcutils.xyz/player/head/eeab5f8a-18dd-4d58-af78-2b3c4543da48",
|
||||||
|
"face": "https://api.mcutils.xyz/player/face/eeab5f8a-18dd-4d58-af78-2b3c4543da48",
|
||||||
|
"body": "https://api.mcutils.xyz/player/body/eeab5f8a-18dd-4d58-af78-2b3c4543da48"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cape": {
|
||||||
|
"url": "http://textures.minecraft.net/texture/2340c0e03dd24a11b15a8b33c2a7e9e32abb2051b2481d0ba7defd635ca7a933"
|
||||||
|
},
|
||||||
|
"rawProperties": [
|
||||||
|
{
|
||||||
|
"name": "textures",
|
||||||
|
"value": "ewogICJ0aW1lc3RhbXAiIDogMTcxMzY3MDc4MTM0NSwKICAicHJvZmlsZUlkIiA6ICJlZWFiNWY4YTE4ZGQ0ZDU4YWY3ODJiM2M0NTQzZGE0OCIsCiAgInByb2ZpbGVOYW1lIiA6ICJJbUZhc2NpbmF0ZWQiLAogICJ0ZXh0dXJlcyIgOiB7CiAgICAiU0tJTiIgOiB7CiAgICAgICJ1cmwiIDogImh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvYmExZTBjOWUyMTk4M2MwNmU0NTYxNDY0MjMxNmNkNzAyOWEyOTdiYzI0NmJjNmQyMzZhNDEzODhjM2ZmOWEwOSIsCiAgICAgICJtZXRhZGF0YSIgOiB7CiAgICAgICAgIm1vZGVsIiA6ICJzbGltIgogICAgICB9CiAgICB9LAogICAgIkNBUEUiIDogewogICAgICAidXJsIiA6ICJodHRwOi8vdGV4dHVyZXMubWluZWNyYWZ0Lm5ldC90ZXh0dXJlLzIzNDBjMGUwM2RkMjRhMTFiMTVhOGIzM2MyYTdlOWUzMmFiYjIwNTFiMjQ4MWQwYmE3ZGVmZDYzNWNhN2E5MzMiCiAgICB9CiAgfQp9",
|
||||||
|
"signed": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Errors
|
||||||
|
|
||||||
|
| Status Code | Description |
|
||||||
|
|-------------|-----------------------------------------------|
|
||||||
|
| 400 | The username is invalid. |
|
||||||
|
| 404 | The player was not found. |
|
||||||
|
| 429 | The Mojang API rate limit has been exhausted. |
|
47
documentation/player/skin-parts.md
Normal file
47
documentation/player/skin-parts.md
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
---
|
||||||
|
title: Player Skin Parts
|
||||||
|
summary: Get a specific part of a player's skin.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Overview
|
||||||
|
|
||||||
|
The player skin parts endpoint allows you to get a specific part of a player's skin.
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /player/:part/:query
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parts
|
||||||
|
|
||||||
|
| Part | Description |
|
||||||
|
|------|---------------------------------|
|
||||||
|
| head | Get the player's isometric head |
|
||||||
|
| face | Get the player's face |
|
||||||
|
| body | Get the player's body |
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
|
||||||
|
| Parameter | Description | Required |
|
||||||
|
|-----------|---------------------------------------------------------|----------|
|
||||||
|
| part | The part of the skin you want to get. | Yes |
|
||||||
|
| query | The username or uuid of the player you want to look up. | Yes |
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X GET "https://api.mcutils.xyz/player/head/imfascinated" -H "accept: image/png"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Response
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Errors
|
||||||
|
|
||||||
|
| Status Code | Description |
|
||||||
|
|-------------|-----------------------------------------------|
|
||||||
|
| 400 | The username is invalid. |
|
||||||
|
| 404 | The player was not found. |
|
||||||
|
| 429 | The Mojang API rate limit has been exhausted. |
|
46
documentation/player/username-to-uuid.md
Normal file
46
documentation/player/username-to-uuid.md
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
title: Player Username to UUID
|
||||||
|
summary: Get a player's UUID from their username.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Overview
|
||||||
|
|
||||||
|
The player username to UUID endpoint allows you to get a player's UUID from their username.
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /player/uuid/:query
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
|
||||||
|
| Parameter | Description | Required |
|
||||||
|
|-----------|---------------------------------------------------------|----------|
|
||||||
|
| query | The username of the player you want to look up. | Yes |
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X GET "https://api.mcutils.xyz/player/uuid/imfascinated" -H "accept: application/json"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cache": {
|
||||||
|
"cached": false,
|
||||||
|
"cachedTime": -1
|
||||||
|
},
|
||||||
|
"uniqueId": "eeab5f8a-18dd-4d58-af78-2b3c4543da48",
|
||||||
|
"username": "ImFascinated"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Errors
|
||||||
|
|
||||||
|
| Status Code | Description |
|
||||||
|
|-------------|-----------------------------------------------|
|
||||||
|
| 400 | The username is invalid. |
|
||||||
|
| 429 | The Mojang API rate limit has been exhausted. |
|
45
documentation/server/blocked.md
Normal file
45
documentation/server/blocked.md
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
title: Server Blocked Status
|
||||||
|
summary: Get the Mojang blocked status of a Minecraft server.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Overview
|
||||||
|
|
||||||
|
The server blocked status endpoint allows you to get the Mojang blocked status of a Minecraft server.
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /server/blocked/:query
|
||||||
|
```
|
||||||
|
|
||||||
|
## Platforms
|
||||||
|
|
||||||
|
This endpoint is only available for Java servers.
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
|
||||||
|
| Parameter | Description | Required |
|
||||||
|
|-----------|---------------------------------------------------------------|----------|
|
||||||
|
| query | The IP address or hostname of the server you want to look up. | Yes |
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X GET "https://api.mcutils.xyz/server/blocked/play.hypixel.net"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"blocked": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Errors
|
||||||
|
|
||||||
|
| Status Code | Description |
|
||||||
|
|-------------|-----------------------------------------------|
|
||||||
|
| 400 | The server was not found. |
|
||||||
|
| 429 | The Mojang API rate limit has been exhausted. |
|
41
documentation/server/favicon.md
Normal file
41
documentation/server/favicon.md
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
title: Server Favicon
|
||||||
|
summary: Get the favicon of a Minecraft server.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Overview
|
||||||
|
|
||||||
|
The server favicon endpoint allows you to get the favicon of a Minecraft server.
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /server/icon/:query
|
||||||
|
```
|
||||||
|
|
||||||
|
## Platforms
|
||||||
|
|
||||||
|
This endpoint is only available for Java servers.
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
|
||||||
|
| Parameter | Description | Required |
|
||||||
|
|-----------|---------------------------------------------------------------------------|----------|
|
||||||
|
| query | The IP address or hostname of the server you want to get the favicon for. | Yes |
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X GET "https://api.mcutils.xyz/server/icon/hypixel.net" -H "accept: image/png"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Response
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Errors
|
||||||
|
|
||||||
|
| Status Code | Description |
|
||||||
|
|-------------|-----------------------------------------------|
|
||||||
|
| 400 | The server was not found. |
|
||||||
|
| 429 | The Mojang API rate limit has been exhausted. |
|
45
documentation/server/preview.md
Normal file
45
documentation/server/preview.md
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
title: Server Preview
|
||||||
|
summary: Get the server list preview of a Minecraft server.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Overview
|
||||||
|
|
||||||
|
The server preview endpoint allows you to get the server list preview of a Minecraft server.
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /server/:platform/preview/:query
|
||||||
|
```
|
||||||
|
|
||||||
|
## Platforms
|
||||||
|
|
||||||
|
| Platform | Description |
|
||||||
|
|----------|-----------------|
|
||||||
|
| bedrock | Bedrock Edition |
|
||||||
|
| java | Java Edition |
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
|
||||||
|
| Parameter | Description | Required |
|
||||||
|
|-----------|---------------------------------------------------------------------------|----------|
|
||||||
|
| platform | The platform of the server you want to get the preview for. | Yes |
|
||||||
|
| query | The IP address or hostname of the server you want to get the preview for. | Yes |
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X GET "https://api.mcutils.xyz/server/java/preview/hypixel.net" -H "accept: image/png"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Response
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Errors
|
||||||
|
|
||||||
|
| Status Code | Description |
|
||||||
|
|-------------|-----------------------------------------------|
|
||||||
|
| 400 | The server was not found. |
|
||||||
|
| 429 | The Mojang API rate limit has been exhausted. |
|
98
documentation/server/server-lookup.md
Normal file
98
documentation/server/server-lookup.md
Normal file
File diff suppressed because one or more lines are too long
5
next-sitemap.config.js
Normal file
5
next-sitemap.config.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
/** @type {import('next-sitemap').IConfig} */
|
||||||
|
module.exports = {
|
||||||
|
siteUrl: process.env.SITE_URL || "https://mcutils.xyz",
|
||||||
|
generateRobotsTxt: true,
|
||||||
|
};
|
@ -1,3 +1,17 @@
|
|||||||
|
import {withSentryConfig} from "@sentry/nextjs";
|
||||||
|
import nextBuildId from "next-build-id";
|
||||||
|
import path from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current git commit hash.
|
||||||
|
*
|
||||||
|
* @type {string|string} The current git commit hash.
|
||||||
|
*/
|
||||||
|
const buildId = (
|
||||||
|
process.env.GIT_REV || nextBuildId.sync({ dir: path.dirname(fileURLToPath(import.meta.url)) })
|
||||||
|
).substring(0, 7);
|
||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
@ -15,6 +29,46 @@ const nextConfig = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
env: {
|
||||||
|
NEXT_PUBLIC_BUILD_ID: buildId,
|
||||||
|
},
|
||||||
|
experimental: {
|
||||||
|
mdxRs: true,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default withSentryConfig(nextConfig, {
|
||||||
|
// For all available options, see:
|
||||||
|
// https://github.com/getsentry/sentry-webpack-plugin#options
|
||||||
|
|
||||||
|
org: "minecraft-utilities",
|
||||||
|
project: "frontend",
|
||||||
|
sentryUrl: "https://glitchtip.fascinated.cc/",
|
||||||
|
|
||||||
|
// Only print logs for uploading source maps in CI
|
||||||
|
silent: !process.env.CI,
|
||||||
|
|
||||||
|
// For all available options, see:
|
||||||
|
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
|
||||||
|
|
||||||
|
// Upload a larger set of source maps for prettier stack traces (increases build time)
|
||||||
|
widenClientFileUpload: true,
|
||||||
|
|
||||||
|
// Uncomment to route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
|
||||||
|
// This can increase your server load as well as your hosting bill.
|
||||||
|
// Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
|
||||||
|
// side errors will fail.
|
||||||
|
// tunnelRoute: "/monitoring",
|
||||||
|
|
||||||
|
// Hides source maps from generated client bundles
|
||||||
|
hideSourceMaps: true,
|
||||||
|
|
||||||
|
// Automatically tree-shake Sentry logger statements to reduce bundle size
|
||||||
|
disableLogger: true,
|
||||||
|
|
||||||
|
// Enables automatic instrumentation of Vercel Cron Monitors. (Does not yet work with App Router route handlers.)
|
||||||
|
// See the following for more information:
|
||||||
|
// https://docs.sentry.io/product/crons/
|
||||||
|
// https://vercel.com/docs/cron-jobs
|
||||||
|
automaticVercelMonitors: true,
|
||||||
|
});
|
49
package.json
49
package.json
@ -5,31 +5,66 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbo",
|
"dev": "next dev --turbo",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
|
"postbuild": "next-sitemap",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@eslint/eslintrc": "^3.0.2",
|
||||||
|
"@heroicons/react": "^2.1.3",
|
||||||
|
"@hookform/resolvers": "^3.3.4",
|
||||||
|
"@mdx-js/loader": "^3.0.1",
|
||||||
|
"@mdx-js/react": "^3.0.1",
|
||||||
|
"@next/mdx": "^14.2.2",
|
||||||
|
"@radix-ui/react-context-menu": "^2.1.5",
|
||||||
|
"@radix-ui/react-dialog": "^1.0.5",
|
||||||
|
"@radix-ui/react-label": "^2.0.2",
|
||||||
|
"@radix-ui/react-popover": "^1.0.7",
|
||||||
|
"@radix-ui/react-select": "^2.0.0",
|
||||||
|
"@radix-ui/react-separator": "^1.0.3",
|
||||||
"@radix-ui/react-slot": "^1.0.2",
|
"@radix-ui/react-slot": "^1.0.2",
|
||||||
"axios": "^1.6.8",
|
"@radix-ui/react-toast": "^1.1.5",
|
||||||
|
"@radix-ui/react-tooltip": "^1.0.7",
|
||||||
|
"@sentry/nextjs": "^8.20.0",
|
||||||
|
"@types/mdx": "^2.0.13",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
|
"clipboard-copy": "^4.0.1",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
"lucide-react": "^0.368.0",
|
"cmdk": "^1.0.0",
|
||||||
"mcutils-library": "^1.2.1",
|
"fuse.js": "^7.0.0",
|
||||||
|
"lucide-react": "^0.451.0",
|
||||||
|
"mcutils-library": "^1.3.1",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"next": "14.2.1",
|
"next": "14.2.5",
|
||||||
|
"next-build-id": "^3.0.0",
|
||||||
|
"next-sitemap": "^4.2.3",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
|
"react-countup": "^6.5.3",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
"react-toastify": "^10.0.5",
|
"react-spinners": "^0.14.0",
|
||||||
|
"react-syntax-highlighter": "^15.5.0",
|
||||||
|
"react-use-websocket": "4.9.0",
|
||||||
|
"read-file": "^0.2.0",
|
||||||
|
"remark-gfm": "^4.0.0",
|
||||||
|
"remote-mdx": "^0.0.8",
|
||||||
"tailwind-merge": "^2.2.2",
|
"tailwind-merge": "^2.2.2",
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"tailwindcss-animate": "^1.0.7"
|
||||||
},
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"overrides": {
|
||||||
|
"remark-mdx": "3.0.1",
|
||||||
|
"unified": "11.0.5",
|
||||||
|
"remark-parse": "11.0.0",
|
||||||
|
"mdast-util-frontmatter": "2.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
"eslint": "^8",
|
"@types/react-syntax-highlighter": "^15.5.11",
|
||||||
"eslint-config-next": "14.2.1",
|
"eslint-config-next": "14.2.5",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
|
11406
pnpm-lock.yaml
generated
11406
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
BIN
public/media/full-ping.png
Normal file
BIN
public/media/full-ping.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.2 KiB |
BIN
public/media/github.png
Normal file
BIN
public/media/github.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.2 KiB |
BIN
public/media/logo.png
Normal file
BIN
public/media/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.7 KiB |
BIN
public/media/platform/bedrock.png
Normal file
BIN
public/media/platform/bedrock.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.3 KiB |
BIN
public/media/platform/java.png
Normal file
BIN
public/media/platform/java.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.9 KiB |
BIN
public/media/server-background.png
Normal file
BIN
public/media/server-background.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 9.6 KiB |
6
renovate.json
Normal file
6
renovate.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"extends": [
|
||||||
|
"config:recommended"
|
||||||
|
]
|
||||||
|
}
|
30
sentry.client.config.ts
Normal file
30
sentry.client.config.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
// This file configures the initialization of Sentry on the client.
|
||||||
|
// The config you add here will be used whenever a users loads a page in their browser.
|
||||||
|
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||||
|
|
||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
|
||||||
|
Sentry.init({
|
||||||
|
dsn: "https://25aaa031240f4d659649d28e0a3fb0cb@glitchtip.fascinated.cc/1",
|
||||||
|
|
||||||
|
// Adjust this value in production, or use tracesSampler for greater control
|
||||||
|
tracesSampleRate: 1,
|
||||||
|
|
||||||
|
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||||
|
debug: false,
|
||||||
|
|
||||||
|
replaysOnErrorSampleRate: 1.0,
|
||||||
|
|
||||||
|
// This sets the sample rate to be 10%. You may want this to be 100% while
|
||||||
|
// in development and sample at a lower rate in production
|
||||||
|
replaysSessionSampleRate: 0.1,
|
||||||
|
|
||||||
|
// You can remove this option if you're not planning to use the Sentry Session Replay feature:
|
||||||
|
integrations: [
|
||||||
|
Sentry.replayIntegration({
|
||||||
|
// Additional Replay configuration goes in here, for example:
|
||||||
|
maskAllText: true,
|
||||||
|
blockAllMedia: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
16
sentry.edge.config.ts
Normal file
16
sentry.edge.config.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
// This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on).
|
||||||
|
// The config you add here will be used whenever one of the edge features is loaded.
|
||||||
|
// Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally.
|
||||||
|
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||||
|
|
||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
|
||||||
|
Sentry.init({
|
||||||
|
dsn: "https://25aaa031240f4d659649d28e0a3fb0cb@glitchtip.fascinated.cc/1",
|
||||||
|
|
||||||
|
// Adjust this value in production, or use tracesSampler for greater control
|
||||||
|
tracesSampleRate: 1,
|
||||||
|
|
||||||
|
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||||
|
debug: false,
|
||||||
|
});
|
19
sentry.server.config.ts
Normal file
19
sentry.server.config.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
// This file configures the initialization of Sentry on the server.
|
||||||
|
// The config you add here will be used whenever the server handles a request.
|
||||||
|
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||||
|
|
||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
|
||||||
|
Sentry.init({
|
||||||
|
dsn: "https://25aaa031240f4d659649d28e0a3fb0cb@glitchtip.fascinated.cc/1",
|
||||||
|
|
||||||
|
// Adjust this value in production, or use tracesSampler for greater control
|
||||||
|
tracesSampleRate: 1,
|
||||||
|
|
||||||
|
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||||
|
debug: false,
|
||||||
|
|
||||||
|
// Uncomment the line below to enable Spotlight (https://spotlightjs.com)
|
||||||
|
// spotlight: process.env.NODE_ENV === 'development',
|
||||||
|
|
||||||
|
});
|
20
src/app/(pages)/api/docs/search/route.ts
Normal file
20
src/app/(pages)/api/docs/search/route.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { searchDocs } from "@/app/common/documentation";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
// The query to search for
|
||||||
|
const query: string | null = request.nextUrl.searchParams.get("query");
|
||||||
|
|
||||||
|
// No query provided
|
||||||
|
if (!query) {
|
||||||
|
return new NextResponse(JSON.stringify({ error: "No query provided" }), { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't allow queries less than 3 characters
|
||||||
|
if (query.length < 3) {
|
||||||
|
return new NextResponse(JSON.stringify({ error: "Query must be at least 3 characters" }), { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the search results
|
||||||
|
return new NextResponse(JSON.stringify(searchDocs(query)));
|
||||||
|
}
|
75
src/app/(pages)/docs/[[...slug]]/page.tsx
Normal file
75
src/app/(pages)/docs/[[...slug]]/page.tsx
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { CustomMDX } from "@/app/components/mdx-components";
|
||||||
|
import { Metadata } from "next";
|
||||||
|
import { generateEmbed } from "@/app/common/embed";
|
||||||
|
import { Title } from "@/app/components/title";
|
||||||
|
import { DocsContentMetadata, getDocContent, getDocsContent } from "@/app/common/documentation";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { GithubLink } from "@/app/components/docs/github-link";
|
||||||
|
import { DocsBreadcrumb } from "@/app/components/docs/breadcrumb";
|
||||||
|
|
||||||
|
type DocumentationPageParams = {
|
||||||
|
params: {
|
||||||
|
/**
|
||||||
|
* The slug for the documentation page.
|
||||||
|
*/
|
||||||
|
slug?: string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function generateStaticParams() {
|
||||||
|
let documentationPages: DocsContentMetadata[] = getDocsContent();
|
||||||
|
|
||||||
|
return documentationPages.map(page => ({
|
||||||
|
slug: [page.slug],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params: { slug } }: DocumentationPageParams): Promise<Metadata> {
|
||||||
|
const page: DocsContentMetadata | undefined = getDocContent(slug);
|
||||||
|
|
||||||
|
// Fallback to page not found
|
||||||
|
if (!page) {
|
||||||
|
return generateEmbed({
|
||||||
|
title: "Page not found",
|
||||||
|
description: "The documentation page was not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return generateEmbed({
|
||||||
|
title: `${page.title} - Documentation`,
|
||||||
|
description: `${page.summary}\n\nClick to view this page`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Page({ params: { slug } }: DocumentationPageParams) {
|
||||||
|
const page: DocsContentMetadata | undefined = getDocContent(slug);
|
||||||
|
|
||||||
|
// Page was not found, show an error page
|
||||||
|
if (!page) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full px-4 flex flex-col gap-2">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
{/* The breadcrumb for the documentation page */}
|
||||||
|
<DocsBreadcrumb page={page} />
|
||||||
|
|
||||||
|
{/* The Git link for the documentation page */}
|
||||||
|
<div className="flex flex-row gap-2 items-center">
|
||||||
|
<GithubLink page={page} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* The documentation page title and description */}
|
||||||
|
<div className="text-center mb-4">
|
||||||
|
<Title title={page.title} subtitle={page.summary} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* The content of the documentation page */}
|
||||||
|
<div className="text-left w-full pb-[2rem]">
|
||||||
|
<CustomMDX source={page.content} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -1,109 +0,0 @@
|
|||||||
import { Card } from "@/app/components/card";
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/app/components/ui/table";
|
|
||||||
import { generateEmbed } from "@/common/embed";
|
|
||||||
import { capitalizeFirstLetter } from "@/common/string-utils";
|
|
||||||
import { formatTime } from "@/common/time-utils";
|
|
||||||
import { cn } from "@/common/utils";
|
|
||||||
import { CachedEndpointStatus, getMojangEndpointStatus } from "mcutils-library";
|
|
||||||
import { Metadata } from "next";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Force the page to be dynamic, so it will be regenerated on every request
|
|
||||||
*/
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the color of the status
|
|
||||||
*
|
|
||||||
* @param status the status of the endpoint
|
|
||||||
* @returns the color of the status
|
|
||||||
*/
|
|
||||||
function getColor(status: any): string {
|
|
||||||
switch (status) {
|
|
||||||
case "ONLINE":
|
|
||||||
return "text-green-500";
|
|
||||||
case "DEGRADED":
|
|
||||||
return "text-yellow-500";
|
|
||||||
case "OFFLINE":
|
|
||||||
return "text-red-500";
|
|
||||||
default:
|
|
||||||
return "text-gray-500";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Formats the status
|
|
||||||
*
|
|
||||||
* @param status the status of the endpoint
|
|
||||||
* @returns the formatted status
|
|
||||||
*/
|
|
||||||
function formatStatus(status: any): string {
|
|
||||||
return capitalizeFirstLetter(status.toLowerCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getData(): Promise<CachedEndpointStatus> {
|
|
||||||
const status = await getMojangEndpointStatus();
|
|
||||||
return status;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
|
||||||
const { endpoints } = await getData();
|
|
||||||
|
|
||||||
let description = "The current status of Mojang Services";
|
|
||||||
description += Object.entries(endpoints)
|
|
||||||
.map(([url, status]) => {
|
|
||||||
return `**${url}**: ${formatStatus(status)}`;
|
|
||||||
})
|
|
||||||
.join("\n");
|
|
||||||
description += `\n\nUpdated: ${formatTime(new Date())}`;
|
|
||||||
|
|
||||||
return generateEmbed({
|
|
||||||
title: "Mojang Status",
|
|
||||||
description: description,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function Page(): Promise<JSX.Element> {
|
|
||||||
const { endpoints } = await getData();
|
|
||||||
const endpointsSize = Object.entries(endpoints).length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex justify-center text-center">
|
|
||||||
<Card className="w-max xs:w-fit">
|
|
||||||
<h1 className="text-xl">Mojang Status</h1>
|
|
||||||
<p>The current status of Mojang Services</p>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
{endpointsSize === 0 && <p>Unable to fetch endpoint statuses</p>}
|
|
||||||
{endpointsSize > 0 && (
|
|
||||||
<Table className="mt-4 md:w-[500px] text-start">
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead className="pl-1">Service</TableHead>
|
|
||||||
<TableHead className="pl-1 text-center">Status</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{Object.entries(endpoints).map(([url, status]) => {
|
|
||||||
return (
|
|
||||||
<TableRow key={url}>
|
|
||||||
<TableCell className="p-[0.3rem]">
|
|
||||||
<Link className="hover:text-primary transition-all" href={url} target="_blank">
|
|
||||||
{url}
|
|
||||||
</Link>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className={cn(getColor(status), "p-[0.3rem] text-center")}>
|
|
||||||
{formatStatus(status)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
116
src/app/(pages)/mojang/status/page.tsx
Normal file
116
src/app/(pages)/mojang/status/page.tsx
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
import { Colors } from "@/app/common/colors";
|
||||||
|
import { generateEmbed } from "@/app/common/embed";
|
||||||
|
import { capitalizeFirstLetter } from "@/app/common/string-utils";
|
||||||
|
import { cn } from "@/app/common/utils";
|
||||||
|
import { Card } from "@/app/components/card";
|
||||||
|
import { Title } from "@/app/components/title";
|
||||||
|
import { CachedEndpointStatus, getMojangEndpointStatus, Status } from "mcutils-library";
|
||||||
|
import { Metadata, Viewport } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ReactElement } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force the page to be dynamic, so it will be regenerated on every request
|
||||||
|
*/
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the color of the status
|
||||||
|
*
|
||||||
|
* @param status the status of the endpoint
|
||||||
|
* @returns the color of the status
|
||||||
|
*/
|
||||||
|
function getColor(status: any): string {
|
||||||
|
switch (status) {
|
||||||
|
case "ONLINE":
|
||||||
|
return "text-green-500";
|
||||||
|
case "DEGRADED":
|
||||||
|
return "text-yellow-500";
|
||||||
|
case "OFFLINE":
|
||||||
|
return "text-red-500";
|
||||||
|
default:
|
||||||
|
return "text-gray-500";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats the status
|
||||||
|
*
|
||||||
|
* @param status the status of the endpoint
|
||||||
|
* @returns the formatted status
|
||||||
|
*/
|
||||||
|
function formatStatus(status: any): string {
|
||||||
|
return capitalizeFirstLetter(status.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateViewport(): Promise<Viewport> {
|
||||||
|
const { endpoints } = await getMojangEndpointStatus();
|
||||||
|
|
||||||
|
let warning = false;
|
||||||
|
for (const endpoint of endpoints) {
|
||||||
|
if (endpoint.status != Status.ONLINE) {
|
||||||
|
warning = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
themeColor: warning ? Colors.yellow : Colors.green,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const { endpoints } = await getMojangEndpointStatus();
|
||||||
|
|
||||||
|
let description = "Current Mojang API Status:\n";
|
||||||
|
for (const endpoint of endpoints) {
|
||||||
|
const { name, hostname, status } = endpoint;
|
||||||
|
|
||||||
|
description += `${name}: ${capitalizeFirstLetter(status.toLowerCase())}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return generateEmbed({
|
||||||
|
title: "Mojang Status",
|
||||||
|
description: description,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function Page(): Promise<ReactElement> {
|
||||||
|
const { endpoints } = await getMojangEndpointStatus();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="text-center mb-4">
|
||||||
|
<Title title="Mojang Status" subtitle="The current status of Mojang Services" />
|
||||||
|
</div>
|
||||||
|
<Card
|
||||||
|
classNameCard="py-0 pb-2 w-screen xs:w-[28rem] md:w-[35rem]"
|
||||||
|
classNameContent="text-left flex flex-col divide-y gap-2"
|
||||||
|
>
|
||||||
|
{endpoints.length == 0 && <p>Unable to fetch endpoint statuses</p>}
|
||||||
|
{endpoints.length > 0 &&
|
||||||
|
endpoints.map((server: CachedEndpointStatus) => {
|
||||||
|
const { name, endpoint, status } = server;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={name} className="flex flex-row justify-between pt-2">
|
||||||
|
<div className="flex flex-col leading-[1.5rem]">
|
||||||
|
<p className="font-semibold">{name}</p>
|
||||||
|
<Link
|
||||||
|
href={endpoint}
|
||||||
|
className="text-sm text-primary hover:opacity-75 transition-all transform-gpu"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<p>{endpoint}</p>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className={cn("flex items-center font-semibold", getColor(status))}>
|
||||||
|
<p>{formatStatus(status)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -1,38 +1,67 @@
|
|||||||
|
import { Stats } from "@/app/components/stats";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { ReactElement } from "react";
|
||||||
|
import { Button } from "../components/ui/button";
|
||||||
|
import { Separator } from "../components/ui/separator";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../components/ui/tooltip";
|
||||||
|
import { Title } from "@/app/components/title";
|
||||||
|
import { LandingButton } from "@/app/types/landing/landing-button";
|
||||||
|
|
||||||
type Button = {
|
const buttons: LandingButton[] = [
|
||||||
title: string;
|
{
|
||||||
url: string;
|
title: "Get Started",
|
||||||
};
|
tooltip: "Click to get started with the API",
|
||||||
|
url: "/docs",
|
||||||
const buttons: Button[] = [
|
className:
|
||||||
{ title: "Get Started", url: "/player" },
|
"bg-gradient-to-r from-indigo-600 to-emerald-600 px-7 hover:opacity-75 transition-all transform-gpu text-white",
|
||||||
{ title: "Documentation", url: "https://api.mcutils.xyz/swagger-ui.html" },
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Home(): JSX.Element {
|
export default function Home(): ReactElement {
|
||||||
return (
|
return (
|
||||||
<div className="text-center flex flex-col justify-center">
|
<div className="text-center flex flex-col gap-4">
|
||||||
<h1 className="text-4xl mb-2">Minecraft Utilities</h1>
|
<div className="p-2">
|
||||||
<div className="text-lg">
|
<Title
|
||||||
<p>Minecraft Utilities offers you many endpoints to get information about a minecraft server or a player.</p>
|
title="Minecraft Utilities"
|
||||||
|
subtitle={
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
Minecraft Utilities offers you many endpoints to get information about a minecraft server or a player.
|
||||||
|
</p>
|
||||||
<p>We offer you a simple and easy to use API.</p>
|
<p>We offer you a simple and easy to use API.</p>
|
||||||
</div>
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="mt-6 flex flex-row gap-2 justify-center">
|
<div className="flex flex-row gap-2 justify-center mt-4 flex-wrap">
|
||||||
{buttons.map((button, index) => {
|
{buttons.map((button, index) => {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Tooltip key={index}>
|
||||||
key={index}
|
<TooltipTrigger asChild>
|
||||||
href={button.url}
|
<Button key={index} className={button.className ? button.className : ""}>
|
||||||
target="_blank"
|
<Link href={button.url} target={button.openInNewTab ? "_blank" : ""}>
|
||||||
className="w-fit p-2 rounded-lg hover:text-primary transition-all cursor-pointer bg-secondary"
|
|
||||||
>
|
|
||||||
<p>{button.title}</p>
|
<p>{button.title}</p>
|
||||||
</Link>
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{button.tooltip}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4 items-center p-2">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="font-semibold text-xl">API Statistics</p>
|
||||||
|
<p>View the statistics for the API in real-time!</p>
|
||||||
|
</div>
|
||||||
|
<Stats />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,40 +1,49 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import { Card } from "@/app/components/card";
|
import { CopyButton } from "@/app/components/copy-button";
|
||||||
import { ErrorCard } from "@/app/components/error-card";
|
import { ErrorCard } from "@/app/components/error-card";
|
||||||
import { LookupPlayer } from "@/app/components/player/lookup-player";
|
import { LookupPlayer } from "@/app/components/player/lookup-player";
|
||||||
import { generateEmbed } from "@/common/embed";
|
import { PlayerView } from "@/app/components/player/player-view";
|
||||||
import { CachedPlayer, McUtilsAPIError, SkinPart, getPlayer } from "mcutils-library";
|
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from "@/app/components/ui/context-menu";
|
||||||
import { Metadata } from "next";
|
import { Colors } from "@/app/common/colors";
|
||||||
import Image from "next/image";
|
import { generateEmbed } from "@/app/common/embed";
|
||||||
import Link from "next/link";
|
import { isValidPlayer } from "@/app/common/player";
|
||||||
|
import config from "@root/config.json";
|
||||||
|
import { CachedPlayer, getPlayer, McUtilsAPIError } from "mcutils-library";
|
||||||
|
import { Metadata, Viewport } from "next";
|
||||||
|
import { ReactElement } from "react";
|
||||||
|
import { Title } from "@/app/components/title";
|
||||||
|
import { PlayerPageParams } from "@/app/types/player/page-params";
|
||||||
|
import { TryAPlayer } from "@/app/components/player/try-a-player";
|
||||||
|
|
||||||
type Params = {
|
export const revalidate = 60;
|
||||||
params: {
|
|
||||||
id: string;
|
export async function generateViewport({ params: { id } }: PlayerPageParams): Promise<Viewport> {
|
||||||
|
const validPlayer = await isValidPlayer(id);
|
||||||
|
return {
|
||||||
|
themeColor: validPlayer ? Colors.green : Colors.red,
|
||||||
};
|
};
|
||||||
};
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params: { id } }: Params): Promise<Metadata> {
|
export async function generateMetadata({ params: { id } }: PlayerPageParams): Promise<Metadata> {
|
||||||
try {
|
// No id provided
|
||||||
if (!id || id.length === 0) {
|
if (!id || id.length === 0) {
|
||||||
return generateEmbed({
|
return generateEmbed({
|
||||||
title: "Player Lookup",
|
title: "Player Lookup",
|
||||||
description: "Click to lookup a player.",
|
description: "Click to lookup a player.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const player = await getPlayer(id);
|
|
||||||
|
|
||||||
const { username, uniqueId, skin } = player;
|
try {
|
||||||
|
const { username, uniqueId, skin } = await getPlayer(id);
|
||||||
const headPartUrl = skin.parts.head;
|
const headPartUrl = skin.parts.head;
|
||||||
|
|
||||||
const description = `UUID: ${uniqueId}\n\nClick to view more information about the player.`;
|
|
||||||
|
|
||||||
return generateEmbed({
|
return generateEmbed({
|
||||||
title: `${username}`,
|
title: `${username}'s Profile`,
|
||||||
description: description,
|
description: `UUID: ${uniqueId}\n\nClick to view more information about the player.`,
|
||||||
image: headPartUrl,
|
image: headPartUrl,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// An error occurred
|
||||||
return generateEmbed({
|
return generateEmbed({
|
||||||
title: "Player Not Found",
|
title: "Player Not Found",
|
||||||
description: (err as McUtilsAPIError).message,
|
description: (err as McUtilsAPIError).message,
|
||||||
@ -42,7 +51,7 @@ export async function generateMetadata({ params: { id } }: Params): Promise<Meta
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function Page({ params: { id } }: Params): Promise<JSX.Element> {
|
export default async function Page({ params: { id } }: PlayerPageParams): Promise<ReactElement> {
|
||||||
let error: string | undefined = undefined; // The error to display
|
let error: string | undefined = undefined; // The error to display
|
||||||
let player: CachedPlayer | undefined = undefined; // The player to display
|
let player: CachedPlayer | undefined = undefined; // The player to display
|
||||||
|
|
||||||
@ -56,51 +65,38 @@ export default async function Page({ params: { id } }: Params): Promise<JSX.Elem
|
|||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col items-center">
|
<div className="h-full flex flex-col items-center">
|
||||||
<div className="mb-4 text-center">
|
<div className="mb-4 text-center">
|
||||||
<h1 className="text-xl">Lookup a Player</h1>
|
<Title
|
||||||
<p>You can enter a players uuid or username to get information about the player.</p>
|
title="Player Lookup"
|
||||||
|
subtitle="You can enter a players uuid or username to get information about the player."
|
||||||
|
/>
|
||||||
|
|
||||||
<LookupPlayer />
|
<LookupPlayer currentPlayer={id && id[0]} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <ErrorCard message={error} />}
|
{error && <ErrorCard message={error} />}
|
||||||
{player != undefined && (
|
{player != undefined && (
|
||||||
<Card className="w-max xs:w-fit">
|
<ContextMenu>
|
||||||
<div className="flex gap-4 flex-col xs:flex-row">
|
<ContextMenuTrigger>
|
||||||
<div className="flex justify-center xs:justify-start">
|
<PlayerView player={player} />
|
||||||
<Image
|
</ContextMenuTrigger>
|
||||||
className="w-[96px] h-[96px]"
|
<ContextMenuContent className="flex flex-col">
|
||||||
src={player.skin.parts.head}
|
<CopyButton content={player.username}>
|
||||||
width={96}
|
<ContextMenuItem>Copy Player Username</ContextMenuItem>
|
||||||
height={96}
|
</CopyButton>
|
||||||
quality={100}
|
|
||||||
alt="The player's skin"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 divide-y">
|
<CopyButton content={player.uniqueId}>
|
||||||
<div>
|
<ContextMenuItem>Copy Player UUID</ContextMenuItem>
|
||||||
<h2 className="text-xl text-primary">{player.username}</h2>
|
</CopyButton>
|
||||||
<p>{player.uniqueId}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<CopyButton content={`${config.publicUrl}/player/${id}`}>
|
||||||
<p className="text-lg mt-2">Skin Parts</p>
|
<ContextMenuItem>Copy Share URL</ContextMenuItem>
|
||||||
<div className="flex gap-2">
|
</CopyButton>
|
||||||
{Object.entries(player.skin.parts)
|
</ContextMenuContent>
|
||||||
.filter(([part]) => part !== SkinPart.HEAD) // Don't show the head part again
|
</ContextMenu>
|
||||||
.map(([part, url]) => {
|
|
||||||
return (
|
|
||||||
<Link key={part} href={url} target="_blank">
|
|
||||||
<img className="h-[64px]" src={url} alt={`The player's ${part}`} loading="lazy" />
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Try a Player */}
|
||||||
|
{player == null && !error && <TryAPlayer />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,25 +1,27 @@
|
|||||||
import { Card } from "@/app/components/card";
|
import { CopyButton } from "@/app/components/copy-button";
|
||||||
import { ErrorCard } from "@/app/components/error-card";
|
import { ErrorCard } from "@/app/components/error-card";
|
||||||
import { LookupServer } from "@/app/components/server/lookup-server";
|
import { LookupServer } from "@/app/components/server/lookup-server";
|
||||||
import { generateEmbed } from "@/common/embed";
|
import { ServerView } from "@/app/components/server/server-view";
|
||||||
import { formatNumber } from "@/common/number-utils";
|
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from "@/app/components/ui/context-menu";
|
||||||
import { capitalizeFirstLetter } from "@/common/string-utils";
|
import { Colors } from "@/app/common/colors";
|
||||||
|
import { generateEmbed } from "@/app/common/embed";
|
||||||
|
import { isValidServer } from "@/app/common/server";
|
||||||
|
import { capitalizeFirstLetter } from "@/app/common/string-utils";
|
||||||
|
import config from "@root/config.json";
|
||||||
import {
|
import {
|
||||||
CachedBedrockMinecraftServer,
|
CachedBedrockMinecraftServer,
|
||||||
CachedJavaMinecraftServer,
|
CachedJavaMinecraftServer,
|
||||||
|
getServer,
|
||||||
McUtilsAPIError,
|
McUtilsAPIError,
|
||||||
ServerPlatform,
|
ServerPlatform,
|
||||||
getServer,
|
|
||||||
} from "mcutils-library";
|
} from "mcutils-library";
|
||||||
import { Metadata } from "next";
|
import { Metadata, Viewport } from "next";
|
||||||
import Image from "next/image";
|
import { ReactElement } from "react";
|
||||||
|
import { Title } from "@/app/components/title";
|
||||||
|
import { ServerPageParams } from "@/app/types/server/page-params";
|
||||||
|
import { TryAServer } from "@/app/components/server/try-a-server";
|
||||||
|
|
||||||
type Params = {
|
export const revalidate = 60;
|
||||||
params: {
|
|
||||||
platform: ServerPlatform;
|
|
||||||
hostname: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the favicon for a server
|
* Gets the favicon for a server
|
||||||
@ -29,11 +31,11 @@ type Params = {
|
|||||||
* @returns the favicon url or null if there is no favicon
|
* @returns the favicon url or null if there is no favicon
|
||||||
*/
|
*/
|
||||||
function getFavicon(
|
function getFavicon(
|
||||||
platform: ServerPlatform,
|
platform: ServerPlatform | null,
|
||||||
server: CachedJavaMinecraftServer | CachedBedrockMinecraftServer
|
server: CachedJavaMinecraftServer | CachedBedrockMinecraftServer | undefined,
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
if (platform === ServerPlatform.Bedrock) {
|
if (server == null || platform === ServerPlatform.Bedrock) {
|
||||||
return undefined;
|
return config.apiEndpoint + "/server/icon/fallback";
|
||||||
}
|
}
|
||||||
server = server as CachedJavaMinecraftServer;
|
server = server as CachedJavaMinecraftServer;
|
||||||
return server.favicon && server.favicon.url;
|
return server.favicon && server.favicon.url;
|
||||||
@ -49,33 +51,42 @@ function checkPlatform(platform: ServerPlatform): boolean {
|
|||||||
return platform === ServerPlatform.Java || platform === ServerPlatform.Bedrock;
|
return platform === ServerPlatform.Java || platform === ServerPlatform.Bedrock;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params: { platform, hostname } }: Params): Promise<Metadata> {
|
export async function generateViewport({ params: { platform, hostname } }: ServerPageParams): Promise<Viewport> {
|
||||||
try {
|
const validPlayer = await isValidServer(platform, hostname);
|
||||||
if (checkPlatform(platform) === false) {
|
return {
|
||||||
|
themeColor: validPlayer || !platform ? Colors.green : Colors.red,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params: { platform, hostname } }: ServerPageParams): Promise<Metadata> {
|
||||||
|
if (!checkPlatform(platform)) {
|
||||||
|
// Invalid platform
|
||||||
return generateEmbed({
|
return generateEmbed({
|
||||||
title: "Server Not Found",
|
title: "Server Not Found",
|
||||||
description: "Invalid platform",
|
description: "Invalid platform",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!hostname || hostname.length === 0) {
|
if (!hostname || hostname.length === 0) {
|
||||||
|
// No hostname
|
||||||
return generateEmbed({
|
return generateEmbed({
|
||||||
title: "Server Lookup",
|
title: "Server Lookup",
|
||||||
description: `Click to lookup a ${capitalizeFirstLetter(platform)} server.`,
|
description: `Click to lookup a ${capitalizeFirstLetter(platform)} server.`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
const server = await getServer(platform, hostname);
|
const server = await getServer(platform, hostname);
|
||||||
const { hostname: serverHostname, players } = server as CachedJavaMinecraftServer | CachedBedrockMinecraftServer;
|
const { hostname: serverHostname } = server;
|
||||||
|
|
||||||
const favicon = server ? getFavicon(platform, server) : undefined;
|
|
||||||
|
|
||||||
const description = `Hostname: ${serverHostname}\n${players.online}/${players.max} players online\n\nClick to view more information about the player.`;
|
|
||||||
|
|
||||||
return generateEmbed({
|
return generateEmbed({
|
||||||
title: `${hostname}`,
|
title: `${serverHostname} ${capitalizeFirstLetter(platform)} Server`,
|
||||||
description: description,
|
embedTitle: `${capitalizeFirstLetter(platform)} Server: ${serverHostname}`,
|
||||||
image: favicon,
|
description: "Click to view more information about the server.",
|
||||||
|
image: `${config.apiEndpoint}/server/${platform}/preview/${serverHostname}`,
|
||||||
|
cardType: "summary_large_image",
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// An error occurred
|
||||||
return generateEmbed({
|
return generateEmbed({
|
||||||
title: "Server Not Found",
|
title: "Server Not Found",
|
||||||
description: (err as McUtilsAPIError).message,
|
description: (err as McUtilsAPIError).message,
|
||||||
@ -83,70 +94,59 @@ export async function generateMetadata({ params: { platform, hostname } }: Param
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function Page({ params: { platform, hostname } }: Params): Promise<JSX.Element> {
|
export default async function Page({ params: { platform, hostname } }: ServerPageParams): Promise<ReactElement> {
|
||||||
let error: string | undefined = undefined; // The error to display
|
let error: string | undefined = undefined; // The error to display
|
||||||
let server: CachedJavaMinecraftServer | CachedBedrockMinecraftServer | undefined = undefined; // The server to display
|
let server: CachedJavaMinecraftServer | CachedBedrockMinecraftServer | undefined = undefined; // The server to display
|
||||||
let invalidPlatform = checkPlatform(platform) === false; // Whether the platform is invalid
|
let invalidPlatform: boolean = !checkPlatform(platform); // Whether the platform is invalid
|
||||||
|
let favicon: string | undefined; // The server's favicon
|
||||||
|
|
||||||
// Try and get the player to display
|
|
||||||
try {
|
|
||||||
console.log(platform);
|
|
||||||
if (invalidPlatform) {
|
if (invalidPlatform) {
|
||||||
error = "Invalid platform"; // Set the error message
|
error = "Invalid platform"; // Set the error message
|
||||||
} else {
|
} else {
|
||||||
|
// Try and get the player to display
|
||||||
|
try {
|
||||||
server = platform && hostname ? await getServer(platform, hostname) : undefined;
|
server = platform && hostname ? await getServer(platform, hostname) : undefined;
|
||||||
}
|
favicon = getFavicon(platform, server);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = (err as McUtilsAPIError).message; // Set the error message
|
error = (err as McUtilsAPIError).message; // Set the error message
|
||||||
}
|
}
|
||||||
|
}
|
||||||
const favicon = server ? getFavicon(platform, server) : undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col items-center">
|
<div className="h-full flex flex-col items-center">
|
||||||
<div className="mb-4 text-center">
|
<div className="mb-4 text-center">
|
||||||
<h1 className="text-xl">Lookup a {invalidPlatform ? "" : capitalizeFirstLetter(platform)} Server</h1>
|
<Title
|
||||||
<p>You can enter a server hostname to get information about the server.</p>
|
title={`Lookup a ${invalidPlatform ? "" : capitalizeFirstLetter(platform)} Server`}
|
||||||
|
subtitle="You can enter a server hostname to get information about the server."
|
||||||
<LookupServer />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <ErrorCard message={error} />}
|
|
||||||
{server != null && (
|
|
||||||
<Card className="w-max xs:w-fit">
|
|
||||||
<div className="flex gap-4 flex-col">
|
|
||||||
<div className="flex gap-4 flex-col xs:flex-row">
|
|
||||||
{favicon && (
|
|
||||||
<div className="flex justify-center xs:justify-start">
|
|
||||||
<Image
|
|
||||||
className="w-[64px] h-[64px]"
|
|
||||||
src={favicon}
|
|
||||||
width={64}
|
|
||||||
height={64}
|
|
||||||
quality={100}
|
|
||||||
alt="The server's favicon"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-col">
|
<LookupServer currentPlatform={platform.toLowerCase()} currentServer={hostname && hostname[0]} />
|
||||||
<h2 className="text-xl text-primary">{server.hostname}</h2>
|
|
||||||
<div>
|
|
||||||
<p>
|
|
||||||
Players online: {formatNumber(server.players.online)}/{formatNumber(server.players.max)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-background rounded-lg p-2 text-sm xs:text-lg">
|
{/* An errored occurred when looking up the server */}
|
||||||
{server.motd.html.map((line, index) => {
|
{error && <ErrorCard message={error} />}
|
||||||
return <p key={index} dangerouslySetInnerHTML={{ __html: line }}></p>;
|
|
||||||
})}
|
{/* The server */}
|
||||||
</div>
|
{server != null && (
|
||||||
</div>
|
<ContextMenu>
|
||||||
</Card>
|
<ContextMenuTrigger asChild>
|
||||||
|
<ServerView server={server} favicon={favicon} />
|
||||||
|
</ContextMenuTrigger>
|
||||||
|
<ContextMenuContent className="flex flex-col">
|
||||||
|
<CopyButton content={server.hostname}>
|
||||||
|
<ContextMenuItem>Copy Server Hostname</ContextMenuItem>
|
||||||
|
</CopyButton>
|
||||||
|
{favicon && (
|
||||||
|
<CopyButton content={favicon}>
|
||||||
|
<ContextMenuItem>Copy Server Favicon URL</ContextMenuItem>
|
||||||
|
</CopyButton>
|
||||||
)}
|
)}
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Try a Server */}
|
||||||
|
{server == null && !error && <TryAServer />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
8
src/app/common/colors.ts
Normal file
8
src/app/common/colors.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Re-useable colors.
|
||||||
|
*/
|
||||||
|
export const Colors = {
|
||||||
|
green: "#0FFF50",
|
||||||
|
red: "#8B0000",
|
||||||
|
yellow: "#FFD700",
|
||||||
|
};
|
194
src/app/common/documentation.ts
Normal file
194
src/app/common/documentation.ts
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
import * as fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import Fuse from "fuse.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadata for documentation content.
|
||||||
|
*/
|
||||||
|
export type DocsContentMetadata = MDXMetadata & {
|
||||||
|
/**
|
||||||
|
* The title of this content.
|
||||||
|
*/
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The summary of this content.
|
||||||
|
*/
|
||||||
|
summary: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadata for an MDX file.
|
||||||
|
*/
|
||||||
|
type MDXMetadata = {
|
||||||
|
/**
|
||||||
|
* The slug of the file, defined once read.
|
||||||
|
*/
|
||||||
|
slug: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The metadata of the file.
|
||||||
|
*/
|
||||||
|
metadata: {
|
||||||
|
[key: string]: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The content of the file.
|
||||||
|
*/
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The regex to match for metadata.
|
||||||
|
*/
|
||||||
|
const METADATA_REGEX: RegExp = /---\s*([\s\S]*?)\s*---/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The directory of the documentation.
|
||||||
|
*/
|
||||||
|
const docsDir = path.join(process.cwd(), "documentation");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cached documentation content.
|
||||||
|
*/
|
||||||
|
const cachedDocs: DocsContentMetadata[] = getDocsContent();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fuse index for searching
|
||||||
|
*/
|
||||||
|
const fuseIndex: Fuse<DocsContentMetadata> = new Fuse(cachedDocs, {
|
||||||
|
keys: ["title", "summary"],
|
||||||
|
includeScore: true,
|
||||||
|
threshold: 0.4,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the directories in the
|
||||||
|
* given directory.
|
||||||
|
*/
|
||||||
|
function getDocsDirectories(dir: string): string[] {
|
||||||
|
const directories: string[] = [dir];
|
||||||
|
const paths: string[] = fs.readdirSync(dir);
|
||||||
|
|
||||||
|
for (const sub of paths) {
|
||||||
|
const subPath: string = path.join(dir, sub);
|
||||||
|
const stat: fs.Stats = fs.statSync(subPath);
|
||||||
|
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
directories.push(...getDocsDirectories(subPath));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return directories;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the content to
|
||||||
|
* display in the docs.
|
||||||
|
*/
|
||||||
|
export function getDocsContent(): DocsContentMetadata[] {
|
||||||
|
const directories: string[] = getDocsDirectories(docsDir);
|
||||||
|
const page: DocsContentMetadata[] = [];
|
||||||
|
|
||||||
|
for (let directory of directories) {
|
||||||
|
page.push(...getMetadata<DocsContentMetadata>(directory));
|
||||||
|
}
|
||||||
|
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the content of the
|
||||||
|
* documentation page.
|
||||||
|
*
|
||||||
|
* @param path the path to the content
|
||||||
|
*/
|
||||||
|
export function getDocContent(path?: string[]): DocsContentMetadata | undefined {
|
||||||
|
const slug: string = path ? path.join("/") : "home";
|
||||||
|
|
||||||
|
return process.env.NODE_ENV === "development"
|
||||||
|
? getDocsContent().find((doc: DocsContentMetadata) => doc.slug === slug)
|
||||||
|
: cachedDocs.find((doc: DocsContentMetadata) => doc.slug === slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search the documentation
|
||||||
|
* for the given query.
|
||||||
|
*
|
||||||
|
* @param query the query to search
|
||||||
|
* @param limit the maximum number of results
|
||||||
|
*/
|
||||||
|
export function searchDocs(
|
||||||
|
query?: string,
|
||||||
|
limit?: number,
|
||||||
|
): {
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
slug: string;
|
||||||
|
}[] {
|
||||||
|
if (!limit) {
|
||||||
|
limit = 5; // Default to 5 results
|
||||||
|
}
|
||||||
|
return fuseIndex.search(query || "", { limit }).map(result => {
|
||||||
|
return {
|
||||||
|
title: result.item.title,
|
||||||
|
summary: result.item.summary,
|
||||||
|
slug: result.item.slug,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the metadata of mdx
|
||||||
|
* files in the given directory.
|
||||||
|
*
|
||||||
|
* @param directory the directory to search
|
||||||
|
*/
|
||||||
|
export function getMetadata<T extends MDXMetadata>(directory: string): T[] {
|
||||||
|
const files: string[] = fs.readdirSync(directory).filter((file: string): boolean => {
|
||||||
|
const extension: string = path.extname(file); // The file extension
|
||||||
|
return extension === ".md" || extension === ".mdx";
|
||||||
|
}); // Read the MDX files
|
||||||
|
return files.map((file: string): T => {
|
||||||
|
const filePath: string = path.join(directory, file); // The path of the file
|
||||||
|
return {
|
||||||
|
...parseMetadata<T>(fs.readFileSync(filePath, "utf-8")),
|
||||||
|
slug: filePath
|
||||||
|
.replace(docsDir, "")
|
||||||
|
.replace(/\.mdx?$/, "")
|
||||||
|
.replace(/\\/g, "/")
|
||||||
|
.substring(1),
|
||||||
|
}; // Map each file to its metadata
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the metadata from
|
||||||
|
* the given content.
|
||||||
|
*
|
||||||
|
* @param content the content to parse
|
||||||
|
* @returns the metadata and content
|
||||||
|
* @template T the type of metadata
|
||||||
|
*/
|
||||||
|
function parseMetadata<T extends MDXMetadata>(content: string): T {
|
||||||
|
const metadataBlock: string = METADATA_REGEX.exec(content)![1]; // Get the block of metadata
|
||||||
|
content = content.replace(METADATA_REGEX, "").trim(); // Remove the metadata block from the content
|
||||||
|
let metadata: Partial<{
|
||||||
|
[key: string]: string;
|
||||||
|
}> = {}; // The metadata to return
|
||||||
|
|
||||||
|
// Parse the metadata block as a key-value pair
|
||||||
|
metadataBlock
|
||||||
|
.trim() // Trim any leading or trailing whitespace
|
||||||
|
.split("\n") // Get each line
|
||||||
|
.forEach((line: string): void => {
|
||||||
|
const split: string[] = line.split(": "); // Split the metadata by the colon
|
||||||
|
let value: string = split[1].trim(); // The value of the metadata
|
||||||
|
value = value.replace(/^['"](.*)['"]$/, "$1"); // Remove quotes
|
||||||
|
metadata[split[0].trim()] = value; // Add the metadata to the object
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return the metadata and content. The initial
|
||||||
|
// slug is empty, and is defined later on.
|
||||||
|
return { ...metadata, content } as T;
|
||||||
|
}
|
69
src/app/common/embed.ts
Normal file
69
src/app/common/embed.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import { Metadata } from "next";
|
||||||
|
|
||||||
|
type Embed = {
|
||||||
|
/**
|
||||||
|
* The title of the embed.
|
||||||
|
*/
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The title of the embed.
|
||||||
|
*/
|
||||||
|
embedTitle?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The description of the embed.
|
||||||
|
*/
|
||||||
|
description: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The image to show as the thumbmail.
|
||||||
|
*/
|
||||||
|
image?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The type of the card.
|
||||||
|
*/
|
||||||
|
cardType?: "summary" | "summary_large_image";
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates metadata for a embed.
|
||||||
|
*
|
||||||
|
* @param title the title of the embed
|
||||||
|
* @param embedTitle the title of the embed
|
||||||
|
* @param description the description of the embed
|
||||||
|
* @param image the image to show as the thumbmail
|
||||||
|
* @param cardType the type of the card
|
||||||
|
* @returns the metadata for the embed
|
||||||
|
*/
|
||||||
|
export function generateEmbed({ title, embedTitle, description, image, cardType }: Embed): Metadata {
|
||||||
|
// Fall back to the title
|
||||||
|
if (!embedTitle) {
|
||||||
|
embedTitle = title;
|
||||||
|
}
|
||||||
|
if (!cardType) {
|
||||||
|
cardType = "summary";
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata: Metadata = {
|
||||||
|
title: `${title}`,
|
||||||
|
openGraph: {
|
||||||
|
title: `${title}`,
|
||||||
|
description: description,
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: cardType,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (image) {
|
||||||
|
metadata.openGraph!.images = [
|
||||||
|
{
|
||||||
|
url: image || "",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata;
|
||||||
|
}
|
17
src/app/common/hastebin.ts
Normal file
17
src/app/common/hastebin.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
const PASTE_URL: string = "https://paste.fascinated.cc";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new haste with the given content.
|
||||||
|
*
|
||||||
|
* @param content the content to create the haste with
|
||||||
|
* @returns the URL of the created haste
|
||||||
|
*/
|
||||||
|
export async function createHaste(content: string): Promise<string> {
|
||||||
|
const response = await fetch(`${PASTE_URL}/api/upload`, {
|
||||||
|
method: "POST",
|
||||||
|
body: content,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { id } = await response.json();
|
||||||
|
return `${PASTE_URL}/${id}`;
|
||||||
|
}
|
16
src/app/common/player.ts
Normal file
16
src/app/common/player.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { getPlayer } from "mcutils-library";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the player is valid.
|
||||||
|
*
|
||||||
|
* @param id the player's id
|
||||||
|
* @returns true if valid, false otherwise
|
||||||
|
*/
|
||||||
|
export async function isValidPlayer(id: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await getPlayer(id);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
17
src/app/common/server.ts
Normal file
17
src/app/common/server.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { getServer, ServerPlatform } from "mcutils-library";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the server is valid.
|
||||||
|
*
|
||||||
|
* @param platform the server's platform
|
||||||
|
* @param query the hostname for the server
|
||||||
|
* @returns true if valid, false otherwise
|
||||||
|
*/
|
||||||
|
export async function isValidServer(platform: ServerPlatform, query: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await getServer(platform, query);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
189
src/app/common/use-toast.ts
Normal file
189
src/app/common/use-toast.ts
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Inspired by react-hot-toast library
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import type { ToastActionElement, ToastProps } from "@/app/components/ui/toast";
|
||||||
|
|
||||||
|
const TOAST_LIMIT = 1;
|
||||||
|
const TOAST_REMOVE_DELAY = 1000000;
|
||||||
|
|
||||||
|
type ToasterToast = ToastProps & {
|
||||||
|
id: string;
|
||||||
|
title?: React.ReactNode;
|
||||||
|
description?: React.ReactNode;
|
||||||
|
action?: ToastActionElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionTypes = {
|
||||||
|
ADD_TOAST: "ADD_TOAST",
|
||||||
|
UPDATE_TOAST: "UPDATE_TOAST",
|
||||||
|
DISMISS_TOAST: "DISMISS_TOAST",
|
||||||
|
REMOVE_TOAST: "REMOVE_TOAST",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
function genId() {
|
||||||
|
count = (count + 1) % Number.MAX_SAFE_INTEGER;
|
||||||
|
return count.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActionType = typeof actionTypes;
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| {
|
||||||
|
type: ActionType["ADD_TOAST"];
|
||||||
|
toast: ToasterToast;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType["UPDATE_TOAST"];
|
||||||
|
toast: Partial<ToasterToast>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType["DISMISS_TOAST"];
|
||||||
|
toastId?: ToasterToast["id"];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType["REMOVE_TOAST"];
|
||||||
|
toastId?: ToasterToast["id"];
|
||||||
|
};
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
toasts: ToasterToast[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
const addToRemoveQueue = (toastId: string) => {
|
||||||
|
if (toastTimeouts.has(toastId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
toastTimeouts.delete(toastId);
|
||||||
|
dispatch({
|
||||||
|
type: "REMOVE_TOAST",
|
||||||
|
toastId: toastId,
|
||||||
|
});
|
||||||
|
}, TOAST_REMOVE_DELAY);
|
||||||
|
|
||||||
|
toastTimeouts.set(toastId, timeout);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const reducer = (state: State, action: Action): State => {
|
||||||
|
switch (action.type) {
|
||||||
|
case "ADD_TOAST":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||||
|
};
|
||||||
|
|
||||||
|
case "UPDATE_TOAST":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.map(t => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
|
||||||
|
};
|
||||||
|
|
||||||
|
case "DISMISS_TOAST": {
|
||||||
|
const { toastId } = action;
|
||||||
|
|
||||||
|
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||||
|
// but I'll keep it here for simplicity
|
||||||
|
if (toastId) {
|
||||||
|
addToRemoveQueue(toastId);
|
||||||
|
} else {
|
||||||
|
state.toasts.forEach(toast => {
|
||||||
|
addToRemoveQueue(toast.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.map(t =>
|
||||||
|
t.id === toastId || toastId === undefined
|
||||||
|
? {
|
||||||
|
...t,
|
||||||
|
open: false,
|
||||||
|
}
|
||||||
|
: t,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "REMOVE_TOAST":
|
||||||
|
if (action.toastId === undefined) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.filter(t => t.id !== action.toastId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const listeners: Array<(state: State) => void> = [];
|
||||||
|
|
||||||
|
let memoryState: State = { toasts: [] };
|
||||||
|
|
||||||
|
function dispatch(action: Action) {
|
||||||
|
memoryState = reducer(memoryState, action);
|
||||||
|
listeners.forEach(listener => {
|
||||||
|
listener(memoryState);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type Toast = Omit<ToasterToast, "id">;
|
||||||
|
|
||||||
|
function toast({ ...props }: Toast) {
|
||||||
|
const id = genId();
|
||||||
|
|
||||||
|
const update = (props: ToasterToast) =>
|
||||||
|
dispatch({
|
||||||
|
type: "UPDATE_TOAST",
|
||||||
|
toast: { ...props, id },
|
||||||
|
});
|
||||||
|
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: "ADD_TOAST",
|
||||||
|
toast: {
|
||||||
|
...props,
|
||||||
|
id,
|
||||||
|
open: true,
|
||||||
|
onOpenChange: open => {
|
||||||
|
if (!open) dismiss();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: id,
|
||||||
|
dismiss,
|
||||||
|
update,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function useToast() {
|
||||||
|
const [state, setState] = React.useState<State>(memoryState);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
listeners.push(setState);
|
||||||
|
return () => {
|
||||||
|
const index = listeners.indexOf(setState);
|
||||||
|
if (index > -1) {
|
||||||
|
listeners.splice(index, 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toast,
|
||||||
|
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useToast, toast };
|
@ -1,4 +1,4 @@
|
|||||||
import { clsx, type ClassValue } from "clsx";
|
import { type ClassValue, clsx } from "clsx";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
/**
|
/**
|
24
src/app/components/cache-information.tsx
Normal file
24
src/app/components/cache-information.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { Cache } from "mcutils-library";
|
||||||
|
import React, { ReactElement, ReactNode } from "react";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/app/components/ui/popover";
|
||||||
|
import moment from "moment";
|
||||||
|
|
||||||
|
type CacheInformationProps = {
|
||||||
|
cache: Cache;
|
||||||
|
children?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CacheInformation({ cache, children }: CacheInformationProps): ReactElement {
|
||||||
|
const isCached = cache.cached;
|
||||||
|
const cacheTime = cache.cachedTime;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>{children}</PopoverTrigger>
|
||||||
|
<PopoverContent>
|
||||||
|
<p className={isCached ? "text-green-400" : "text-red-400"}>{isCached ? "Cached" : "Not Cached"}</p>
|
||||||
|
{cacheTime !== -1 && <p>{moment(cacheTime).calendar()}</p>}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
@ -1,11 +1,29 @@
|
|||||||
import { cn } from "@/common/utils";
|
import { Card as ShadcnCard, CardContent } from "@/app/components/ui/card";
|
||||||
|
import { cn } from "@/app/common/utils";
|
||||||
|
import { ReactElement } from "react";
|
||||||
|
import { type ClassValue } from "clsx";
|
||||||
|
|
||||||
export function Card({
|
type CardProps = {
|
||||||
children,
|
/**
|
||||||
className,
|
* The children for this element.
|
||||||
}: Readonly<{
|
*/
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
|
||||||
}>): JSX.Element {
|
/**
|
||||||
return <div className={cn("bg-secondary rounded-lg p-3", className)}>{children}</div>;
|
* The class names for the card.
|
||||||
|
*/
|
||||||
|
classNameCard?: ClassValue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The class names for the content.
|
||||||
|
*/
|
||||||
|
classNameContent?: ClassValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Card({ children, classNameCard, classNameContent }: CardProps): ReactElement {
|
||||||
|
return (
|
||||||
|
<ShadcnCard className={cn("p-1.5", classNameCard)}>
|
||||||
|
<CardContent className={cn("p-1.5", classNameContent)}>{children}</CardContent>
|
||||||
|
</ShadcnCard>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
52
src/app/components/code-dialog.tsx
Normal file
52
src/app/components/code-dialog.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { ReactElement } from "react";
|
||||||
|
import { CodeHighlighter } from "./code-highlighter";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "./ui/dialog";
|
||||||
|
import { CreateHasteButton } from "@/app/components/create-haste-button";
|
||||||
|
|
||||||
|
type CodeDialogProps = {
|
||||||
|
/**
|
||||||
|
* The title of the dialog.
|
||||||
|
*/
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The description of the dialog.
|
||||||
|
*/
|
||||||
|
description: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The code to show in the dialog.
|
||||||
|
*/
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The children for this element.
|
||||||
|
*/
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CodeDialog({ title, description, code, children }: CodeDialogProps): ReactElement {
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||||
|
<DialogContent className="text-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<CodeHighlighter code={code} />
|
||||||
|
<DialogFooter>
|
||||||
|
<CreateHasteButton content={code} />
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
92
src/app/components/code-highlighter.tsx
Normal file
92
src/app/components/code-highlighter.tsx
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import { ReactElement } from "react";
|
||||||
|
import SyntaxHighlighter from "react-syntax-highlighter";
|
||||||
|
import createElement from "react-syntax-highlighter/dist/esm/create-element";
|
||||||
|
import { atomOneDark } from "react-syntax-highlighter/dist/esm/styles/hljs";
|
||||||
|
import { cn } from "@/app/common/utils";
|
||||||
|
import { capitalizeFirstLetter } from "@/app/common/string-utils";
|
||||||
|
|
||||||
|
type CodeHighlighterProps = {
|
||||||
|
/**
|
||||||
|
* The code to highlight.
|
||||||
|
*/
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The language of the code.
|
||||||
|
*/
|
||||||
|
language?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Should the element be rounded?
|
||||||
|
*/
|
||||||
|
rounded?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the rows with the ability to render links.
|
||||||
|
*
|
||||||
|
* @param rows the rows to render
|
||||||
|
* @param stylesheet the stylesheet to use
|
||||||
|
* @param useInlineStyles should inline styles be used
|
||||||
|
* @returns the rendered rows
|
||||||
|
*/
|
||||||
|
function rowRenderer({
|
||||||
|
rows,
|
||||||
|
stylesheet,
|
||||||
|
useInlineStyles,
|
||||||
|
}: {
|
||||||
|
rows: any;
|
||||||
|
stylesheet: { [key: string]: React.CSSProperties };
|
||||||
|
useInlineStyles: boolean;
|
||||||
|
}) {
|
||||||
|
return rows.map((node: any, i: number) => {
|
||||||
|
node.children = node.children.map((children: any) => {
|
||||||
|
const text = children?.children?.[0]?.value;
|
||||||
|
|
||||||
|
if (typeof text === "string" && text.startsWith('"http')) {
|
||||||
|
return {
|
||||||
|
...children,
|
||||||
|
tagName: "a",
|
||||||
|
properties: {
|
||||||
|
...children.properties,
|
||||||
|
href: text.slice(1, -1), // in JSON strings are enclosed with ", they need to be removed
|
||||||
|
target: "_blank",
|
||||||
|
// Tailwind CSS classes
|
||||||
|
class: "underline !text-primary hover:!text-muted-foreground transition-all",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
});
|
||||||
|
|
||||||
|
return createElement({
|
||||||
|
node,
|
||||||
|
stylesheet,
|
||||||
|
useInlineStyles,
|
||||||
|
key: `code-segement${i}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CodeHighlighter({ code, language = "json", rounded = true }: CodeHighlighterProps): ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="text-xs md:text-md relative">
|
||||||
|
{/* Language */}
|
||||||
|
<div className="absolute top-0 right-0 p-1 bg-muted rounded-bl-md rounded-tr-md">
|
||||||
|
<span className="text-xs text-muted-foreground">{capitalizeFirstLetter(language)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Code */}
|
||||||
|
<SyntaxHighlighter
|
||||||
|
className={cn("max-h-[600px] !bg-secondary break-all rounded-md", rounded && "rounded-md")}
|
||||||
|
language={language}
|
||||||
|
style={atomOneDark}
|
||||||
|
wrapLongLines
|
||||||
|
renderer={rowRenderer}
|
||||||
|
>
|
||||||
|
{code}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
117
src/app/components/command-menu.tsx
Normal file
117
src/app/components/command-menu.tsx
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { ReactElement, useState } from "react";
|
||||||
|
import { DocsContentMetadata } from "@/app/common/documentation";
|
||||||
|
import {
|
||||||
|
CommandDialog,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/app/components/ui/command";
|
||||||
|
import { Button, ButtonProps } from "@/app/components/ui/button";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { cn } from "@/app/common/utils";
|
||||||
|
|
||||||
|
export function CommandMenu({ ...props }: ButtonProps): ReactElement {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to show the search
|
||||||
|
*/
|
||||||
|
const [open, setOpen] = useState<boolean>(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pages that were found
|
||||||
|
*/
|
||||||
|
const [pages, setPages] = useState<DocsContentMetadata[] | undefined>(undefined);
|
||||||
|
|
||||||
|
// Handle keyboard shortcuts
|
||||||
|
React.useEffect(() => {
|
||||||
|
const down = (e: KeyboardEvent) => {
|
||||||
|
if ((e.key === "k" && (e.metaKey || e.ctrlKey)) || e.key === "/") {
|
||||||
|
if (
|
||||||
|
(e.target instanceof HTMLElement && e.target.isContentEditable) ||
|
||||||
|
e.target instanceof HTMLInputElement ||
|
||||||
|
e.target instanceof HTMLTextAreaElement ||
|
||||||
|
e.target instanceof HTMLSelectElement
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
setOpen(open => !open);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("keydown", down);
|
||||||
|
return () => document.removeEventListener("keydown", down);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search the documentation
|
||||||
|
* for the given query.
|
||||||
|
*
|
||||||
|
* @param query the query to search for
|
||||||
|
*/
|
||||||
|
async function searchDocs(query: string): Promise<void> {
|
||||||
|
// Don't bother searching if the query is less than 3 characters
|
||||||
|
if (query.length < 3) {
|
||||||
|
setPages(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt to search for the query
|
||||||
|
const response = await fetch(`/api/docs/search?query=${query}`);
|
||||||
|
const pages: DocsContentMetadata[] = await response.json();
|
||||||
|
setPages(pages);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"relative h-8 w-full justify-start rounded-[0.5rem] bg-background text-sm font-normal text-muted-foreground shadow-none sm:pr-12 md:w-40 lg:w-64",
|
||||||
|
props.className,
|
||||||
|
)}
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<CommandDialog open={open} onOpenChange={setOpen}>
|
||||||
|
<CommandInput
|
||||||
|
placeholder="Query..."
|
||||||
|
onValueChange={async search => {
|
||||||
|
await searchDocs(search);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No results found.</CommandEmpty>
|
||||||
|
{pages && pages.length > 1 && (
|
||||||
|
<CommandGroup heading="Suggestions">
|
||||||
|
{pages.map(page => {
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
key={page.slug}
|
||||||
|
onSelect={() => {
|
||||||
|
router.push(`/docs/${page.slug}`); // Go to the page
|
||||||
|
setOpen(false); // Close the dialog
|
||||||
|
setPages(undefined); // Clear the pages
|
||||||
|
}}
|
||||||
|
className="flex flex-col items-start gap-1"
|
||||||
|
>
|
||||||
|
<p className="font-semibold text-primary">{page.title}</p>
|
||||||
|
<p>{page.summary}</p>
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
)}
|
||||||
|
</CommandList>
|
||||||
|
</CommandDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -1,14 +1,18 @@
|
|||||||
|
import { ReactElement } from "react";
|
||||||
import NavBar from "./navbar";
|
import NavBar from "./navbar";
|
||||||
|
|
||||||
export default function Container({
|
type ContainerProps = {
|
||||||
children,
|
/**
|
||||||
}: Readonly<{
|
* The children for this element.
|
||||||
|
*/
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>): JSX.Element {
|
};
|
||||||
|
|
||||||
|
export default function Container({ children }: ContainerProps): ReactElement {
|
||||||
return (
|
return (
|
||||||
<div className="z-[9999] m-auto flex h-screen min-h-full flex-col items-center opacity-90 xs:max-w-[1200px]">
|
<div className="z-[9999] m-auto flex h-screen flex-col items-center opacity-90 w-full xs:max-w-[1200px]">
|
||||||
<NavBar />
|
<NavBar />
|
||||||
<div className="w-full flex-1 p-4">{children}</div>
|
<div className="w-full flex mt-4 justify-center h-full">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
51
src/app/components/copy-button.tsx
Normal file
51
src/app/components/copy-button.tsx
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useToast } from "@/app/common/use-toast";
|
||||||
|
import copy from "clipboard-copy";
|
||||||
|
import { ReactElement } from "react";
|
||||||
|
|
||||||
|
type CopyButtonProps = {
|
||||||
|
/**
|
||||||
|
* The content to copy to the clipboard.
|
||||||
|
*/
|
||||||
|
content: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The message to display when the content is copied.
|
||||||
|
*/
|
||||||
|
message?: string | boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The children for this element.
|
||||||
|
*/
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A button that copies the content to the clipboard
|
||||||
|
*
|
||||||
|
* @param props the properties for the button
|
||||||
|
* @returns the copy button
|
||||||
|
*/
|
||||||
|
export function CopyButton({ content, message, children }: CopyButtonProps): ReactElement {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
await copy(content);
|
||||||
|
toast({
|
||||||
|
title: "Copied!",
|
||||||
|
description: (
|
||||||
|
<p>
|
||||||
|
Copied <b>{!message ? content : message}</b> to your clipboard.
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
21
src/app/components/create-haste-button.tsx
Normal file
21
src/app/components/create-haste-button.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ReactElement } from "react";
|
||||||
|
import { Button } from "@/app/components/ui/button";
|
||||||
|
import { CreateHasteButtonProps } from "@/app/types/create-haste-button";
|
||||||
|
import { createHaste } from "@/app/common/hastebin";
|
||||||
|
|
||||||
|
export function CreateHasteButton({ content }: CreateHasteButtonProps): ReactElement {
|
||||||
|
/**
|
||||||
|
* Uploads the content to Haste and opens the URL in a new tab.
|
||||||
|
*/
|
||||||
|
async function upload(): Promise<void> {
|
||||||
|
const url = await createHaste(content);
|
||||||
|
console.log(url);
|
||||||
|
|
||||||
|
// Open the URL in a new tab.
|
||||||
|
window.open(url, "_blank", "noopener,noreferrer");
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Button onClick={() => upload()}>Create Haste</Button>;
|
||||||
|
}
|
48
src/app/components/docs/breadcrumb.tsx
Normal file
48
src/app/components/docs/breadcrumb.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { ReactElement } from "react";
|
||||||
|
import {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
} from "@/app/components/ui/breadcrumb";
|
||||||
|
import { capitalizeFirstLetter } from "@/app/common/string-utils";
|
||||||
|
import { DocsContentMetadata } from "@/app/common/documentation";
|
||||||
|
|
||||||
|
type DocsBreadcrumbProps = {
|
||||||
|
/**
|
||||||
|
* The page to render the breadcrumb for.
|
||||||
|
*/
|
||||||
|
page: DocsContentMetadata;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DocsBreadcrumb({ page }: DocsBreadcrumbProps): ReactElement {
|
||||||
|
const slugParts: string[] = page.slug.split("/");
|
||||||
|
const isHome: boolean = slugParts.length == 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Breadcrumb>
|
||||||
|
<BreadcrumbList>
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<BreadcrumbLink href={`/docs`}>Home</BreadcrumbLink>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
{!isHome &&
|
||||||
|
slugParts.map((slug, index, array) => {
|
||||||
|
const path: string = array.slice(0, index + 1).join("/");
|
||||||
|
const name: string = slug.includes("-")
|
||||||
|
? slug.split("-").map(capitalizeFirstLetter).join(" ")
|
||||||
|
: capitalizeFirstLetter(slug);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={slug} className="flex items-center ">
|
||||||
|
<BreadcrumbSeparator className="pr-1.5" />
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<BreadcrumbLink href={`/docs/${path}`}>{capitalizeFirstLetter(name)}</BreadcrumbLink>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</BreadcrumbList>
|
||||||
|
</Breadcrumb>
|
||||||
|
);
|
||||||
|
}
|
22
src/app/components/docs/github-link.tsx
Normal file
22
src/app/components/docs/github-link.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { DocsContentMetadata } from "@/app/common/documentation";
|
||||||
|
import { ReactElement } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
type GithubLink = {
|
||||||
|
/**
|
||||||
|
* The page to link to.
|
||||||
|
*/
|
||||||
|
page: DocsContentMetadata;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function GithubLink({ page }: GithubLink): ReactElement {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`https://git.fascinated.cc/MinecraftUtilities/Frontend/src/branch/master/documentation/${page.slug}.md`}
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<Image src="/media/github.png" alt="The GitHub logo" width={32} height={32} className="filter dark:invert" />
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
@ -1,10 +1,14 @@
|
|||||||
|
import { ReactElement } from "react";
|
||||||
import { Card } from "./card";
|
import { Card } from "./card";
|
||||||
|
|
||||||
type ErrorProps = {
|
type ErrorProps = {
|
||||||
|
/**
|
||||||
|
* The message to show.
|
||||||
|
*/
|
||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ErrorCard({ message }: ErrorProps): JSX.Element {
|
export function ErrorCard({ message }: ErrorProps): ReactElement {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<div className="flex flex-col justify-center text-center">
|
<div className="flex flex-col justify-center text-center">
|
||||||
|
42
src/app/components/github-star.tsx
Normal file
42
src/app/components/github-star.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ReactElement, useEffect, useState } from "react";
|
||||||
|
import { Star } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { cn } from "@/app/common/utils";
|
||||||
|
|
||||||
|
type GithubStarProps = {
|
||||||
|
/**
|
||||||
|
* The class name for this component.
|
||||||
|
*/
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function GithubStar({ className }: GithubStarProps): ReactElement {
|
||||||
|
const [starCount, setStarCount] = useState(0);
|
||||||
|
|
||||||
|
const getStarCount = async () => {
|
||||||
|
const res = await fetch("https://api.github.com/repos/RealFascinated/MinecraftUtilities");
|
||||||
|
const data = await res.json();
|
||||||
|
return data.stargazers_count;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getStarCount().then(setStarCount);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
className={cn(
|
||||||
|
"bg-github-green px-2 py-1 rounded-lg items-center gap-1 hover:opacity-85 transform-gpu transition-all hidden md:flex",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
href="https://github.com/RealFascinated/MinecraftUtilities"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<p className="text-white text-sm bg-github-green-accent py-[3px] px-[4px] rounded-lg leading-none">{starCount}</p>
|
||||||
|
<Star size={18} />
|
||||||
|
<p className="text-white text-sm leading-none">Star us!</p>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
36
src/app/components/href-button.tsx
Normal file
36
src/app/components/href-button.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { cn } from "@/app/common/utils";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ReactElement } from "react";
|
||||||
|
|
||||||
|
type ButtonProps = {
|
||||||
|
/**
|
||||||
|
* The title of the button.
|
||||||
|
*/
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The URL to go to.
|
||||||
|
*/
|
||||||
|
url: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether clicking the button will
|
||||||
|
* open the link in a new tab.
|
||||||
|
*/
|
||||||
|
openInNewTab?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The class names for the button.
|
||||||
|
*/
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function HrefButton({ title, url, openInNewTab, className }: ButtonProps): ReactElement {
|
||||||
|
return (
|
||||||
|
<div className={cn("w-fit", className)}>
|
||||||
|
<Link href={url} target={openInNewTab ? "_blank" : ""}>
|
||||||
|
<p className="hover:text-primary transition-all">{title}</p>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -1,11 +0,0 @@
|
|||||||
export type IconProps = {
|
|
||||||
/**
|
|
||||||
* The size of the icon
|
|
||||||
*/
|
|
||||||
size?: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The color of the icon
|
|
||||||
*/
|
|
||||||
color?: string;
|
|
||||||
};
|
|
@ -1,9 +0,0 @@
|
|||||||
import { IconProps } from "./icon-props";
|
|
||||||
|
|
||||||
export function MoonIcon({ size = 24, color = "#fff" }: IconProps): JSX.Element {
|
|
||||||
return (
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} fill={color} viewBox="0 -960 960 960">
|
|
||||||
<path d="M480-120q-150 0-255-105T120-480q0-150 105-255t255-105q14 0 27.5 1t26.5 3q-41 29-65.5 75.5T444-660q0 90 63 153t153 63q55 0 101-24.5t75-65.5q2 13 3 26.5t1 27.5q0 150-105 255T480-120Zm0-80q88 0 158-48.5T740-375q-20 5-40 8t-40 3q-123 0-209.5-86.5T364-660q0-20 3-40t8-40q-78 32-126.5 102T200-480q0 116 82 198t198 82Zm-10-270Z" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,9 +0,0 @@
|
|||||||
import { IconProps } from "./icon-props";
|
|
||||||
|
|
||||||
export function SunIcon({ size = 24, color = "#fff" }: IconProps): JSX.Element {
|
|
||||||
return (
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} fill={color} viewBox="0 -960 960 960">
|
|
||||||
<path d="M480-360q50 0 85-35t35-85q0-50-35-85t-85-35q-50 0-85 35t-35 85q0 50 35 85t85 35Zm0 80q-83 0-141.5-58.5T280-480q0-83 58.5-141.5T480-680q83 0 141.5 58.5T680-480q0 83-58.5 141.5T480-280ZM200-440H40v-80h160v80Zm720 0H760v-80h160v80ZM440-760v-160h80v160h-80Zm0 720v-160h80v160h-80ZM256-650l-101-97 57-59 96 100-52 56Zm492 496-97-101 53-55 101 97-57 59Zm-98-550 97-101 59 57-100 96-56-52ZM154-212l101-97 55 53-97 101-59-57Zm326-268Z" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,9 +1,17 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import { ReactElement } from "react";
|
||||||
|
|
||||||
export default function Logo({ size = 30 }: Readonly<{ size?: number }>): JSX.Element {
|
type LogoProps = {
|
||||||
|
/**
|
||||||
|
* The size the logo will be.
|
||||||
|
*/
|
||||||
|
size?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Logo({ size = 30 }: LogoProps): ReactElement {
|
||||||
return (
|
return (
|
||||||
<Image
|
<Image
|
||||||
src="https://git.fascinated.cc/MinecraftUtilities/Assets/raw/branch/master/logo.png"
|
src={`/media/logo.png`}
|
||||||
alt={"The Logo"}
|
alt={"The Logo"}
|
||||||
width={size}
|
width={size}
|
||||||
height={size}
|
height={size}
|
||||||
|
44
src/app/components/mdx-components.tsx
Normal file
44
src/app/components/mdx-components.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { MDXRemote } from "remote-mdx/rsc";
|
||||||
|
import { ReactElement } from "react";
|
||||||
|
import {
|
||||||
|
formatCode,
|
||||||
|
formatHeading,
|
||||||
|
formatLink,
|
||||||
|
formatList,
|
||||||
|
formatTable,
|
||||||
|
formatTableData,
|
||||||
|
formatTableHeader,
|
||||||
|
} from "@/app/components/mdx-renderer";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The components to use in the MDX renderer.
|
||||||
|
*/
|
||||||
|
const components = {
|
||||||
|
h1: (props: any) => formatHeading(1, props),
|
||||||
|
h2: (props: any) => formatHeading(2, props),
|
||||||
|
h3: (props: any) => formatHeading(3, props),
|
||||||
|
h4: (props: any) => formatHeading(4, props),
|
||||||
|
h5: (props: any) => formatHeading(5, props),
|
||||||
|
h6: (props: any) => formatHeading(6, props),
|
||||||
|
code: (props: any) => formatCode(props),
|
||||||
|
ul: (props: any) => formatList(props),
|
||||||
|
a: (props: any) => formatLink(props),
|
||||||
|
table: (props: any) => formatTable(props),
|
||||||
|
th: (props: any) => formatTableHeader(props),
|
||||||
|
td: (props: any) => formatTableData(props),
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CustomMDX(props: any): ReactElement {
|
||||||
|
return (
|
||||||
|
<MDXRemote
|
||||||
|
{...props}
|
||||||
|
components={{ ...components, ...(props.components || {}) }}
|
||||||
|
options={{
|
||||||
|
mdxOptions: {
|
||||||
|
remarkPlugins: [remarkGfm],
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
90
src/app/components/mdx-renderer.tsx
Normal file
90
src/app/components/mdx-renderer.tsx
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import { CodeHighlighter } from "@/app/components/code-highlighter";
|
||||||
|
import { Separator } from "@/app/components/ui/separator";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ReactElement } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a heading component.
|
||||||
|
*
|
||||||
|
* @param level The level of the heading.
|
||||||
|
* @param props The props to pass to the heading.
|
||||||
|
*/
|
||||||
|
export function formatHeading(level: number, props: any): ReactElement {
|
||||||
|
const Tag = `h${level}`;
|
||||||
|
const paddingBottom = level > 1 ? "pt-6" : "";
|
||||||
|
const textSize = 4 - level;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`pb-4 ${paddingBottom}`}>
|
||||||
|
<Tag className={`text-${textSize}xl font-semibold pb-2`} {...props} />
|
||||||
|
<Separator />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a code block.
|
||||||
|
*
|
||||||
|
* @param props The props to pass to the code block.
|
||||||
|
*/
|
||||||
|
export function formatCode(props: any): ReactElement {
|
||||||
|
if (!props.className) {
|
||||||
|
return <code className="text-xs bg-secondary p-1 rounded-md leading-none" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const language = props.className.replace("language-", "");
|
||||||
|
return (
|
||||||
|
<div className="pt-4">
|
||||||
|
<CodeHighlighter language={props.className ? language : undefined} code={props.children} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a list.
|
||||||
|
*
|
||||||
|
* @param props The props to pass to the list.
|
||||||
|
*/
|
||||||
|
export function formatList(props: any): ReactElement {
|
||||||
|
return <ul className="list-disc pl-4 ml-2 pt-2">{props.children}</ul>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a link.
|
||||||
|
*
|
||||||
|
* @param props The props to pass to the link.
|
||||||
|
*/
|
||||||
|
export function formatLink(props: any): ReactElement {
|
||||||
|
return (
|
||||||
|
<Link href={props.href} className="text-primary hover:opacity-85 transition-all transform-gpu">
|
||||||
|
{props.children}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a table.
|
||||||
|
*
|
||||||
|
* @param props The props to pass to the table.
|
||||||
|
*/
|
||||||
|
export function formatTable(props: any): ReactElement {
|
||||||
|
return <table className="table-auto divide-y divide-gray-200 mt-4">{props.children}</table>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format table header.
|
||||||
|
*
|
||||||
|
* @param props The props to pass to the table header.
|
||||||
|
*/
|
||||||
|
export function formatTableHeader(props: any): ReactElement {
|
||||||
|
return <th className="border-border border p-1.5">{props.children}</th>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format table data.
|
||||||
|
*
|
||||||
|
* @param props The props to pass to the table data.
|
||||||
|
*/
|
||||||
|
export function formatTableData(props: any): ReactElement {
|
||||||
|
return <td className="border-border border p-1.5">{props.children}</td>;
|
||||||
|
}
|
@ -1,42 +1,90 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { ReactElement } from "react";
|
||||||
|
import { HrefButton } from "./href-button";
|
||||||
import Logo from "./logo";
|
import Logo from "./logo";
|
||||||
import { RedirectButton } from "./rediect-button";
|
|
||||||
import { ToggleThemeButton } from "./theme-toggle-button";
|
import { ToggleThemeButton } from "./theme-toggle-button";
|
||||||
|
import { GithubStar } from "@/app/components/github-star";
|
||||||
|
import { Card } from "@/app/components/card";
|
||||||
|
import { cn } from "@/app/common/utils";
|
||||||
|
import { CommandMenu } from "@/app/components/command-menu";
|
||||||
|
|
||||||
type Page = {
|
type Page = {
|
||||||
title: string;
|
/**
|
||||||
|
* The name of the button for the navbar.
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The URL to go to.
|
||||||
|
*/
|
||||||
url: string;
|
url: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to hide the button on mobile.
|
||||||
|
*/
|
||||||
|
hideOnMobile?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether clicking the button will
|
||||||
|
* open the link in a new tab.
|
||||||
|
*/
|
||||||
|
openInNewTab?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const pages: Page[] = [
|
const pages: Page[] = [
|
||||||
{ title: "Player", url: "/player" },
|
{ name: "Player", url: "/player" },
|
||||||
{ title: "Server", url: "/server/java" },
|
{ name: "Server", url: "/server/java" },
|
||||||
{ title: "Mojang", url: "/mojang" },
|
{ name: "Mojang", url: "/mojang/status" },
|
||||||
|
{ name: "API", url: "https://api.mcutils.xyz", hideOnMobile: true, openInNewTab: true },
|
||||||
|
{ name: "Docs", url: "/docs" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function NavBar(): JSX.Element {
|
export default function NavBar(): ReactElement {
|
||||||
|
const path: string = usePathname();
|
||||||
|
const isDocs: boolean = path ? path.includes("/docs") : false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-secondary w-full rounded-lg flex items-center gap-3 mt-2 bg-opacity-85 h-12">
|
<Card
|
||||||
<Link href="/" className="flex items-center gap-2 pl-3 pr-1">
|
classNameCard="w-full p-0 mt-2 border-none"
|
||||||
|
classNameContent="p-0 relative rounded-lg flex justify-between items-center gap-3 px-3 bg-opacity-85 h-12"
|
||||||
|
>
|
||||||
|
{/* Left */}
|
||||||
|
<div className={cn("flex flex-row items-center gap-2 z-50", isDocs ? "w-full md:w-fit" : "w-fit")}>
|
||||||
|
<Link href="/" className="flex items-center gap-2">
|
||||||
<Logo />
|
<Logo />
|
||||||
<p className="hidden xs:block">Minecraft Utilities</p>
|
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{/* Command Menu */}
|
||||||
|
<CommandMenu className={cn(isDocs ? "" : "hidden md:inline-flex")} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Links */}
|
||||||
|
<div className={cn("absolute inset-x-0 justify-center", isDocs ? "hidden md:flex" : "flex")}>
|
||||||
|
<div className="flex gap-4">
|
||||||
{pages.map((page, index) => {
|
{pages.map((page, index) => {
|
||||||
return <RedirectButton key={index} title={page.title} url={page.url} />;
|
const isActive: boolean = path ? path.includes(page.url) : false;
|
||||||
})}
|
|
||||||
|
|
||||||
<div className="flex-grow"></div>
|
return (
|
||||||
|
<HrefButton
|
||||||
<div className="mr-4 flex items-center gap-2">
|
className={cn(isActive ? "text-primary" : "", page.hideOnMobile ? "hidden md:block" : "")}
|
||||||
<div className="hidden md:block">
|
key={index}
|
||||||
<RedirectButton
|
title={page.name}
|
||||||
title="Star us on Github!"
|
url={page.url}
|
||||||
url="https://github.com/RealFascinated/minecraft-helper"
|
openInNewTab={page.openInNewTab}
|
||||||
openInNewTab
|
|
||||||
/>
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right */}
|
||||||
|
<div className="flex gap-4 items-center z-50">
|
||||||
<ToggleThemeButton />
|
<ToggleThemeButton />
|
||||||
|
<GithubStar className={isDocs ? "hidden md:flex" : "hidden"} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,38 +1,75 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useToast } from "@/app/common/use-toast";
|
||||||
|
import { getPlayer } from "mcutils-library";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { ReactElement, useState } from "react";
|
||||||
|
import ScaleLoader from "react-spinners/ScaleLoader";
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
import { Input } from "../ui/input";
|
import { Input } from "../ui/input";
|
||||||
|
import { Label } from "../ui/label";
|
||||||
|
|
||||||
export function LookupPlayer(): JSX.Element {
|
type PlayerLookupProps = {
|
||||||
|
/**
|
||||||
|
* The last displayed player.
|
||||||
|
*/
|
||||||
|
currentPlayer: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LookupPlayer({ currentPlayer }: PlayerLookupProps): ReactElement {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [player, setPlayer] = useState("");
|
const { toast } = useToast();
|
||||||
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the player value
|
* Lookup a server based on the platform
|
||||||
*
|
*
|
||||||
* @param event the input event
|
* @param query the query to lookup
|
||||||
*/
|
*/
|
||||||
const setPlayerValue = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const lookupPlayer = async (query: string) => {
|
||||||
setPlayer(event.target.value);
|
if (!query || query.length === 0) {
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lookup a player
|
|
||||||
*/
|
|
||||||
const lookupPlayer = () => {
|
|
||||||
if (!player || player.length === 0) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.push(`/player/${player}`);
|
|
||||||
|
// Ignore the same player
|
||||||
|
if (currentPlayer !== undefined && query.toLowerCase() == currentPlayer.toLowerCase()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const player = await getPlayer(query);
|
||||||
|
|
||||||
|
router.push(`/player/${player.username}`);
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
variant: "destructive",
|
||||||
|
description: (err as Error).message,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="flex gap-2 justify-center mt-2">
|
<form
|
||||||
<Input className="w-fit" placeholder="Name / UUID" value={player} onChange={setPlayerValue} maxLength={36} />
|
className="flex flex-col gap-2 justify-center items-center mt-4 flex-wrap"
|
||||||
<Button type="submit" onClick={() => lookupPlayer()}>
|
autoComplete="off"
|
||||||
Lookup
|
action={(form: FormData) => {
|
||||||
|
lookupPlayer(form.get("query") as string);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex gap-2 justify-center">
|
||||||
|
<div className="flex flex-col gap-2 items-start">
|
||||||
|
<Label htmlFor="query">Player</Label>
|
||||||
|
<Input className="w-fit" type="search" name="query" placeholder="Query..." maxLength={128} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" className="flex gap-2">
|
||||||
|
{loading && <ScaleLoader width={1} height={20} radius={2} />}
|
||||||
|
Search
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
27
src/app/components/player/player-skin.tsx
Normal file
27
src/app/components/player/player-skin.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { CachedPlayer, SkinPart } from "mcutils-library";
|
||||||
|
import { ReactElement } from "react";
|
||||||
|
import { SkinPartImage } from "@/app/components/player/skin-part-image";
|
||||||
|
|
||||||
|
type PlayerSkinProps = {
|
||||||
|
/**
|
||||||
|
* The player to get the skin from.
|
||||||
|
*/
|
||||||
|
player: CachedPlayer;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PlayerSkin({ player }: PlayerSkinProps): ReactElement {
|
||||||
|
const skin = player.skin;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<p className="text-lg">Skin Parts</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{Object.entries(skin.parts)
|
||||||
|
.filter(([part]) => part !== SkinPart.HEAD) // Don't show the head part again
|
||||||
|
.map(([part, url]) => {
|
||||||
|
return <SkinPartImage key={part} playerName={player.username} part={part as SkinPart} url={url} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
56
src/app/components/player/player-view.tsx
Normal file
56
src/app/components/player/player-view.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
import { CachedPlayer, SkinPart } from "mcutils-library";
|
||||||
|
import { ReactElement } from "react";
|
||||||
|
import { Card } from "../card";
|
||||||
|
import { CodeDialog } from "../code-dialog";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { Separator } from "../ui/separator";
|
||||||
|
import { SkinPartImage } from "./skin-part-image";
|
||||||
|
import { CacheInformation } from "@/app/components/cache-information";
|
||||||
|
import { PlayerSkin } from "@/app/components/player/player-skin";
|
||||||
|
import { ReloadPageButton } from "@/app/components/reload-page-button";
|
||||||
|
|
||||||
|
type PlayerViewProps = {
|
||||||
|
/**
|
||||||
|
* The player to display.
|
||||||
|
*/
|
||||||
|
player: CachedPlayer;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PlayerView({ player }: PlayerViewProps): ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 items-center">
|
||||||
|
<Card classNameCard="w-screen xs:w-fit">
|
||||||
|
<div className="flex gap-4 flex-col xs:flex-row relative">
|
||||||
|
<div className="flex items-center flex-col">
|
||||||
|
<SkinPartImage playerName={player.username} part={SkinPart.HEAD} url={player.skin.parts.head} size={96} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl text-primary font-semibold">{player.username}</h2>
|
||||||
|
<p>{player.uniqueId}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<PlayerSkin player={player} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<div className="flex gap-2 flex-wrap justify-center">
|
||||||
|
<ReloadPageButton />
|
||||||
|
<CodeDialog
|
||||||
|
title="Player Data"
|
||||||
|
description="The player's data from the API"
|
||||||
|
code={JSON.stringify(player, undefined, 2)}
|
||||||
|
>
|
||||||
|
<Button>View as JSON</Button>
|
||||||
|
</CodeDialog>
|
||||||
|
<CacheInformation cache={player.cache}>
|
||||||
|
<Button>Cache Information</Button>
|
||||||
|
</CacheInformation>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
116
src/app/components/player/skin-part-image.tsx
Normal file
116
src/app/components/player/skin-part-image.tsx
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
import { capitalizeFirstLetter } from "@/app/common/string-utils";
|
||||||
|
import { SkinPart } from "mcutils-library";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ReactElement } from "react";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "../ui/dialog";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||||
|
|
||||||
|
type SkinPartImageProps = {
|
||||||
|
/**
|
||||||
|
* The player's name.
|
||||||
|
*/
|
||||||
|
playerName: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The skin part.
|
||||||
|
*/
|
||||||
|
part: SkinPart;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The URL to the skin part.
|
||||||
|
*/
|
||||||
|
url: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The size to display the skin part.
|
||||||
|
*/
|
||||||
|
size?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SkinOverlay = {
|
||||||
|
/**
|
||||||
|
* The title to display.
|
||||||
|
*/
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to show the part with overlays.
|
||||||
|
*/
|
||||||
|
overlays?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const skinPartOverlay: SkinOverlay[] = [
|
||||||
|
{
|
||||||
|
title: "Without Overlays",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "With Overlays",
|
||||||
|
overlays: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function SkinPartImage({ playerName, part, url, size = 64 }: SkinPartImageProps): ReactElement {
|
||||||
|
const partName = capitalizeFirstLetter(part);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt={`The ${playerName}'s ${partName}`}
|
||||||
|
loading="lazy"
|
||||||
|
style={{
|
||||||
|
height: `${size}px`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="w-fit h-fit">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{playerName}'s {partName}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>See the skin part below.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex items-center w-full flex-col divide-y md:flex-row md:divide-x md:divide-y-0 text-muted-foreground">
|
||||||
|
{skinPartOverlay.map((overlay, index) => {
|
||||||
|
return (
|
||||||
|
<div key={index} className="p-2 w-max text-center items-center font-semibold flex flex-col gap-2">
|
||||||
|
<p>{overlay.title}</p>
|
||||||
|
<img
|
||||||
|
className="h-[200px] md:h-[256px] w-max"
|
||||||
|
src={url + (overlay.overlays ? "?overlays=true" : "")}
|
||||||
|
alt={`The ${playerName}'s ${partName}`}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Link href={url} target="_blank">
|
||||||
|
<Button>Open in new tab</Button>
|
||||||
|
</Link>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>
|
||||||
|
Click to view {playerName}'s {partName}
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
49
src/app/components/player/try-a-player.tsx
Normal file
49
src/app/components/player/try-a-player.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { ReactElement } from "react";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/app/components/ui/tooltip";
|
||||||
|
import Image from "next/image";
|
||||||
|
import config from "@root/config.json";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Card } from "@/app/components/card";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The players to try out.
|
||||||
|
*/
|
||||||
|
const tryMePlayers: string[] = ["Notch", "jeb_", "Dinnerbone", "Grumm", "deadmau5"];
|
||||||
|
|
||||||
|
export function TryAPlayer(): ReactElement {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<div className="flex flex-col items-center justify-center text-center">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">Try a Player</h2>
|
||||||
|
<p className="text-muted-foreground">Try one of these players to see how the player view works.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-center gap-2 max-w-2xl mt-4">
|
||||||
|
{tryMePlayers.map(playerName => (
|
||||||
|
<Tooltip key={playerName}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className="flex items-center justify-center bg-background p-1.5 rounded-md gap-2">
|
||||||
|
<Image
|
||||||
|
src={`${config.apiEndpoint}/player/head/${playerName}`}
|
||||||
|
alt={"The player's head"}
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
/>
|
||||||
|
<Link href={`/player/${playerName}`} className="hover:opacity-85 transform-gpu transition-all">
|
||||||
|
{playerName}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>
|
||||||
|
Click to try the player <b>{playerName}</b>.
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
@ -1,17 +0,0 @@
|
|||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
type ButtonProps = {
|
|
||||||
title: string;
|
|
||||||
url: string;
|
|
||||||
openInNewTab?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function RedirectButton({ title, url, openInNewTab }: ButtonProps): JSX.Element {
|
|
||||||
return (
|
|
||||||
<div className="w-fit rounded-lg">
|
|
||||||
<Link href={url} target={openInNewTab ? "_blank" : ""}>
|
|
||||||
<p className="hover:text-primary transition-all">{title}</p>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
28
src/app/components/reload-page-button.tsx
Normal file
28
src/app/components/reload-page-button.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ReactElement } from "react";
|
||||||
|
import { Button } from "@/app/components/ui/button";
|
||||||
|
import { ArrowPathIcon } from "@heroicons/react/16/solid";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/app/components/ui/tooltip";
|
||||||
|
|
||||||
|
export function ReloadPageButton(): ReactElement {
|
||||||
|
/**
|
||||||
|
* Reload the page.
|
||||||
|
*/
|
||||||
|
function reload(): void {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button onClick={() => reload()}>
|
||||||
|
<ArrowPathIcon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Reload the page</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
@ -1,43 +1,103 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ServerPlatform } from "mcutils-library";
|
import { capitalizeFirstLetter } from "@/app/common/string-utils";
|
||||||
|
import { useToast } from "@/app/common/use-toast";
|
||||||
|
import { getServer, ServerPlatform } from "mcutils-library";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { ReactElement, useState } from "react";
|
||||||
|
import ScaleLoader from "react-spinners/ScaleLoader";
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
import { Input } from "../ui/input";
|
import { Input } from "../ui/input";
|
||||||
|
import { Label } from "../ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select";
|
||||||
|
|
||||||
export function LookupServer(): JSX.Element {
|
type LookupServerProps = {
|
||||||
const router = useRouter();
|
/**
|
||||||
const [hostname, setHostname] = useState("");
|
* The last displayed platform.
|
||||||
|
*/
|
||||||
|
currentPlatform: string | undefined;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the hostname value
|
* The last displayed server.
|
||||||
*
|
|
||||||
* @param event the input event
|
|
||||||
*/
|
*/
|
||||||
const setHostnameValue = (event: React.ChangeEvent<HTMLInputElement>) => {
|
currentServer: string | undefined;
|
||||||
setHostname(event.target.value);
|
};
|
||||||
};
|
|
||||||
|
export function LookupServer({ currentPlatform, currentServer }: LookupServerProps): ReactElement {
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lookup a server based on the platform
|
* Lookup a server based on the platform
|
||||||
*
|
*
|
||||||
* @param platform the server platform
|
* @param platform the server platform
|
||||||
|
* @param query the query to lookup
|
||||||
*/
|
*/
|
||||||
const lookupServer = (platform: ServerPlatform) => {
|
const lookupServer = async (platform: ServerPlatform, query: string) => {
|
||||||
if (!hostname || hostname.length === 0) {
|
if (query == null || query.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.push(`/server/${platform}/${hostname}`);
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const server = await getServer(platform, query);
|
||||||
|
|
||||||
|
// Ignore the same server
|
||||||
|
if (currentServer !== undefined && server.hostname == currentServer.toLowerCase()) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push(`/server/${platform}/${server.hostname}`);
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
variant: "destructive",
|
||||||
|
description: (err as Error).message,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="flex gap-2 justify-center items-center mt-2 flex-col xs:flex-row">
|
<form
|
||||||
<Input className="w-fit" placeholder="Hostname" value={hostname} onChange={setHostnameValue} maxLength={128} />
|
className="flex flex-col gap-2 justify-center items-center mt-4"
|
||||||
<div className="flex gap-2 justify-center">
|
autoComplete="off"
|
||||||
<Button onClick={() => lookupServer(ServerPlatform.Java)}>Java</Button>
|
action={(form: FormData) => {
|
||||||
<Button onClick={() => lookupServer(ServerPlatform.Bedrock)}>Bedrock</Button>
|
lookupServer(form.get("platform") as ServerPlatform, form.get("query") as string);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex gap-2 justify-center flex-wrap">
|
||||||
|
<div className="flex flex-col gap-2 items-start">
|
||||||
|
<Label htmlFor="platform">Platform</Label>
|
||||||
|
<Select name="platform" defaultValue={currentPlatform}>
|
||||||
|
<SelectTrigger className="w-[180px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Object.entries(ServerPlatform).map(([_, platform], index) => {
|
||||||
|
return (
|
||||||
|
<SelectItem key={index} value={platform}>
|
||||||
|
{capitalizeFirstLetter(platform)}
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 items-start">
|
||||||
|
<Label htmlFor="query">Hostname</Label>
|
||||||
|
<Input className="w-fit" type="search" name="query" placeholder="Query..." maxLength={128} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" className="flex gap-2">
|
||||||
|
{loading && <ScaleLoader width={1} height={20} radius={2} />}
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
48
src/app/components/server/server-view.tsx
Normal file
48
src/app/components/server/server-view.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { CachedBedrockMinecraftServer, CachedJavaMinecraftServer } from "mcutils-library";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { ReactElement } from "react";
|
||||||
|
import { CodeDialog } from "../code-dialog";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { CacheInformation } from "@/app/components/cache-information";
|
||||||
|
import { ReloadPageButton } from "@/app/components/reload-page-button";
|
||||||
|
|
||||||
|
type ServerViewProps = {
|
||||||
|
/**
|
||||||
|
* The server to display.
|
||||||
|
*/
|
||||||
|
server: CachedJavaMinecraftServer | CachedBedrockMinecraftServer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The favicon for the server.
|
||||||
|
*/
|
||||||
|
favicon: string | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ServerView({ server, favicon }: ServerViewProps): ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="flex relative flex-col gap-2 items-center w-screen">
|
||||||
|
<div>
|
||||||
|
<Image
|
||||||
|
src={`https://api.mcutils.xyz/server/java/preview/${server.hostname}`}
|
||||||
|
alt={"The server preview"}
|
||||||
|
width={650}
|
||||||
|
height={256}
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 flex-wrap justify-center">
|
||||||
|
<ReloadPageButton />
|
||||||
|
<CodeDialog
|
||||||
|
title="Server Data"
|
||||||
|
description="The servers's data from the API"
|
||||||
|
code={JSON.stringify(server, undefined, 2)}
|
||||||
|
>
|
||||||
|
<Button>View as JSON</Button>
|
||||||
|
</CodeDialog>
|
||||||
|
<CacheInformation cache={server.cache}>
|
||||||
|
<Button>Cache Information</Button>
|
||||||
|
</CacheInformation>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
54
src/app/components/server/try-a-server.tsx
Normal file
54
src/app/components/server/try-a-server.tsx
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { ReactElement } from "react";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/app/components/ui/tooltip";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { capitalizeFirstLetter } from "@/app/common/string-utils";
|
||||||
|
import { Card } from "@/app/components/card";
|
||||||
|
import { ServerPlatform } from "mcutils-library";
|
||||||
|
import { TryMeServer } from "@/app/types/server/try-me-server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The servers to try out.
|
||||||
|
*/
|
||||||
|
const tryMeServers: TryMeServer[] = [
|
||||||
|
{ platform: ServerPlatform.Java, hostname: "mc.hypixel.net" },
|
||||||
|
{ platform: ServerPlatform.Java, hostname: "wildprison.net" },
|
||||||
|
{ platform: ServerPlatform.Java, hostname: "cubecraft.net" },
|
||||||
|
{ platform: ServerPlatform.Bedrock, hostname: "geo.hivebedrock.network" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function TryAServer(): ReactElement {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<div className="flex flex-col items-center justify-center text-center">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">Try a Server</h2>
|
||||||
|
<p className="text-muted-foreground">Try one of these servers to see how the server view works.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-center gap-2 max-w-2xl mt-4">
|
||||||
|
{tryMeServers.map(({ platform, hostname }) => (
|
||||||
|
<Tooltip key={hostname}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className="flex items-center justify-center bg-background p-1.5 rounded-md gap-2">
|
||||||
|
<Image src={`/media/platform/${platform}.png`} alt={"The server's platform"} width={28} height={28} />
|
||||||
|
<Link
|
||||||
|
href={`/server/${platform}/${hostname}`}
|
||||||
|
className="hover:opacity-85 transform-gpu transition-all"
|
||||||
|
>
|
||||||
|
{hostname}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>
|
||||||
|
Click to try the <b>{capitalizeFirstLetter(platform)}</b> server: <b>{hostname}</b>
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
31
src/app/components/stat.tsx
Normal file
31
src/app/components/stat.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { ReactElement } from "react";
|
||||||
|
import CountUp from "react-countup";
|
||||||
|
|
||||||
|
type StatProps = {
|
||||||
|
/**
|
||||||
|
* The title of this statistic.
|
||||||
|
*/
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The value to display for this statistic.
|
||||||
|
*/
|
||||||
|
value: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The icon to display.
|
||||||
|
*/
|
||||||
|
icon: ReactElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Stat({ title, value, icon }: StatProps): ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="bg-card p-2 rounded-lg flex divide-x justify-center items-center text-center w-fit">
|
||||||
|
<div className="pr-2">{icon}</div>
|
||||||
|
<div className="pl-2">
|
||||||
|
<p>{title}</p>
|
||||||
|
<CountUp start={0} end={value} duration={0.75} preserveValue className="text-primary" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
74
src/app/components/stats.tsx
Normal file
74
src/app/components/stats.tsx
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Stat } from "@/app/components/stat";
|
||||||
|
import { ArrowTrendingUpIcon, ServerIcon, UserIcon } from "@heroicons/react/16/solid";
|
||||||
|
import { ReactElement } from "react";
|
||||||
|
import useWebSocket, { ReadyState } from "react-use-websocket";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||||
|
|
||||||
|
type Stat = {
|
||||||
|
/**
|
||||||
|
* The metric ID from the websocket.
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The display name for this statistic.
|
||||||
|
*/
|
||||||
|
displayName: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tooltip to display for this statistic.
|
||||||
|
*/
|
||||||
|
tooltip: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The icon to use for this statistic.
|
||||||
|
*/
|
||||||
|
icon: ReactElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stats: Stat[] = [
|
||||||
|
{
|
||||||
|
id: "totalRequests",
|
||||||
|
displayName: "Total Requests",
|
||||||
|
tooltip: "The total amount of requests to the API",
|
||||||
|
icon: <ArrowTrendingUpIcon width={24} height={24} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "uniquePlayerLookups",
|
||||||
|
displayName: "Player Lookups",
|
||||||
|
tooltip: "The unique amount of player lookups",
|
||||||
|
icon: <UserIcon width={24} height={24} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "uniqueServerLookups",
|
||||||
|
displayName: "Server Lookups",
|
||||||
|
tooltip: "The unique amount of server lookups",
|
||||||
|
icon: <ServerIcon width={24} height={24} />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Stats(): ReactElement {
|
||||||
|
const { lastMessage, readyState } = useWebSocket("wss://api.mcutils.xyz/websocket/metrics");
|
||||||
|
const metrics = lastMessage !== null && readyState == ReadyState.OPEN ? JSON.parse(lastMessage.data) : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2 flex-wrap justify-center">
|
||||||
|
{stats.map((stat, index) => {
|
||||||
|
const value = metrics ? metrics[stat.id] : "???";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip key={index}>
|
||||||
|
<TooltipTrigger className="cursor-default">
|
||||||
|
<Stat title={stat.displayName} value={value} icon={stat.icon} />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{stat.tooltip}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||||
import { type ThemeProviderProps } from "next-themes/dist/types";
|
import { type ThemeProviderProps } from "next-themes/dist/types";
|
||||||
|
import { ReactElement } from "react";
|
||||||
|
|
||||||
export default function ThemeProvider({ children, ...props }: ThemeProviderProps): JSX.Element {
|
export default function ThemeProvider({ children, ...props }: ThemeProviderProps): ReactElement {
|
||||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||||
}
|
}
|
||||||
|
@ -1,15 +1,28 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { MoonIcon, SunIcon } from "@heroicons/react/16/solid";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import { MoonIcon } from "./icon/moon-icon";
|
import { ReactElement, useEffect, useState } from "react";
|
||||||
import { SunIcon } from "./icon/sun-icon";
|
|
||||||
|
|
||||||
export function ToggleThemeButton(): JSX.Element {
|
export function ToggleThemeButton(): ReactElement {
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button className="p-2 rounded-lg" onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>
|
<button
|
||||||
{theme === "dark" ? <SunIcon /> : <MoonIcon color="#000" />}
|
className="rounded-lg"
|
||||||
|
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||||
|
aria-label="Toggle Theme"
|
||||||
|
>
|
||||||
|
{theme == "dark" ? <SunIcon width={24} height={24} /> : <MoonIcon width={24} height={24} color="#000" />}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
15
src/app/components/title.tsx
Normal file
15
src/app/components/title.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { ReactElement } from "react";
|
||||||
|
|
||||||
|
type TitleProps = {
|
||||||
|
title: string | ReactElement;
|
||||||
|
subtitle: string | ReactElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Title({ title, subtitle }: TitleProps): ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 items-center">
|
||||||
|
<div className="text-3xl text-primary font-semibold">{typeof title === "string" ? <h1>{title}</h1> : title}</div>
|
||||||
|
{typeof subtitle === "string" ? <p>{subtitle}</p> : subtitle}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
90
src/app/components/ui/breadcrumb.tsx
Normal file
90
src/app/components/ui/breadcrumb.tsx
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { ChevronRight, MoreHorizontal } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/app/common/utils";
|
||||||
|
|
||||||
|
const Breadcrumb = React.forwardRef<
|
||||||
|
HTMLElement,
|
||||||
|
React.ComponentPropsWithoutRef<"nav"> & {
|
||||||
|
separator?: React.ReactNode;
|
||||||
|
}
|
||||||
|
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
|
||||||
|
Breadcrumb.displayName = "Breadcrumb";
|
||||||
|
|
||||||
|
const BreadcrumbList = React.forwardRef<HTMLOListElement, React.ComponentPropsWithoutRef<"ol">>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<ol
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
BreadcrumbList.displayName = "BreadcrumbList";
|
||||||
|
|
||||||
|
const BreadcrumbItem = React.forwardRef<HTMLLIElement, React.ComponentPropsWithoutRef<"li">>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<li ref={ref} className={cn("inline-flex items-center gap-1.5", className)} {...props} />
|
||||||
|
),
|
||||||
|
);
|
||||||
|
BreadcrumbItem.displayName = "BreadcrumbItem";
|
||||||
|
|
||||||
|
const BreadcrumbLink = React.forwardRef<
|
||||||
|
HTMLAnchorElement,
|
||||||
|
React.ComponentPropsWithoutRef<"a"> & {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
>(({ asChild, className, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "a";
|
||||||
|
|
||||||
|
return <Comp ref={ref} className={cn("transition-colors hover:text-foreground", className)} {...props} />;
|
||||||
|
});
|
||||||
|
BreadcrumbLink.displayName = "BreadcrumbLink";
|
||||||
|
|
||||||
|
const BreadcrumbPage = React.forwardRef<HTMLSpanElement, React.ComponentPropsWithoutRef<"span">>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<span
|
||||||
|
ref={ref}
|
||||||
|
role="link"
|
||||||
|
aria-disabled="true"
|
||||||
|
aria-current="page"
|
||||||
|
className={cn("font-normal text-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
BreadcrumbPage.displayName = "BreadcrumbPage";
|
||||||
|
|
||||||
|
const BreadcrumbSeparator = ({ children, className, ...props }: React.ComponentProps<"li">) => (
|
||||||
|
<li role="presentation" aria-hidden="true" className={cn("[&>svg]:size-3.5", className)} {...props}>
|
||||||
|
{children ?? <ChevronRight />}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
|
||||||
|
|
||||||
|
const BreadcrumbEllipsis = ({ className, ...props }: React.ComponentProps<"span">) => (
|
||||||
|
<span
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
<span className="sr-only">More</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
BreadcrumbEllipsis,
|
||||||
|
};
|
@ -2,7 +2,7 @@ import { Slot } from "@radix-ui/react-slot";
|
|||||||
import { cva, type VariantProps } from "class-variance-authority";
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/common/utils";
|
import { cn } from "@/app/common/utils";
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||||
@ -27,7 +27,7 @@ const buttonVariants = cva(
|
|||||||
variant: "default",
|
variant: "default",
|
||||||
size: "default",
|
size: "default",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export interface ButtonProps
|
export interface ButtonProps
|
||||||
@ -40,7 +40,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|||||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
const Comp = asChild ? Slot : "button";
|
const Comp = asChild ? Slot : "button";
|
||||||
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
|
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
Button.displayName = "Button";
|
Button.displayName = "Button";
|
||||||
|
|
||||||
|
43
src/app/components/ui/card.tsx
Normal file
43
src/app/components/ui/card.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/app/common/utils";
|
||||||
|
|
||||||
|
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)} {...props} />
|
||||||
|
));
|
||||||
|
Card.displayName = "Card";
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-4", className)} {...props} />
|
||||||
|
),
|
||||||
|
);
|
||||||
|
CardHeader.displayName = "CardHeader";
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<h3 ref={ref} className={cn("text-2xl font-semibold leading-none tracking-tight", className)} {...props} />
|
||||||
|
),
|
||||||
|
);
|
||||||
|
CardTitle.displayName = "CardTitle";
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||||
|
),
|
||||||
|
);
|
||||||
|
CardDescription.displayName = "CardDescription";
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => <div ref={ref} className={cn("p-4 pt-0", className)} {...props} />,
|
||||||
|
);
|
||||||
|
CardContent.displayName = "CardContent";
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("flex items-center p-4 pt-0", className)} {...props} />
|
||||||
|
),
|
||||||
|
);
|
||||||
|
CardFooter.displayName = "CardFooter";
|
||||||
|
|
||||||
|
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
|
135
src/app/components/ui/command.tsx
Normal file
135
src/app/components/ui/command.tsx
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { type DialogProps } from "@radix-ui/react-dialog";
|
||||||
|
import { Command as CommandPrimitive } from "cmdk";
|
||||||
|
import { Search } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/app/common/utils";
|
||||||
|
import { Dialog, DialogContent } from "@/app/components/ui/dialog";
|
||||||
|
|
||||||
|
const Command = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
shouldFilter={false}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Command.displayName = CommandPrimitive.displayName;
|
||||||
|
|
||||||
|
interface CommandDialogProps extends DialogProps {}
|
||||||
|
|
||||||
|
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
|
||||||
|
return (
|
||||||
|
<Dialog {...props}>
|
||||||
|
<DialogContent className="overflow-hidden p-0 shadow-lg">
|
||||||
|
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||||
|
{children}
|
||||||
|
</Command>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CommandInput = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||||
|
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
<CommandPrimitive.Input
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
|
||||||
|
CommandInput.displayName = CommandPrimitive.Input.displayName;
|
||||||
|
|
||||||
|
const CommandList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
|
CommandList.displayName = CommandPrimitive.List.displayName;
|
||||||
|
|
||||||
|
const CommandEmpty = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||||
|
>((props, ref) => <CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />);
|
||||||
|
|
||||||
|
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
|
||||||
|
|
||||||
|
const CommandGroup = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Group
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
|
CommandGroup.displayName = CommandPrimitive.Group.displayName;
|
||||||
|
|
||||||
|
const CommandSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Separator ref={ref} className={cn("-mx-1 h-px bg-border", className)} {...props} />
|
||||||
|
));
|
||||||
|
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
|
||||||
|
|
||||||
|
const CommandItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled='true']:pointer-events-none data-[disabled='true']:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
|
CommandItem.displayName = CommandPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return <span className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)} {...props} />;
|
||||||
|
};
|
||||||
|
CommandShortcut.displayName = "CommandShortcut";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Command,
|
||||||
|
CommandDialog,
|
||||||
|
CommandInput,
|
||||||
|
CommandList,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandShortcut,
|
||||||
|
CommandSeparator,
|
||||||
|
};
|
180
src/app/components/ui/context-menu.tsx
Normal file
180
src/app/components/ui/context-menu.tsx
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
|
||||||
|
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/app/common/utils";
|
||||||
|
|
||||||
|
const ContextMenu = ContextMenuPrimitive.Root;
|
||||||
|
|
||||||
|
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
|
||||||
|
|
||||||
|
const ContextMenuGroup = ContextMenuPrimitive.Group;
|
||||||
|
|
||||||
|
const ContextMenuPortal = ContextMenuPrimitive.Portal;
|
||||||
|
|
||||||
|
const ContextMenuSub = ContextMenuPrimitive.Sub;
|
||||||
|
|
||||||
|
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
|
||||||
|
|
||||||
|
const ContextMenuSubTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||||
|
inset && "pl-8",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRight className="ml-auto h-4 w-4" />
|
||||||
|
</ContextMenuPrimitive.SubTrigger>
|
||||||
|
));
|
||||||
|
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
|
||||||
|
|
||||||
|
const ContextMenuSubContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
|
||||||
|
|
||||||
|
const ContextMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.Portal>
|
||||||
|
<ContextMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</ContextMenuPrimitive.Portal>
|
||||||
|
));
|
||||||
|
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const ContextMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
inset && "pl-8",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
const ContextMenuCheckboxItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<ContextMenuPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</ContextMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</ContextMenuPrimitive.CheckboxItem>
|
||||||
|
));
|
||||||
|
ContextMenuCheckboxItem.displayName = ContextMenuPrimitive.CheckboxItem.displayName;
|
||||||
|
|
||||||
|
const ContextMenuRadioItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.RadioItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<ContextMenuPrimitive.ItemIndicator>
|
||||||
|
<Circle className="h-2 w-2 fill-current" />
|
||||||
|
</ContextMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</ContextMenuPrimitive.RadioItem>
|
||||||
|
));
|
||||||
|
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
|
||||||
|
|
||||||
|
const ContextMenuLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn("px-2 py-1.5 text-sm font-semibold text-foreground", inset && "pl-8", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
|
||||||
|
|
||||||
|
const ContextMenuSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-border", className)} {...props} />
|
||||||
|
));
|
||||||
|
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
|
||||||
|
|
||||||
|
const ContextMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return <span className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)} {...props} />;
|
||||||
|
};
|
||||||
|
ContextMenuShortcut.displayName = "ContextMenuShortcut";
|
||||||
|
|
||||||
|
export {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuCheckboxItem,
|
||||||
|
ContextMenuRadioItem,
|
||||||
|
ContextMenuLabel,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
ContextMenuShortcut,
|
||||||
|
ContextMenuGroup,
|
||||||
|
ContextMenuPortal,
|
||||||
|
ContextMenuSub,
|
||||||
|
ContextMenuSubContent,
|
||||||
|
ContextMenuSubTrigger,
|
||||||
|
ContextMenuRadioGroup,
|
||||||
|
};
|
97
src/app/components/ui/dialog.tsx
Normal file
97
src/app/components/ui/dialog.tsx
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/app/common/utils";
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root;
|
||||||
|
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger;
|
||||||
|
|
||||||
|
const DialogPortal = DialogPrimitive.Portal;
|
||||||
|
|
||||||
|
const DialogClose = DialogPrimitive.Close;
|
||||||
|
|
||||||
|
const DialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-50 grid max-w-md md:max-w-2xl xl:max-w-6xl translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
));
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
|
||||||
|
);
|
||||||
|
DialogHeader.displayName = "DialogHeader";
|
||||||
|
|
||||||
|
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
|
||||||
|
);
|
||||||
|
DialogFooter.displayName = "DialogFooter";
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||||
|
));
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
};
|
@ -1,6 +1,6 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/common/utils";
|
import { cn } from "@/app/common/utils";
|
||||||
|
|
||||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||||
|
|
||||||
@ -10,7 +10,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type,
|
|||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
{...props}
|
{...props}
|
||||||
|
19
src/app/components/ui/label.tsx
Normal file
19
src/app/components/ui/label.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "@/app/common/utils";
|
||||||
|
|
||||||
|
const labelVariants = cva("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70");
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||||
|
));
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Label };
|
31
src/app/components/ui/popover.tsx
Normal file
31
src/app/components/ui/popover.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||||
|
|
||||||
|
import { cn } from "@/app/common/utils";
|
||||||
|
|
||||||
|
const Popover = PopoverPrimitive.Root;
|
||||||
|
|
||||||
|
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||||
|
|
||||||
|
const PopoverContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||||
|
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
));
|
||||||
|
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent };
|
145
src/app/components/ui/select.tsx
Normal file
145
src/app/components/ui/select.tsx
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||||
|
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/app/common/utils";
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root;
|
||||||
|
|
||||||
|
const SelectGroup = SelectPrimitive.Group;
|
||||||
|
|
||||||
|
const SelectValue = SelectPrimitive.Value;
|
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
));
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
|
const SelectScrollUpButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
));
|
||||||
|
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||||
|
|
||||||
|
const SelectScrollDownButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
));
|
||||||
|
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
|
||||||
|
|
||||||
|
const SelectContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
|
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
));
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const SelectLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Label ref={ref} className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)} {...props} />
|
||||||
|
));
|
||||||
|
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
));
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
const SelectSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
|
||||||
|
));
|
||||||
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectGroup,
|
||||||
|
SelectValue,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectContent,
|
||||||
|
SelectLabel,
|
||||||
|
SelectItem,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
};
|
22
src/app/components/ui/separator.tsx
Normal file
22
src/app/components/ui/separator.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||||
|
|
||||||
|
import { cn } from "@/app/common/utils";
|
||||||
|
|
||||||
|
const Separator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||||
|
>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn("shrink-0 bg-border", orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Separator };
|
@ -1,4 +1,4 @@
|
|||||||
import { cn } from "@/common/utils";
|
import { cn } from "@/app/common/utils";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||||
@ -6,26 +6,26 @@ const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableE
|
|||||||
<div className="relative w-full overflow-auto">
|
<div className="relative w-full overflow-auto">
|
||||||
<table ref={ref} className={cn("w-full caption-bottom text-sm", className)} {...props} />
|
<table ref={ref} className={cn("w-full caption-bottom text-sm", className)} {...props} />
|
||||||
</div>
|
</div>
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
Table.displayName = "Table";
|
Table.displayName = "Table";
|
||||||
|
|
||||||
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||||
({ className, ...props }, ref) => <thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
({ className, ...props }, ref) => <thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />,
|
||||||
);
|
);
|
||||||
TableHeader.displayName = "TableHeader";
|
TableHeader.displayName = "TableHeader";
|
||||||
|
|
||||||
const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||||
({ className, ...props }, ref) => (
|
({ className, ...props }, ref) => (
|
||||||
<tbody ref={ref} className={cn("[&_tr:last-child]:border-0", className)} {...props} />
|
<tbody ref={ref} className={cn("[&_tr:last-child]:border-0", className)} {...props} />
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
TableBody.displayName = "TableBody";
|
TableBody.displayName = "TableBody";
|
||||||
|
|
||||||
const TableFooter = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
const TableFooter = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||||
({ className, ...props }, ref) => (
|
({ className, ...props }, ref) => (
|
||||||
<tfoot ref={ref} className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)} {...props} />
|
<tfoot ref={ref} className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)} {...props} />
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
TableFooter.displayName = "TableFooter";
|
TableFooter.displayName = "TableFooter";
|
||||||
|
|
||||||
@ -36,7 +36,7 @@ const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTML
|
|||||||
className={cn("border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted", className)}
|
className={cn("border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
TableRow.displayName = "TableRow";
|
TableRow.displayName = "TableRow";
|
||||||
|
|
||||||
@ -46,25 +46,25 @@ const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
TableHead.displayName = "TableHead";
|
TableHead.displayName = "TableHead";
|
||||||
|
|
||||||
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
|
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
|
||||||
({ className, ...props }, ref) => (
|
({ className, ...props }, ref) => (
|
||||||
<td ref={ref} className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)} {...props} />
|
<td ref={ref} className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)} {...props} />
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
TableCell.displayName = "TableCell";
|
TableCell.displayName = "TableCell";
|
||||||
|
|
||||||
const TableCaption = React.forwardRef<HTMLTableCaptionElement, React.HTMLAttributes<HTMLTableCaptionElement>>(
|
const TableCaption = React.forwardRef<HTMLTableCaptionElement, React.HTMLAttributes<HTMLTableCaptionElement>>(
|
||||||
({ className, ...props }, ref) => (
|
({ className, ...props }, ref) => (
|
||||||
<caption ref={ref} className={cn("mt-4 text-sm text-muted-foreground", className)} {...props} />
|
<caption ref={ref} className={cn("mt-4 text-sm text-muted-foreground", className)} {...props} />
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
TableCaption.displayName = "TableCaption";
|
TableCaption.displayName = "TableCaption";
|
||||||
|
|
||||||
|
113
src/app/components/ui/toast.tsx
Normal file
113
src/app/components/ui/toast.tsx
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as ToastPrimitives from "@radix-ui/react-toast";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/app/common/utils";
|
||||||
|
|
||||||
|
const ToastProvider = ToastPrimitives.Provider;
|
||||||
|
|
||||||
|
const ToastViewport = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Viewport
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
|
||||||
|
|
||||||
|
const toastVariants = cva(
|
||||||
|
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "border bg-background text-foreground",
|
||||||
|
destructive: "destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const Toast = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
|
||||||
|
>(({ className, variant, ...props }, ref) => {
|
||||||
|
return <ToastPrimitives.Root ref={ref} className={cn(toastVariants({ variant }), className)} {...props} />;
|
||||||
|
});
|
||||||
|
Toast.displayName = ToastPrimitives.Root.displayName;
|
||||||
|
|
||||||
|
const ToastAction = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Action
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ToastAction.displayName = ToastPrimitives.Action.displayName;
|
||||||
|
|
||||||
|
const ToastClose = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Close
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
toast-close=""
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</ToastPrimitives.Close>
|
||||||
|
));
|
||||||
|
ToastClose.displayName = ToastPrimitives.Close.displayName;
|
||||||
|
|
||||||
|
const ToastTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Title ref={ref} className={cn("text-sm font-semibold", className)} {...props} />
|
||||||
|
));
|
||||||
|
ToastTitle.displayName = ToastPrimitives.Title.displayName;
|
||||||
|
|
||||||
|
const ToastDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Description ref={ref} className={cn("text-sm opacity-90", className)} {...props} />
|
||||||
|
));
|
||||||
|
ToastDescription.displayName = ToastPrimitives.Description.displayName;
|
||||||
|
|
||||||
|
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
|
||||||
|
|
||||||
|
type ToastActionElement = React.ReactElement<typeof ToastAction>;
|
||||||
|
|
||||||
|
export {
|
||||||
|
type ToastProps,
|
||||||
|
type ToastActionElement,
|
||||||
|
ToastProvider,
|
||||||
|
ToastViewport,
|
||||||
|
Toast,
|
||||||
|
ToastTitle,
|
||||||
|
ToastDescription,
|
||||||
|
ToastClose,
|
||||||
|
ToastAction,
|
||||||
|
};
|
33
src/app/components/ui/toaster.tsx
Normal file
33
src/app/components/ui/toaster.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Toast,
|
||||||
|
ToastClose,
|
||||||
|
ToastDescription,
|
||||||
|
ToastProvider,
|
||||||
|
ToastTitle,
|
||||||
|
ToastViewport,
|
||||||
|
} from "@/app/components/ui/toast";
|
||||||
|
import { useToast } from "@/app/common/use-toast";
|
||||||
|
|
||||||
|
export function Toaster() {
|
||||||
|
const { toasts } = useToast();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastProvider>
|
||||||
|
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||||
|
return (
|
||||||
|
<Toast key={id} {...props}>
|
||||||
|
<div className="grid gap-1">
|
||||||
|
{title && <ToastTitle>{title}</ToastTitle>}
|
||||||
|
{description && <ToastDescription>{description}</ToastDescription>}
|
||||||
|
</div>
|
||||||
|
{action}
|
||||||
|
<ToastClose />
|
||||||
|
</Toast>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<ToastViewport />
|
||||||
|
</ToastProvider>
|
||||||
|
);
|
||||||
|
}
|
30
src/app/components/ui/tooltip.tsx
Normal file
30
src/app/components/ui/tooltip.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||||
|
|
||||||
|
import { cn } from "@/app/common/utils";
|
||||||
|
|
||||||
|
const TooltipProvider = TooltipPrimitive.Provider;
|
||||||
|
|
||||||
|
const Tooltip = TooltipPrimitive.Root;
|
||||||
|
|
||||||
|
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||||
|
|
||||||
|
const TooltipContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
6
src/app/font/fonts.ts
Normal file
6
src/app/font/fonts.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { Inter } from "next/font/google";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default font to use for the site.
|
||||||
|
*/
|
||||||
|
export const inter = Inter({ subsets: ["latin"] });
|
22
src/app/global-error.tsx
Normal file
22
src/app/global-error.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
import Error from "next/error";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Button } from "@/app/components/ui/button";
|
||||||
|
|
||||||
|
export default function GlobalError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||||
|
useEffect(() => {
|
||||||
|
Sentry.captureException(error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex text-center flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-red-400 font-2xl font-semibold">Error</h2>
|
||||||
|
<p>An error occurred while rendering this page.</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={reset}>Reload</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user