interactivity!!!̆

This commit is contained in:
2025-09-27 23:44:54 +02:00
parent 459c0c7b74
commit 4547ef8be2
10 changed files with 454 additions and 153 deletions

1
.gitignore vendored
View File

@ -42,3 +42,4 @@ next-env.d.ts
# Large images
/public/tiles
/public/fonts

View File

@ -13,10 +13,12 @@
"leaflet": "^1.9.4",
"next": "15.5.4",
"next-auth": "^5.0.0-beta.29",
"next-safe-action": "^8.0.11",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-leaflet": "^5.0.0",
"react-modal": "^3.16.3",
"zod": "^4.1.11",
},
"devDependencies": {
"@eslint/eslintrc": "^3",
@ -612,6 +614,8 @@
"next-auth": ["next-auth@5.0.0-beta.29", "", { "dependencies": { "@auth/core": "0.40.0" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "next": "^14.0.0-0 || ^15.0.0-0", "nodemailer": "^6.6.5", "react": "^18.2.0 || ^19.0.0-0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-Ukpnuk3NMc/LiOl32njZPySk7pABEzbjhMUFd5/n10I0ZNC7NCuVv8IY2JgbDek2t/PUOifQEoUiOOTLy4os5A=="],
"next-safe-action": ["next-safe-action@8.0.11", "", { "peerDependencies": { "next": ">= 14.0.0", "react": ">= 18.2.0", "react-dom": ">= 18.2.0" } }, "sha512-gqJLmnQLAoFCq1kRBopN46New+vx1n9J9Y/qDQLXpv/VqU40AWxDakvshwwnWAt8R0kLvlakNYNLX5PqlXWSMg=="],
"oauth4webapi": ["oauth4webapi@3.8.1", "", {}, "sha512-olkZDELNycOWQf9LrsELFq8n05LwJgV8UkrS0cburk6FOwf8GvLam+YB+Uj5Qvryee+vwWOfQVeI5Vm0MVg7SA=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
@ -832,6 +836,8 @@
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],

View File

@ -18,10 +18,12 @@
"leaflet": "^1.9.4",
"next": "15.5.4",
"next-auth": "^5.0.0-beta.29",
"next-safe-action": "^8.0.11",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-leaflet": "^5.0.0",
"react-modal": "^3.16.3"
"react-modal": "^3.16.3",
"zod": "^4.1.11"
},
"devDependencies": {
"typescript": "^5",

View File

@ -1,9 +1,64 @@
"use client";
import dynamic from "next/dynamic";
import { useMemo } from "react";
import type {MapData} from "@/app/map"
export function Map(props: {data: MapData}) {
import dynamic from "next/dynamic";
import { ReactNode, RefObject, useMemo, useRef, useState } from "react";
import { Coords, EditStates, MapData, Setter } from "@/app/types"
import { addPOI } from "@/app/db-actions";
import { useAction } from "next-safe-action/hooks";
import { signIn } from "@/auth";
export function Dialog({
ref,
title,
buttons,
children,
}: {
ref: RefObject<HTMLDialogElement | null>
title: ReactNode,
children?: ReactNode,
buttons: {
label: ReactNode,
action?: () => any,
}[]
}) {
return (
<>
<dialog ref={ref} className="dialog-main">
<div className='dialog-title'>
{title}
</div>
<div className='dialog-body'>
<div className='dialog-elems'>
{children}
</div>
<div className='dialog-buttons'>
{buttons.map(({ label, action }, index) =>
<button key={index}
onClick={async () => {
if (action) {
const a = action()
if (a instanceof Promise) await a
}
ref.current?.close()
}}
>{label}</button>
)}
</div>
</div>
</dialog>
</>
)
}
export function Map(props: {
data: MapData,
coords: Coords,
setCoords: Setter<Coords>,
curPath?: Coords[],
setCurPath: Setter<Coords[]>,
editState?: EditStates,
setEditState: Setter<EditStates>
}) {
const It = useMemo(() => dynamic(
() => import("@/app/map"),
{
@ -13,3 +68,36 @@ export function Map(props: {data: MapData}) {
), [])
return <It {...props} />;
}
export function Toolbar(props: { editState: EditStates, setEditState: Setter<EditStates> }) {
const addPOIAction = useAction(addPOI, { onSuccess: () => { alert("Doing stuff I guess"); addPOIAction.reset() } })
return <>
<div className='toolbar'>
{
[
{ label: "Add PoI", state: EditStates.ADD_POI },
{ label: "Add Settlement", state: EditStates.ADD_SETTLEMENT },
{ label: "Add Road", state: EditStates.ADD_ROAD },
{ label: "Add Rail", state: EditStates.ADD_TRAIN },
{ label: "Add Transit Station", state: EditStates.ADD_STOP },
{ label: "Add Country Part", state: EditStates.ADD_COUNTRY },
].map(it =>
<button key={it.state} onClick={() => props.setEditState(it.state)}>{it.label}</button>
)
}
</div>
<p>Current State: {EditStates[props.editState]}</p>
</>
}
export function Client({ data, id }: { data: MapData, id?: string }) {
const [coords, setCoords]: [Coords, Setter<Coords>] = useState([0, 0])
const [editState, setEditState]: [EditStates | undefined, Setter<EditStates>] = useState()
const [curPath, setCurPath]: [Coords[] | undefined, Setter<Coords[]>] = useState()
return <>
<Map {...{ data, coords, setCoords, curPath, setCurPath, editState, setEditState }} />
{
(!id) ? null : <Toolbar {...{editState: editState ?? EditStates.NONE, setEditState}}/>
}
</>
}

31
src/app/db-actions.ts Normal file
View File

@ -0,0 +1,31 @@
"use server";
import { z } from "zod";
import { returnValidationErrors } from "next-safe-action";
import { actionClient } from "@/app/safe-action";
import { auth } from "@/auth";
import { notFound } from "next/navigation";
import { sql } from "bun";
import { revalidatePath } from "next/cache";
const addPOISchema = z.object({
label: z.string().trim().min(1).max(256),
coordinates: z.object({
x: z.number().min(-8000).max(8000),
y: z.number().min(-8000).max(8000)
})
})
export const addPOI = actionClient
.inputSchema(addPOISchema)
.action(async ({parsedInput: { label, coordinates }}) => {
console.log("Arrived in the server action")
const id = (await auth())?.discord_id
if (!id) notFound();
const coords_formatted = `(${coordinates.x},${coordinates.y})`
await sql.begin(async (tx) =>
await tx`INSERT INTO points_of_interest (label, coordinates, last_editor) VALUES (${label}, ${coords_formatted}, ${id}) RETURNING *`
)
revalidatePath("/");
return {};
})

View File

@ -1,12 +1,45 @@
@font-face {
font-family: Andika;
font-feature-settings: "ss13" on;
font-weight: normal;
font-style: normal;
src: url("/fonts/Andika-Regular.ttf");
}
@font-face {
font-family: Andika;
font-feature-settings: "ss13" on;
font-weight: normal;
font-style: italic;
src: url("/fonts/Andika-Italic.ttf");
}
@font-face {
font-family: Andika;
font-feature-settings: "ss13" on;
font-weight: bold;
font-style: normal;
src: url("/fonts/Andika-Bold.ttf");
}
@font-face {
font-family: Andika;
font-feature-settings: "ss13" on;
font-weight: bold;
font-style: italic;
src: url("/fonts/Andika-BoldItalic.ttf");
}
:root {
--background: #fff;
--foreground: #000;
--background-accent: #ccc;
--background-accent2: #bbb;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #000;
--foreground: #fff;
--background-accent: #444;
--background-accent2: #555;
}
}
@ -24,6 +57,7 @@ body {
box-sizing: border-box;
padding: 0;
margin: 0;
font-family: 'Andika', sans-serif;
}
@media (prefers-color-scheme: dark) {
@ -31,3 +65,54 @@ body {
color-scheme: dark;
}
}
.dialog-main {
position: absolute;
inset: 50%;
transform: translate(-50%);
width: 40ch;
max-width: 60ch;
height: 10rem;
color: var(--foreground);
&:open {
display: flex;
}
background-color: var(--background);
flex-direction: column;
}
.dialog-title {
width: 100%;
font-size: 1.25rem;
text-align: center;
background-color: var(--background-accent);
}
.dialog-body {
display: flex;
flex-direction: column;
height: 100%;
}
.dialog-elems {
padding: .5rem;
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
}
.toolbar, .dialog-buttons {
display: flex;
flex-direction: row;
margin-top: auto;
justify-content: space-around;
width: 100%;
margin-bottom: .75rem;
}
.toolbar button, dialog button {
min-width: 10ch;
background-color: var(--background-accent);
&:hover {
background-color: var(--background-accent2);
}
}

View File

@ -1,91 +1,60 @@
"use client";
import { MapContainer, Marker, Rectangle, TileLayer, Tooltip, Polyline, Polygon } from "react-leaflet";
import L, { Icon } from "leaflet";
export type CountryEntry = {
code2: string;
code3: string;
common_name: string;
name?: string;
ruler?: string | string[];
ruler_title?: string;
ruler_link?: string;
founded?: string;
capital?: string;
ung?: "MEMBER" | "OBSERVER" | "FORMER";
ung_joined?: string;
ung_demoted?: string;
ung_left?: string;
dissolved?: string | true;
dissolved_date?: string;
disputed?: string | true;
not_ngation?: true;
condominium?: string[]
};
export type POI = {
coordinates: string;
label: string;
id: number;
last_editor: number | string
};
export type Road = {
id: number;
path: string;
network: string;
name: string;
last_editor: number | string
}
export type CountryPart = {
id: number;
country: string;
shape: string;
last_editor: number | string;
}
export type RailLine = {
id: String;
path: String;
last_editor: number | string;
}
export type Settlement = {
id: number;
coordinates: string;
name: string;
last_editor: number | string
}
export type TransitStop = {
id: string;
coordinates: string;
last_editor: number | string;
}
export type MapData = {
countries: CountryEntry[];
parts: CountryPart[];
pois: POI[];
farms: POI[];
rails: RailLine[];
roads: Road[];
settlements: Settlement[];
stops: TransitStop[];
}
import { CountryEntry, MapData, Coords, Setter, EditStates } from "@/app/types";
import { ReactNode, RefObject, useCallback, useRef, useState } from "react";
import { useAction } from "next-safe-action/hooks";
import { addPOI } from "@/app/db-actions";
import { Dialog } from "@/app/client";
function find_entry(code: string, entries: CountryEntry[]): CountryEntry | undefined {
return entries.find((it) => it.code2 === code || it.code3 === code)
}
function parseCoords(desc: string): [number, number][] {
console.log(desc);
const foo = [...desc.matchAll(/\((-?\d+),(-?\d+)\)/g)]
return foo.map(it => [-+it[2] - .5, +it[1] + .5])
}
export default function Map(props: { data: MapData }) {
const { data } = props;
export default function Map(props: {
data: MapData,
coords: Coords,
setCoords: Setter<Coords>,
curPath?: Coords[],
setCurPath: Setter<Coords[]>,
editState?: EditStates,
setEditState: Setter<EditStates>
}) {
const {
data,
coords,
setCoords,
curPath,
setCurPath,
editState,
setEditState
} = props;
// refs to dialogs
const poiDialog = useRef<HTMLDialogElement>(null);
const poiDialogLabel = useRef<HTMLInputElement>(null);
const addPOIAction = useAction(addPOI, { onSuccess: () => { console.log("running the actions callback"); addPOIAction.reset() } })
const map = useCallback((it: L.Map | null) => {
if (!it) return;
it.off("click");
it.on("click", e => {
setCoords([Math.round(e.latlng.lng), Math.round(-e.latlng.lat)])
console.log("running the on click handler of the map", coords)
//switch (editState) {
//case EditStates.NONE: return;
//case EditStates.ADD_POI: {
poiDialog.current?.showModal();
return;
//}
//}
})
}, [editState, curPath])
return (
<>
<MapContainer
@ -93,7 +62,8 @@ export default function Map(props: { data: MapData }) {
center={[0, 0]}
maxBounds={[[-8192, -8192], [8192, 8192]]}
crs={L.CRS.Simple}
zoom={0}>
zoom={0}
ref={map}>
<TileLayer
url="/tiles/{z}/{x}_{y}.png"
tileSize={512}
@ -124,12 +94,37 @@ export default function Map(props: { data: MapData }) {
{
data.parts.map(it =>
<Polygon key={it.id} positions={parseCoords(it.shape)} stroke={true} color="#A00" fill={true} fillOpacity={.5} fillColor="#A00">
<Tooltip position={parseCoords(it.shape)[0]}>{find_entry(it.country, data.countries)?.common_name}</Tooltip>
<Tooltip>{find_entry(it.country, data.countries)?.common_name}</Tooltip>
</Polygon>
)
}
{data.stops.map(it => <Marker key={it.id} position={parseCoords(it.coordinates)[0]}><Tooltip>{it.id}</Tooltip></Marker>)}
{
data.stops.map(it =>
<Marker key={it.id} position={parseCoords(it.coordinates)[0]}>
<Tooltip>{it.id}</Tooltip>
</Marker>
)
}
</MapContainer>
<Dialog
title="Add Point of Interest"
ref={poiDialog}
buttons={[
{
label: "Add", action: () => {
if (!poiDialogLabel.current) throw Error("WHAT THE FUCK????")
console.log("running the dialogs action")
console.log(poiDialogLabel.current.value, coords[0], coords[1])
addPOIAction.execute({
label: poiDialogLabel.current.value,
coordinates: { x: coords[0], y: coords[1] }
})
}
},
{ label: "Cancel" },
]}
>
<input ref={poiDialogLabel} type="text" placeholder="Point of Interest" />
</Dialog>
</>)
}

View File

@ -1,12 +1,9 @@
import { auth, signIn } from "@/auth";
import { Map } from "@/app/client";
import type {MapData, CountryEntry, CountryPart, POI, RailLine, Settlement, TransitStop, Road} from "@/app/map"
import { Client, /*Map,*/ Toolbar } from "@/app/client";
import { MapData, CountryEntry, CountryPart, POI, RailLine, Settlement, TransitStop, Road, EditStates } from "@/app/types"
import { sql } from "bun"
import { JSDOM } from "jsdom"
export async function getCountries(): Promise<CountryEntry[]> {
let data_raw = await fetch("https://mc.nguh.org/w/api.php?action=parse&page=Data:UŊCDSO%2FCountries&prop=text&format=json")
let data = await data_raw.json()
@ -18,7 +15,8 @@ export async function getCountries () : Promise<CountryEntry[]> {
else return [];
}
async function getMapData() : Promise<MapData> {
export async function getMapData(): Promise<MapData> {
console.log("Fetched the data");
const [countries, parts, pois, farms, rails, roads, settlements, stops]:
[CountryEntry[], CountryPart[], POI[], POI[], RailLine[], Road[], Settlement[], TransitStop[]] = await Promise.all([
getCountries(),
@ -36,11 +34,14 @@ async function getMapData() : Promise<MapData> {
export default async function Home() {
const session = await auth();
const data = await getMapData()
return <>
<Map data={await getMapData()}/>
{(session) ? null : <form action={async () => {
<Client data={data} id={session?.discord_id} />
{
(session) ? null : <form action={async () => {
'use server';
await signIn("discord")
}}><button type="submit">Log In</button></form>}
}}><button type="submit">Log In</button></form>
}
</>
}

3
src/app/safe-action.ts Normal file
View File

@ -0,0 +1,3 @@
import { createSafeActionClient } from "next-safe-action";
export const actionClient = createSafeActionClient();

89
src/app/types.ts Normal file
View File

@ -0,0 +1,89 @@
export type CountryEntry = {
code2: string;
code3: string;
common_name: string;
name?: string;
ruler?: string | string[];
ruler_title?: string;
ruler_link?: string;
founded?: string;
capital?: string;
ung?: "MEMBER" | "OBSERVER" | "FORMER";
ung_joined?: string;
ung_demoted?: string;
ung_left?: string;
dissolved?: string | true;
dissolved_date?: string;
disputed?: string | true;
not_ngation?: true;
condominium?: string[]
};
export type POI = {
coordinates: string;
label: string;
id: number;
last_editor: number | string
};
export type Road = {
id: number;
path: string;
network: string;
name: string;
last_editor: number | string
}
export type CountryPart = {
id: number;
country: string;
shape: string;
last_editor: number | string;
}
export type RailLine = {
id: String;
path: String;
last_editor: number | string;
}
export type Settlement = {
id: number;
coordinates: string;
name: string;
last_editor: number | string
}
export type TransitStop = {
id: string;
coordinates: string;
last_editor: number | string;
}
export type MapData = {
countries: CountryEntry[];
parts: CountryPart[];
pois: POI[];
farms: POI[];
rails: RailLine[];
roads: Road[];
settlements: Settlement[];
stops: TransitStop[];
}
export type Coords = [number, number];
export type Setter<T> = (it: T) => void;
export enum EditStates {
NONE,
ADD_POI,
ADD_SETTLEMENT,
ADD_FARM,
ADD_STOP,
ADD_COUNTRY,
ADD_COUNTRY_PARTIAL,
ADD_ROAD,
ADD_ROAD_PARTIAL,
ADD_TRAIN,
ADD_TRAIN_PARTIAL,
}