first commit still needs bug testing or something

This commit is contained in:
2026-01-21 02:14:07 -05:00
parent f6763d7500
commit f5adcc68b2
33 changed files with 11106 additions and 0 deletions

41
.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
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/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## 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/app/building-your-application/deploying) for more details.

22
components.json Normal file
View File

@@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

517
dp2.txt Normal file
View File

@@ -0,0 +1,517 @@
DISCIPLINARY PRINCIPLES II
Moderation for Minecraft
OCTOBER 31, 2025
BY MR. R, MS. W, MS. L, AND MR. T
Revision 11
================================================================================
TABLE OF CONTENTS
================================================================================
Concepts of DP II ............................................................ 3
Major Revisions .............................................................. 4
Introduction and Policies .................................................... 4
What counts as inappropriate language .................................... 4
The Point System ......................................................... 5
Discord and Minecraft are separate ....................................... 5
Permanent isnt REALLY permanent ......................................... 5
Point Decay and the new requirement for Trusted .......................... 5
Rules for Trespassing .................................................... 5
Requirements for Trusted ................................................. 6
Awards and Illegal Items ................................................. 6
How to Report ............................................................ 7
The Process .............................................................. 7
Calculating Item Points .................................................. 7
Anti-Bullying Frameworks ..................................................... 8
The signs of bullying .................................................... 8
Starting an anti-bullying case ........................................... 9
Monitoring an anti-bullying case ......................................... 9
Specially Protected Persons .............................................. 9
Non-communication agreements ............................................ 10
What counts as Slander? .................................................. 10
How Bullying Cases End ................................................... 10
Bullying punishments are added on top .................................... 11
Crimes and Punishments ....................................................... 11
Item Offenses ............................................................ 11
Block Offences ........................................................... 14
Hacking Offences ......................................................... 18
Communication Offences ................................................... 22
Examples of Each Crime ....................................................... 27
================================================================================
CONCEPTS OF DP II
================================================================================
The primary goal of this revision to Disciplinary Principles is to strictly codify all
possible offenses. Some players, such as naniclan and ender_sam_ have complained that
either the justice system is too lenient, different moderators hand out different
punishments for the same thing, and we keep favourites. The use of this system will not
only convert discipline into an objective measure, it will ensure that everybody gets what is
fair to them.
================================================================================
MAJOR REVISIONS
================================================================================
Revision 11: Adds anti bullying frameworks, mandated by the Landry Strike Action Deal of October 2025.
Revision 10: Adds new trespassing policy, mandated by the Landry Strike Action Deal of October 2025.
Revision 9: Cleans up the formatting of the document.
Revision 8: Adds a new crime: Conspiring to Act Corruptly.
Revision 7: Updates the requirements for trusted to meet R6 standards for DP II.
Revision 6: Merges the multiple point categories into one single point category.
================================================================================
INTRODUCTION AND POLICIES
================================================================================
WHAT COUNTS AS INAPPROPRIATE LANGUAGE
Throughout this guide, you will see numerous references to inappropriate language.
Inappropriate language is any text that:
* Uses highly sexual or suggestive language
* Contains racial or homophobic slurs
* Contains traditional curse words aimed at an individual.
- "What the f*ck" OK
- "F*ck you, [Player Name]" Not OK
* Note: hell and damn do not count as inappropriate language under any circumstance.
"Ass" is permitted as long as it is not used in a sexual manner.
* Expresses undue hatred towards a specific group or player.
THE POINT SYSTEM
There is now only one point count. This count is incremented for every crime.
DISCORD AND MINECRAFT ARE SEPARATE
If a player commits a crime on Minecraft, their discord account will remain unaffected,
except in these circumstances:
* The player has committed a massive grief
* The player has been convicted of “Grand Manipulation” or “Corruption”
If a player commits any of these crimes, their discord account will be banned and will be
unbanned along with them if that ever happens.
Likewise, if a discord account does something bad, the Minecraft account will
remain unaffected. This is partially to protect people from answering for the crimes of
others impersonating them on Discord.
An exception to this is when discord has been used to clearly plot corrupt acts
against the server. Then, the players may be charged both on Discord and Minecraft with
“Conspiring to Act Corruptly.”
PERMANENT ISN'T REALLY PERMANENT
In many cases, permanently banned players will be unbanned after many months if
staff believe the player is genuinely reformed.
NOTE Exception to this rule: If this player has a history of manipulation or Grand
Manipulations, they will not be believed, so unbans in this case will be greatly
delayed if ever.
POINT DECAY AND THE NEW REQUIREMENT FOR TRUSTED
Points decay at one point for every week. When reporting a crime, make sure to
check when the users last offence was, and subtract points accordingly. To be
trusted, a user must now have 0 active points. This means that players with criminal
records need to wait for their records to naturally decay.
RULES FOR TRESPASSING
Prior to November 1, 2025, trespassing, snooping, and looking in chests was not a
crime at all. After several complaints, rigid trespassing rules were implemented.
* For the most part, you will not get in trouble just for walking on somebodys land.
* Trespassing works on a complaint-based system. If somebody tells you to leave, you
must leave.
* If you fail to leave, you will be teleported out by staff and issued a punishment.
* Snooping (looking in private chests) is a semi-enforced crime. You will be
questioned if caught, but not punished retroactively.
* Punishments are double if the player trespasses in a staff area (e.g., the Nani
Base).
REQUIREMENTS FOR TRUSTED
To become trusted, one of the following criteria must be met:
* Total play time > 10 hours AND Joined at least one week ago AND Existing
trusted player vouches for them AND zero active disciplinary points.
* Total play time > 15 hours AND joined at least two weeks ago AND zero
active disciplinary points.
Once a user becomes trusted, they require three active disciplinary points to be
demoted.
AWARDS AND ILLEGAL ITEMS
The following are the ONLY legal unobtainable items:
* Award for Service, 1st class Enchanted netherite ingot
* Award for Service, 2nd class Enchanted diamond
* Award for Service, 3rd class Enchanted iron ingot
* Award for Bugfinding Enchanted gold ingot
* Award for 5 exploits 50% scale stick (“small stick”)
* Award for Playtime Enchanted bedrock
* Halloween 2025 winner enchanted jack o lantern
* 1 year anniversary award enchanted cake
* Ban Hammer mace with lore #banhammer (staff only)
Any other illegal item must be treated as a violation.
================================================================================
HOW TO REPORT
================================================================================
1. Run /history on the perpetrator.
2. Find the date of their last offence and the [note].
3. Subtract two points for each month that has passed. If less than two weeks has
passed, do not subtract any points.
4. If points decreased due to decay, run a new /note <user> (new point total).
5. Execute punishment commands (punishments are calculated AFTER adding points
from the current offense).
6. Add a new /note <user> with the updated total.
THE PROCESS
1. Receive Report: From players or staff discovery.
2. Investigate: Use CoreProtect inspection.
3. Discover Scope: For griefs/thefts, determine the block/item count.
4. Reverse Damage: Use CoreProtect rollback (check for #tnt, #water, etc.).
5. Issue Punishment: Apply based on the specific crime section.
CALCULATING ITEM POINTS (FOR THEFT)
* 20 points: Elytra, Nether Star, Beacon, Heavy Core, Mace, Netherite Block,
Special Awards.
* 10 points: Netherite derivatives, Diamond Block, Gold Block, Emerald Block,
Shulker Box.
* 5 points: Diamonds and derivatives, Iron Block, Potions.
* 2 points: Any tool/armour piece, any ore/ingot, Firework Rockets.
* 1 point: All other blocks.
================================================================================
ANTI-BULLYING FRAMEWORKS
================================================================================
Created October 30, 2025, to stop chronic trolling that causes serious distress.
THE SIGNS OF BULLYING
Defined as chronic harassment or intimidation. Includes:
* Chronically insulting or slandering (false statements).
* Harassment via signs, posters, voice calls, or spying.
* Repeated killing or snooping.
* Seemingly innocuous actions like "shipping" or jokes if the victim is upset.
STARTING A CASE
Staff may start a case if they believe a player is being bullied. They must confirm
with the victim if they feel harassed. Isolated incidents are treated as standard
offences (Inciting Verbal Conflict or Abusive Chat).
MONITORING A CASE
1. Notify the bullies. If they stop, no further action is taken.
2. If they continue, implement further protections.
SPECIALLY PROTECTED PERSONS (SPP)
If bullying is consistent, a player is declared an SPP.
* Their name becomes forbidden to say on the server.
* Crimes against them have stricter punishments (e.g., Trespassing becomes a
1-week ban).
* Slander is introduced as a harsher offence.
NON-COMMUNICATION AGREEMENTS (NCA)
Both parties agree not to talk. Any breach results in punishments.
WHAT COUNTS AS SLANDER?
Slander is a factual, untrue statement designed to damage reputation.
* "I do not like [Player]" NOT SLANDER (Opinion).
* "[Player] has an STD" SLANDER (Untrue fact statement).
* "[Player] is an idiot" SLANDER (Harmful to reputation).
* "F*** [Player]" NOT SLANDER (Abusive language).
HOW BULLYING CASES END
1. Not enough evidence.
2. Victim agrees the bullying has stopped. (SPP cases continue to be monitored).
BULLYING PUNISHMENTS
Added on top of standard crime punishments. They do NOT decay, ever.
================================================================================
CRIMES AND PUNISHMENTS
================================================================================
ITEM OFFENSES
Inappropriate Item Names
Criteria: Named weapon in inventory or publicly featured item with inappropriate
language.
Value: 1 point per offence.
Punishment:
- 1-5 points: Warning
- 6-8 points: Tempban 1 week
- 9+ points: Tempban 4 weeks
Inappropriate Book Contents
Criteria: Book with inappropriate language without a preceding warning page.
Value: 1 point.
Action: Confiscate book.
Punishment: Warning.
Minor Theft
Criteria: Theft totalling < 50 item points.
Value: 1 point.
Punishment:
- 0-3 points: Warning
- 4-5 points: 2 day tempban
- 6-10 points: 1 week tempban
- 11-15 points: 2 week tempban
- 16+ points: 4 week tempban
Moderate Theft
Criteria: Theft 50 to 500 item points.
Value: 2 points.
Punishment:
- 0-5 points: Warning
- 6-10 points: 5 day tempban
- 11-15 points: 2 week tempban
- 16+ points: Permanent ban
Severe Theft
Criteria: Theft > 500 item points.
Value: 3 points.
Punishment:
- 0-5 points: Warning
- 6-10 points: 1 week tempban
- 11-15 points: 2 week tempban
- 16+ points: Permanent ban
Unconsensual Killing
Criteria: Repeatedly (2+ times) killing a player without permission.
Value: 2 points.
Punishment:
- 0-5 points: Warning
- 6-10 points: 3 day tempban
- 11-15 points: 1 week tempban
- 16+ points: 2 week tempban
Using an illegal item without reporting it
Criteria: Deliberately used an illegal item.
Value: 1 point.
Punishment: Warning.
BLOCK OFFENSES
Vandalism
Criteria: < 10 blocks destroyed (excluding Theft-Grief) or < 5 entities killed.
Value: 1 point.
Punishment: Warning.
Theft-Grief
Criteria: < 100 blocks destroyed, primarily valuables (Beacons, Ore blocks, etc.).
Value: Rollback count divided by 20 (min. 2 points).
Punishment:
- 0-5 points (or 1st offence): Warning
- 6-8 points: 3 day tempban
- 9-10 points: 5 day tempban
- 11-15 points: 2 week ban
- 16-20 points: 1 month ban
- 21+ points: Permanent Ban
Minor Grief
Criteria: < 100 blocks without permission or 5-20 valuable entities.
Value: 3 points.
Punishment: Same as Theft-Grief.
Vandalism of Public Infrastructure
Criteria: Damage rendering Iron Farms, Railways, etc., inoperable.
Value: +3 points.
Punishment: Same as Theft-Grief.
Moderate Grief
Criteria: 100 to 1,000 blocks.
Value: 5 points.
Punishment: Same as Theft-Grief (Last level with clemency/warning).
Large Grief
Criteria: 1,000 to 100,000 blocks.
Value: 8 points.
Punishment: Same as Theft-Grief (Immediate temp/perm ban).
Massive Grief
Criteria: > 100,000 blocks (TNT carpet bombing, lavacasts).
Value: 25 points.
Punishment: Immediate permanent ban.
Trespassing
Criteria: Remaining on property after being asked to leave or snooping.
Value: 1 point.
Punishment:
- 0-5 points: Warning
- 6-10 points: 1 day ban
- 11+ points: 5 day ban
Trespassing on Staff/SPP Land
Criteria: Snooping or remaining on Staff/SPP property after being asked to leave.
Value: 3 points.
Punishment:
- Same as Trespassing, but if perpetrator has bullied the SPP: immediate 1 week ban
following warning.
HACKING OFFENSES
X-Raying
Criteria: Using X-ray or Baritone #mine to find items.
Value: 1 point.
Punishment:
- 0-9 points: Warning
- 10+ points: Wipe of inventory and ender chest.
Use of Hacking Client
Criteria: Flying, killaura, etc.
Value: 5 points.
Punishment:
- 0-5 points: Kick with warning
- 6-15 points: 3 day temp ban
- 16+ points: Permanent ban
Deliberately Lagging the Server
Criteria: Lag machines, entity hoarding, or refusing to leave laggy areas.
Value: 5 points.
Punishment:
- 0-5 points: Warning
- 6-15 points: 5 day temp ban
- 16+ points: Permanent ban
Misuse of Worldedit
Criteria: Crashing or causing excessive lag.
Value: 5 points.
Punishment:
- 1st offence: Warning
- 2nd offence: Demotion from trusted (1 month).
Finding an exploit
Value: 0 points.
Action: Thank player, issue Gold Ingot. (If found 5 exploits, give "small stick").
Punishment: If they have a history of cheating, ban until exploit is patched.
Abuse of Exploits
Criteria: Repeatedly using exploit for unfair advantage.
Value: 10 points.
Punishment:
- 0-10 points: 5 day ban
- 11-15 points: 1 week ban
- 16-25 points: 2 week ban
- 26+ points: permanent ban
ADMIN OFFENSES
Light Admin Abuse
Criteria: Unnecessary teleporting, time/weather setting.
Value: N/A.
Punishment:
- 1st: Warning
- 2nd: Demotion to trusted.
Moderate Admin Abuse
Criteria: Changing gamemodes, issuing illegal items, unauthorized demotions, or
harassment.
Value: 5 points.
Punishment: Immediate demotion to standard player.
Severe Admin Abuse
Criteria: Deleting server files, root access theft, hardware damage, or
distributing passwords.
Punishment: Immediate permanent ban and demotion.
Neglect of Duty
Criteria: Ignoring players, showing bias, or behaving disrespectfully.
Punishment:
- 1st: Warning
- 2nd: Warning
- 3rd: Demotion or suspension (30+ days).
COMMUNICATION OFFENSES
Abusive Chat
Criteria: Inappropriate language.
Value: 1 point.
Punishment:
- 0-3 points: Warning
- 4-5 points: 15 minute mute
- 6-10 points: 1 hour mute
- 11+ points: 1 day mute
- If victim is SPP: Escalates to 1 day mute + permanent loss of trusted.
Inciting Verbal Conflict
Criteria: Chat aiming to incite violence or negative reactions.
Value: 2 points.
Punishment:
- 0-3 points: Warning
- 4-5 points: 30 minute mute
- 6-10 points: 2 hour mute
- 11+ points: 1 day mute
- If victim is SPP: Escalates to permanent loss of trusted.
Abusive VC Language
Criteria: Inappropriate language or inciting conflict in voice chat.
Value: 1 point.
Punishment:
- 0-3 points: Warning
- 4-5 points: 1 hour VC ban
- 6-10 points: 1 day VC ban
- 11+ points: Permanent VC ban
Lying to staff member
Criteria: Untrue statements regarding theft, griefing, etc.
Value: 1 point.
Punishment:
- 0-5 points: Warning
- 6-10 points: 1 day ban
- 11+ points: 3 day ban
Manipulation
Criteria: Repeatedly lying to conceal illicit activities or framing others.
Value: 5 points.
Punishment:
- 0-5 points: 3 day ban
- 6-10 points: 1 week ban
- 11+ points: 2 week ban
Grand Manipulation
Criteria: Lies involving large-scale cleanup or out-of-game malicious actions
(e.g., newsletters).
Value: 20 points.
Punishment: Permanent Ban.
Corruption
Criteria: Working against the server, inviting griefers, or distributing secrets.
Punishment: Warning, tempban, or permanent ban (Staff discretion).
Conspiring to Act Corruptly
Criteria: Planning/working against the server.
Value: 3 points.
Punishment: Warning and loss of trusted.
Misuse of Public Utilities
Criteria: Excessive taking from public chests or reselling output.
Value: 2 points.
Punishment: Return items; > 5 points: Loss of business access.
Slander (Against SPP Only)
Value: 3 points.
Punishment: Mute for (offence number) days. 3rd offence: Victim's choice of
punishment up to permanent ban.
Violation of NCA
Criteria: Breaking a non-communication agreement.
Value: 2 points.
Punishment:
- 1st: 12-hour mute
- 2nd: 1 day mute
- 3rd+: (offence number) days mute.
================================================================================
EXAMPLES OF EACH CRIME (HISTORICAL)
================================================================================
* Item Names: Lapizi101 naming a sword "Enderbyte09's [Redacted]"
* Minor Theft: Shellbuck69 stealing materials from naniclan.
* Severe Theft: Shellbuck69 cleaning out Ender Sams shop and Khai1705s base.
* Vandalism: Shellbuck69 stealing the public ender chest.
* Vandalism (Infrastructure): joyousssss removing the public iron farm wall.
* Massive Grief: RokoPlays lavacasting spawn (2 million blocks).
* Use of Hacking Client: Blackadder71 flying on May 16.
* Finding Exploit: herodoge finding over 10 exploits.
* Abuse of Exploit: Koops and gang creating illegal items in survival.
* Moderate Admin Abuse: Skaterva creating numerous illegal items.
* Severe Admin Abuse: Former admin Skaterva sending OS console password to chat.
* Neglect of Duty: jc_gamestar9032 being biased and insulting players.
* Abusive Chat: Ad6nmalt cursing 200+ times in one day.
* Grand Manipulation: herodoge pretending to be a 16-year-old girl for months.
* Corruption: Koops spamming pornography on Discord while impersonating others.
* Trespassing: shellbuck69 repeatedly snooping in naniclans old base.

18
eslint.config.mjs Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

7
next.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

8334
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

41
package.json Normal file
View File

@@ -0,0 +1,41 @@
{
"name": "dp2-moderation-assistant",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"class-variance-authority": "^0.7.1",
"lucide-react": "^0.562.0",
"next": "16.1.4",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-hook-form": "^7.71.1",
"zod": "^4.3.5"
},
"devDependencies": {
"@shadcn/ui": "^0.0.4",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"clsx": "^2.1.1",
"eslint": "^9",
"eslint-config-next": "16.1.4",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
}
}

7
postcss.config.mjs Normal file
View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
public/next.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

195
src/app/globals.css Normal file
View File

@@ -0,0 +1,195 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
/* Make the UI bigger and more usable */
.container {
max-width: 1200px;
}
/* Increase font sizes for better readability */
.text-sm {
font-size: 14px;
line-height: 1.4;
}
.text-lg {
font-size: 18px;
line-height: 1.5;
}
/* Make cards more prominent */
.card {
padding: 1.5rem;
}
.card-header {
padding-bottom: 1rem;
}
.card-title {
font-size: 24px;
font-weight: 600;
margin-bottom: 0.5rem;
}
.card-description {
font-size: 14px;
color: var(--color-muted-foreground);
}
/* Increase spacing for better usability */
.space-y-6 > * + * {
margin-top: 1.5rem;
}
.space-y-4 > * + * {
margin-top: 1rem;
}
.space-y-2 > * + * {
margin-top: 0.5rem;
}
/* Make buttons more prominent */
.btn {
padding: 0.75rem 1.5rem;
font-size: 16px;
font-weight: 500;
}
/* Make inputs larger */
.input {
padding: 0.75rem 1rem;
font-size: 16px;
border-radius: 8px;
}
/* Make labels more prominent */
.label {
font-size: 14px;
font-weight: 500;
margin-bottom: 0.25rem;
}
}

34
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,34 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "DP2 Moderation Assistant",
description: "Calculate punishments based on DP2 guidelines",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased dark`}
>
{children}
</body>
</html>
);
}

11
src/app/page.tsx Normal file
View File

@@ -0,0 +1,11 @@
import { DP2Form } from '@/components/DP2Form';
export default function Home() {
return (
<main className="min-h-screen bg-background">
<div className="container mx-auto py-8">
<DP2Form />
</div>
</main>
);
}

540
src/components/DP2Form.tsx Normal file
View File

@@ -0,0 +1,540 @@
'use client';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { CRIMES, Crime, ITEM_POINTS } from '@/lib/dp2-rules';
import { useDP2Calculator } from '@/hooks/useDP2Calculator';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Checkbox } from '@/components/ui/checkbox';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
import { Copy, RefreshCw, Moon, Sun, X } from 'lucide-react';
const FormSchema = z.object({
playerName: z.string().min(2, "Player name must be at least 2 characters"),
currentPoints: z.number().min(0, "Current points must be 0 or higher"),
lastOffenseDate: z.date().optional(),
crimeId: z.string(),
itemDetails: z.array(z.object({
type: z.string(),
quantity: z.number().min(1, "Quantity must be at least 1"),
})).optional(),
blockCount: z.number().min(0, "Block count must be 0 or higher").optional(),
entityCount: z.number().min(0, "Entity count must be 0 or higher").optional(),
isSPP: z.boolean().optional(),
specialItems: z.object({
elytra: z.number().min(0),
netherStar: z.number().min(0),
beacon: z.number().min(0),
netheriteBlock: z.number().min(0),
diamondBlock: z.number().min(0),
}),
});
type FormData = z.infer<typeof FormSchema>;
export function DP2Form() {
const [activeCategory, setActiveCategory] = useState<Crime['category'] | null>(null);
const { result, isLoading, calculatePunishment, copyToClipboard } = useDP2Calculator();
const form = useForm<FormData>({
resolver: zodResolver(FormSchema),
defaultValues: {
playerName: '',
currentPoints: 0,
crimeId: '',
itemDetails: [],
blockCount: 0,
entityCount: 0,
isSPP: false,
specialItems: {
elytra: 0,
netherStar: 0,
beacon: 0,
netheriteBlock: 0,
diamondBlock: 0,
},
},
});
const onSubmit = (data: FormData) => {
console.log('Form submitted with data:', data);
console.log('CrimeId value:', data.crimeId);
if (!data.crimeId || data.crimeId.trim() === '') {
console.error('CrimeId is empty!');
return;
}
calculatePunishment(
{
playerName: data.playerName,
currentPoints: data.currentPoints,
lastOffenseDate: data.lastOffenseDate,
},
{
crimeId: data.crimeId,
itemDetails: data.itemDetails,
blockCount: data.blockCount,
entityCount: data.entityCount,
isSPP: data.isSPP,
specialItems: data.specialItems,
}
);
};
const selectedCrime = CRIMES.find(c => c.id === form.watch('crimeId'));
const filteredCrimes = CRIMES.filter(crime =>
activeCategory ? crime.category === activeCategory : true
);
return (
<div className="max-w-4xl mx-auto space-y-6">
<Card>
<CardHeader className="flex flex-row justify-between items-start">
<div>
<CardTitle>dp2 moderation assistant</CardTitle>
<CardDescription>calculate punishments and generate commands based on dp2 guidelines</CardDescription>
</div>
<Button variant="ghost" size="icon" className="ml-auto">
<Moon className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Sun className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</CardHeader>
<CardContent>
<form onSubmit={form.handleSubmit((data) => onSubmit(data as unknown as FormData))} className="space-y-6">
{/* Player Information */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="space-y-2">
<Label htmlFor="playerName">player name</Label>
<Input
id="playerName"
placeholder="enter player name"
className="h-12 text-lg"
{...form.register('playerName', { required: true })}
/>
{form.formState.errors.playerName && (
<p className="text-sm text-red-500">{form.formState.errors.playerName.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="currentPoints">current points (from /history)</Label>
<Input
id="currentPoints"
type="number"
min="0"
placeholder="0"
className="h-12 text-lg"
{...form.register('currentPoints', { valueAsNumber: true })}
/>
{form.formState.errors.currentPoints && (
<p className="text-sm text-red-500">{form.formState.errors.currentPoints.message}</p>
)}
</div>
</div>
{/* Crime Category Selection */}
<div className="space-y-2">
<Label>crime category</Label>
<div className="flex flex-wrap gap-2">
<Button
variant={activeCategory === null ? "default" : "outline"}
size="sm"
onClick={() => {
setActiveCategory(null);
form.setValue('crimeId', '');
}}
className="text-sm"
>
all
</Button>
<Button
variant={activeCategory === "item" ? "default" : "outline"}
size="sm"
onClick={() => {
setActiveCategory("item");
form.setValue('crimeId', '');
}}
className="text-sm"
>
item offenses
</Button>
<Button
variant={activeCategory === "block" ? "default" : "outline"}
size="sm"
onClick={() => {
setActiveCategory("block");
form.setValue('crimeId', '');
}}
className="text-sm"
>
block offenses
</Button>
<Button
variant={activeCategory === "hacking" ? "default" : "outline"}
size="sm"
onClick={() => {
setActiveCategory("hacking");
form.setValue('crimeId', '');
}}
className="text-sm"
>
hacking offenses
</Button>
<Button
variant={activeCategory === "communication" ? "default" : "outline"}
size="sm"
onClick={() => {
setActiveCategory("communication");
form.setValue('crimeId', '');
}}
className="text-sm"
>
communication offenses
</Button>
</div>
</div>
{/* Crime Selection */}
<div className="space-y-2">
<Label htmlFor="crimeId">specific offense</Label>
<Select
onValueChange={(value) => form.setValue('crimeId', value)}
value={form.watch('crimeId')}
>
<SelectTrigger>
<SelectValue placeholder="select an offense" />
</SelectTrigger>
<SelectContent>
{filteredCrimes.map((crime) => (
<SelectItem key={crime.id} value={crime.id}>
{crime.name}
</SelectItem>
))}
</SelectContent>
</Select>
{form.formState.errors.crimeId && (
<p className="text-sm text-red-500">{form.formState.errors.crimeId.message}</p>
)}
</div>
{/* Crime Details */}
{selectedCrime && (
<div className="border-t pt-6">
<h3 className="text-lg font-semibold mb-4">offense details</h3>
{/* Classification Guidelines */}
{selectedCrime.id === 'theft' && (
<div className="bg-muted rounded-lg p-4 mb-4">
<p className="text-sm font-medium text-muted-foreground mb-2">Theft Classification:</p>
<p className="text-sm text-muted-foreground">
{`Minor: < 50 item points | Moderate: 50-500 item points | Severe: > 500 item points`}
</p>
</div>
)}
{selectedCrime.id === 'grief' && (
<div className="bg-muted rounded-lg p-4 mb-4">
<p className="text-sm font-medium text-muted-foreground mb-2">Grief Classification:</p>
<p className="text-sm text-muted-foreground">
{`Minor: < 100 blocks | Moderate: 100-1,000 blocks | Large: 1,000-100,000 blocks | Massive: > 100,000 blocks`}
</p>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Special Items - Only for Theft */}
{selectedCrime.id === 'theft' && (
<div className="space-y-4">
<div className="space-y-2">
<Label>special items stolen</Label>
<p className="text-xs text-muted-foreground">
Use these dedicated inputs for special items. Do not manually enter them below.
</p>
<div className="space-y-2">
<div className="grid grid-cols-1 gap-2">
<div className="flex items-center justify-between">
<span className="text-sm">elytra (20 points)</span>
<Input
type="number"
min="0"
placeholder="0"
className="w-20 h-8"
{...form.register('specialItems.elytra', { valueAsNumber: true })}
/>
</div>
<div className="flex items-center justify-between">
<span className="text-sm">nether star (20 points)</span>
<Input
type="number"
min="0"
placeholder="0"
className="w-20 h-8"
{...form.register('specialItems.netherStar', { valueAsNumber: true })}
/>
</div>
<div className="flex items-center justify-between">
<span className="text-sm">beacon (20 points)</span>
<Input
type="number"
min="0"
placeholder="0"
className="w-20 h-8"
{...form.register('specialItems.beacon', { valueAsNumber: true })}
/>
</div>
<div className="flex items-center justify-between">
<span className="text-sm">netherite block (10 points)</span>
<Input
type="number"
min="0"
placeholder="0"
className="w-20 h-8"
{...form.register('specialItems.netheriteBlock', { valueAsNumber: true })}
/>
</div>
<div className="flex items-center justify-between">
<span className="text-sm">diamond block (10 points)</span>
<Input
type="number"
min="0"
placeholder="0"
className="w-20 h-8"
{...form.register('specialItems.diamondBlock', { valueAsNumber: true })}
/>
</div>
</div>
</div>
</div>
{/* Item Details */}
<div className="space-y-2">
<Label>additional items stolen</Label>
<p className="text-xs text-muted-foreground">
Add any other stolen items not listed above.
</p>
<div className="space-y-2">
{form.watch('itemDetails')?.map((_, index) => (
<div key={index} className="flex items-center gap-2">
<div className="grid grid-cols-2 gap-2 flex-1">
<Input
placeholder="item type"
{...form.register(`itemDetails.${index}.type`)}
/>
<Input
type="number"
placeholder="quantity"
min="1"
{...form.register(`itemDetails.${index}.quantity`, { valueAsNumber: true })}
/>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="text-red-500 hover:text-red-700 hover:bg-red-50 p-1 h-8 w-8"
onClick={() => {
const currentItems = form.getValues('itemDetails') || [];
const newItems = currentItems.filter((_, i) => i !== index);
form.setValue('itemDetails', newItems);
}}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
const currentItems = form.getValues('itemDetails') || [];
form.setValue('itemDetails', [...currentItems, { type: '', quantity: 1 }]);
}}
>
add item
</Button>
</div>
</div>
</div>
)}
{/* Item Details for non-theft crimes */}
{selectedCrime.requiresItemDetails && selectedCrime.id !== 'theft' && (
<div className="space-y-2">
<Label>item details</Label>
<div className="space-y-2">
{form.watch('itemDetails')?.map((_, index) => (
<div key={index} className="flex items-center gap-2">
<div className="grid grid-cols-2 gap-2 flex-1">
<Input
placeholder="item type"
{...form.register(`itemDetails.${index}.type`)}
/>
<Input
type="number"
placeholder="quantity"
min="1"
{...form.register(`itemDetails.${index}.quantity`, { valueAsNumber: true })}
/>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="text-red-500 hover:text-red-700 hover:bg-red-50 p-1 h-8 w-8"
onClick={() => {
const currentItems = form.getValues('itemDetails') || [];
const newItems = currentItems.filter((_, i) => i !== index);
form.setValue('itemDetails', newItems);
}}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
const currentItems = form.getValues('itemDetails') || [];
form.setValue('itemDetails', [...currentItems, { type: '', quantity: 1 }]);
}}
>
add item
</Button>
</div>
</div>
)}
{/* Block Details */}
{selectedCrime.requiresBlockDetails && (
<div className="space-y-2">
<Label htmlFor="blockCount">block count</Label>
<Input
id="blockCount"
type="number"
min="0"
placeholder="0"
{...form.register('blockCount', { valueAsNumber: true })}
/>
</div>
)}
{/* Entity Details */}
{selectedCrime.requiresEntityDetails && (
<div className="space-y-2">
<Label htmlFor="entityCount">entity count</Label>
<Input
id="entityCount"
type="number"
min="0"
placeholder="0"
{...form.register('entityCount', { valueAsNumber: true })}
/>
</div>
)}
{/* SPP Status */}
{selectedCrime.requiresSPP && (
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox
id="isSPP"
{...form.register('isSPP')}
/>
<Label htmlFor="isSPP">victim is SPP (specially protected person)</Label>
</div>
</div>
)}
</div>
</div>
)}
<Button type="submit" disabled={isLoading}>
{isLoading ? 'calculating...' : 'calculate punishment'}
</Button>
</form>
</CardContent>
</Card>
{/* Results */}
{result && (
<Card>
<CardHeader>
<CardTitle>results</CardTitle>
<CardDescription>generated commands and explanation</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>commands</Label>
<div className="space-y-2">
{result.commands.map((command, index) => (
<div key={index} className="flex items-center justify-between p-3 bg-muted rounded-md">
<code className="text-sm font-mono">{command}</code>
<Button
variant="ghost"
size="sm"
onClick={() => copyToClipboard(command)}
className="ml-2"
>
<Copy className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
<div className="space-y-2">
<Label>summary</Label>
<div className="p-4 bg-muted rounded-md">
<div className="text-sm space-y-1">
<div><strong>crime:</strong> {result.crime.name}</div>
<div><strong>base points:</strong> {result.basePoints}</div>
<div><strong>total points:</strong> {result.totalPoints}</div>
<div><strong>punishment:</strong> {result.punishmentLevel.replace('_', ' ')}</div>
</div>
</div>
</div>
</div>
<div className="space-y-2">
<Label>detailed explanation</Label>
<Textarea
value={result.explanation}
readOnly
className="min-h-[120px] font-mono text-sm"
/>
</div>
<div className="flex justify-between items-center pt-4 border-t">
<div className="text-sm text-muted-foreground">
note: always verify the generated commands before executing
</div>
<Button
variant="outline"
onClick={() => form.reset()}
className="flex items-center space-x-2"
>
<RefreshCw className="h-4 w-4" />
<span>reset form</span>
</Button>
</div>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@@ -0,0 +1,62 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

167
src/components/ui/form.tsx Normal file
View File

@@ -0,0 +1,167 @@
"use client"
import * as React from "react"
import type * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
)
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : props.children
if (!body) {
return null
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
)
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,45 @@
"use client"
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }

View File

@@ -0,0 +1,190 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground 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 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
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}
align={align}
{...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)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@@ -0,0 +1,118 @@
import { useState, useCallback } from 'react';
import { CRIMES, calculateTotalPoints, getPunishmentLevel, generateCommands, generateExplanation, Crime, PunishmentLevel } from '@/lib/dp2-rules';
export interface PlayerFormData {
playerName: string;
currentPoints: number;
lastOffenseDate?: Date;
}
export interface OffenseFormData {
crimeId: string;
itemDetails?: Array<{ type: string; quantity: number }>;
blockCount?: number;
entityCount?: number;
isSPP?: boolean;
specialItems?: {
elytra: number;
netherStar: number;
beacon: number;
netheriteBlock: number;
diamondBlock: number;
};
}
export interface CalculationResult {
crime: Crime;
basePoints: number;
totalPoints: number;
punishmentLevel: PunishmentLevel;
commands: string[];
explanation: string;
}
export function useDP2Calculator() {
const [result, setResult] = useState<CalculationResult | null>(null);
const [isLoading, setIsLoading] = useState(false);
const calculatePunishment = useCallback((playerData: PlayerFormData, offenseData: OffenseFormData) => {
setIsLoading(true);
try {
console.log('Calculating punishment for:', { playerData, offenseData });
const crime = CRIMES.find(c => c.id === offenseData.crimeId);
if (!crime) {
console.error('No crime found for crimeId:', offenseData.crimeId);
console.error('Available crimes:', CRIMES.map(c => c.id));
throw new Error('Invalid crime selected');
}
// Calculate item points if applicable
let itemPoints = 0;
if (offenseData.itemDetails) {
itemPoints = offenseData.itemDetails.reduce((total, item) => {
return total + (item.quantity || 0);
}, 0);
}
// Calculate special item points if applicable
let specialItemPoints = 0;
if (offenseData.specialItems) {
specialItemPoints =
(offenseData.specialItems.elytra * 20) +
(offenseData.specialItems.netherStar * 20) +
(offenseData.specialItems.beacon * 20) +
(offenseData.specialItems.netheriteBlock * 10) +
(offenseData.specialItems.diamondBlock * 10);
}
// Calculate base points (including item points and special item points)
const basePoints = crime.basePoints + itemPoints + specialItemPoints;
// Calculate total points with decay
const totalPoints = calculateTotalPoints(basePoints, playerData.currentPoints, playerData.lastOffenseDate);
// Determine punishment level
const punishmentLevel = getPunishmentLevel(totalPoints, crime.category, offenseData.isSPP);
// Generate commands
const commands = generateCommands(playerData.playerName, totalPoints, punishmentLevel);
// Generate explanation
const explanation = generateExplanation(crime, basePoints, totalPoints, punishmentLevel);
const calculationResult: CalculationResult = {
crime,
basePoints,
totalPoints,
punishmentLevel,
commands,
explanation,
};
setResult(calculationResult);
} catch (error) {
console.error('Calculation error:', error);
setResult(null);
} finally {
setIsLoading(false);
}
}, []);
const copyToClipboard = useCallback(async (text: string) => {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (error) {
console.error('Failed to copy to clipboard:', error);
return false;
}
}, []);
return {
result,
isLoading,
calculatePunishment,
copyToClipboard,
};
}

423
src/lib/dp2-rules.ts Normal file
View File

@@ -0,0 +1,423 @@
import { z } from 'zod';
// Types for DP2 system
export type CrimeCategory = 'item' | 'block' | 'hacking' | 'communication';
export interface Crime {
id: string;
name: string;
category: CrimeCategory;
description: string;
basePoints: number;
requiresItemDetails?: boolean;
requiresBlockDetails?: boolean;
requiresEntityDetails?: boolean;
requiresSPP?: boolean;
}
export interface PlayerHistory {
name: string;
currentPoints: number;
lastOffenseDate?: Date;
}
export interface PunishmentResult {
totalPoints: number;
punishmentLevel: PunishmentLevel;
commands: string[];
explanation: string;
}
export type PunishmentLevel =
| 'warning'
| '15min_mute'
| '30min_mute'
| '1hour_mute'
| '1day_mute'
| '12hour_vc_ban'
| '1day_vc_ban'
| 'permanent_vc_ban'
| 'kick'
| '1day_ban'
| '2day_ban'
| '3day_ban'
| '5day_ban'
| '1week_ban'
| '2week_ban'
| '4week_ban'
| '1month_ban'
| 'permanent_ban'
| 'wipe_inventory'
| 'demote_trusted'
| 'demote_standard';
// Crime definitions based on DP2 guidelines
export const CRIMES: Crime[] = [
// Item Offenses
{
id: 'inappropriate_item_names',
name: 'Inappropriate Item Names',
category: 'item',
description: 'Named weapon in inventory or publicly featured item with inappropriate language',
basePoints: 1,
},
{
id: 'inappropriate_book_contents',
name: 'Inappropriate Book Contents',
category: 'item',
description: 'Book with inappropriate language without a preceding warning page',
basePoints: 1,
},
{
id: 'theft',
name: 'Theft',
category: 'item',
description: 'Theft of items - classification based on total item points: Minor (< 50), Moderate (50-500), Severe (> 500)',
basePoints: 1,
requiresItemDetails: true,
},
{
id: 'unconsensual_killing',
name: 'Unconsensual Killing',
category: 'item',
description: 'Repeatedly (2+ times) killing a player without permission',
basePoints: 2,
},
{
id: 'illegal_item_use',
name: 'Using Illegal Item',
category: 'item',
description: 'Deliberately used an illegal item',
basePoints: 1,
},
// Block Offenses
{
id: 'vandalism',
name: 'Vandalism',
category: 'block',
description: '< 10 blocks destroyed or < 5 entities killed',
basePoints: 1,
requiresBlockDetails: true,
requiresEntityDetails: true,
},
{
id: 'grief',
name: 'Grief',
category: 'block',
description: 'Griefing - classification based on block count: Minor (< 100), Moderate (100-1,000), Large (1,000-100,000), Massive (> 100,000)',
basePoints: 1,
requiresBlockDetails: true,
requiresEntityDetails: true,
},
{
id: 'theft_grief',
name: 'Theft-Grief',
category: 'block',
description: '< 100 blocks destroyed, primarily valuables',
basePoints: 2,
requiresBlockDetails: true,
},
{
id: 'vandalism_infrastructure',
name: 'Vandalism of Public Infrastructure',
category: 'block',
description: 'Damage rendering Iron Farms, Railways, etc., inoperable',
basePoints: 3,
requiresBlockDetails: true,
},
{
id: 'trespassing',
name: 'Trespassing',
category: 'block',
description: 'Remaining on property after being asked to leave or snooping',
basePoints: 1,
},
{
id: 'trespassing_staff',
name: 'Trespassing on Staff/SPP Land',
category: 'block',
description: 'Snooping or remaining on Staff/SPP property after being asked to leave',
basePoints: 3,
requiresSPP: true,
},
// Hacking Offenses
{
id: 'x_raying',
name: 'X-Raying',
category: 'hacking',
description: 'Using X-ray or Baritone #mine to find items',
basePoints: 1,
},
{
id: 'hacking_client',
name: 'Use of Hacking Client',
category: 'hacking',
description: 'Flying, killaura, etc.',
basePoints: 5,
},
{
id: 'lagging_server',
name: 'Deliberately Lagging Server',
category: 'hacking',
description: 'Lag machines, entity hoarding, or refusing to leave laggy areas',
basePoints: 5,
},
{
id: 'worldedit_misuse',
name: 'Misuse of Worldedit',
category: 'hacking',
description: 'Crashing or causing excessive lag',
basePoints: 5,
},
{
id: 'exploit_abuse',
name: 'Abuse of Exploits',
category: 'hacking',
description: 'Repeatedly using exploit for unfair advantage',
basePoints: 10,
},
// Communication Offenses
{
id: 'abusive_chat',
name: 'Abusive Chat',
category: 'communication',
description: 'Inappropriate language',
basePoints: 1,
},
{
id: 'inciting_verbal_conflict',
name: 'Inciting Verbal Conflict',
category: 'communication',
description: 'Chat aiming to incite violence or negative reactions',
basePoints: 2,
},
{
id: 'abusive_vc',
name: 'Abusive VC Language',
category: 'communication',
description: 'Inappropriate language or inciting conflict in voice chat',
basePoints: 1,
},
{
id: 'lying_to_staff',
name: 'Lying to Staff Member',
category: 'communication',
description: 'Untrue statements regarding theft, griefing, etc.',
basePoints: 1,
},
{
id: 'manipulation',
name: 'Manipulation',
category: 'communication',
description: 'Repeatedly lying to conceal illicit activities or framing others',
basePoints: 5,
},
{
id: 'grand_manipulation',
name: 'Grand Manipulation',
category: 'communication',
description: 'Lies involving large-scale cleanup or out-of-game malicious actions',
basePoints: 20,
},
{
id: 'slander',
name: 'Slander (Against SPP Only)',
category: 'communication',
description: 'False statements designed to damage reputation',
basePoints: 3,
requiresSPP: true,
},
{
id: 'violation_nca',
name: 'Violation of NCA',
category: 'communication',
description: 'Breaking a non-communication agreement',
basePoints: 2,
},
];
// Item point values for theft calculations
export const ITEM_POINTS = {
'elytra': 20,
'nether_star': 20,
'beacon': 20,
'heavy_core': 20,
'mace': 20,
'netherite_block': 20,
'award_1st_class': 20,
'award_2nd_class': 10,
'award_3rd_class': 5,
'award_bugfinding': 5,
'award_5_exploits': 5,
'award_playtime': 20,
'netherite_ingot': 10,
'netherite_sword': 10,
'netherite_pickaxe': 10,
'netherite_axe': 10,
'netherite_shovel': 10,
'netherite_hoe': 10,
'netherite_helmet': 10,
'netherite_chestplate': 10,
'netherite_leggings': 10,
'netherite_boots': 10,
'diamond_block': 10,
'gold_block': 10,
'emerald_block': 10,
'shulker_box': 10,
'diamond': 5,
'diamond_sword': 5,
'diamond_pickaxe': 5,
'diamond_axe': 5,
'diamond_shovel': 5,
'diamond_hoe': 5,
'diamond_helmet': 5,
'diamond_chestplate': 5,
'diamond_leggings': 5,
'diamond_boots': 5,
'iron_block': 5,
'potion': 5,
'tool': 2,
'armor': 2,
'ore': 2,
'ingot': 2,
'firework_rocket': 2,
'other': 1,
};
// Punishment calculation functions
export function calculatePointDecay(currentPoints: number, lastOffenseDate?: Date): number {
if (!lastOffenseDate) return currentPoints;
const weeksSinceOffense = Math.floor((Date.now() - lastOffenseDate.getTime()) / (1000 * 60 * 60 * 24 * 7));
const decayPoints = Math.min(weeksSinceOffense, currentPoints);
return Math.max(0, currentPoints - decayPoints);
}
export function calculateTotalPoints(basePoints: number, currentPoints: number, lastOffenseDate?: Date): number {
const decayedPoints = calculatePointDecay(currentPoints, lastOffenseDate);
return decayedPoints + basePoints;
}
export function getPunishmentLevel(points: number, crimeCategory: CrimeCategory, isSPP?: boolean): PunishmentLevel {
// Special cases for specific crimes
if (crimeCategory === 'hacking') {
if (points <= 5) return 'kick';
if (points <= 15) return '3day_ban';
return 'permanent_ban';
}
if (crimeCategory === 'communication') {
if (isSPP) {
if (points <= 3) return '1day_mute';
return 'permanent_ban';
}
if (points <= 3) return 'warning';
if (points <= 5) return '15min_mute';
if (points <= 10) return '1hour_mute';
return '1day_mute';
}
if (crimeCategory === 'block') {
if (points <= 5) return 'warning';
if (points <= 8) return '3day_ban';
if (points <= 10) return '5day_ban';
if (points <= 15) return '2week_ban';
if (points <= 20) return '1month_ban';
return 'permanent_ban';
}
// Item offenses
if (points <= 5) return 'warning';
if (points <= 8) return '1week_ban';
if (points <= 15) return '2week_ban';
return '4week_ban';
}
export function generateCommands(playerName: string, totalPoints: number, punishmentLevel: PunishmentLevel): string[] {
const commands: string[] = [];
// Always add note command
commands.push(`/note ${playerName} ${totalPoints}`);
// Add punishment command based on level
switch (punishmentLevel) {
case 'warning':
commands.push(`/warn ${playerName} DP2 violation`);
break;
case '15min_mute':
case '30min_mute':
case '1hour_mute':
case '1day_mute':
commands.push(`/mute ${playerName} ${punishmentLevel.replace('_', ' ')}`);
break;
case '12hour_vc_ban':
case '1day_vc_ban':
case 'permanent_vc_ban':
commands.push(`/vcban ${playerName} ${punishmentLevel.replace('_', ' ')}`);
break;
case 'kick':
commands.push(`/kick ${playerName} DP2 violation`);
break;
case '1day_ban':
case '2day_ban':
case '3day_ban':
case '5day_ban':
case '1week_ban':
case '2week_ban':
case '4week_ban':
case '1month_ban':
commands.push(`/ban ${playerName} ${punishmentLevel.replace('_ban', '')}`);
break;
case 'permanent_ban':
commands.push(`/ban ${playerName} permanent`);
break;
case 'wipe_inventory':
commands.push(`/wipe ${playerName} inventory`);
break;
case 'demote_trusted':
commands.push(`/demote ${playerName} trusted`);
break;
case 'demote_standard':
commands.push(`/demote ${playerName} standard`);
break;
}
return commands;
}
export function generateExplanation(crime: Crime, basePoints: number, totalPoints: number, punishmentLevel: PunishmentLevel): string {
const punishmentText = punishmentLevel.replace('_', ' ');
return `Crime: ${crime.name} (${basePoints} points)
Total points after decay: ${totalPoints}
Punishment: ${punishmentText}
${crime.description}`;
}
// Form validation schemas
export const playerSchema = z.object({
playerName: z.string().min(2, "Player name must be at least 2 characters"),
currentPoints: z.number().min(0, "Current points must be 0 or higher"),
lastOffenseDate: z.date().optional(),
});
export const offenseSchema = z.object({
crimeId: z.string(),
itemDetails: z.array(z.object({
type: z.string(),
quantity: z.number().min(1, "Quantity must be at least 1"),
})).optional(),
blockCount: z.number().min(0, "Block count must be 0 or higher").optional(),
entityCount: z.number().min(0, "Entity count must be 0 or higher").optional(),
isSPP: z.boolean().optional(),
});
export const formSchema = z.object({
player: playerSchema,
offense: offenseSchema,
});

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

34
tsconfig.json Normal file
View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}