diff --git a/.gitignore b/.gitignore index 4f2826f..e889211 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,5 @@ yarn-error.log* next-env.d.ts # Large images -/public/tiles \ No newline at end of file +/public/tiles +/public/fonts \ No newline at end of file diff --git a/bun.lock b/bun.lock index 747de4c..89cbd24 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index 4ead668..cf22cf6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/client.tsx b/src/app/client.tsx index da79844..aa4b402 100644 --- a/src/app/client.tsx +++ b/src/app/client.tsx @@ -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 + title: ReactNode, + children?: ReactNode, + buttons: { + label: ReactNode, + action?: () => any, + }[] +}) { + return ( + <> + +
+ {title} +
+
+
+ {children} +
+
+ {buttons.map(({ label, action }, index) => + + )} +
+
+
+ + ) +} + +export function Map(props: { + data: MapData, + coords: Coords, + setCoords: Setter, + curPath?: Coords[], + setCurPath: Setter, + editState?: EditStates, + setEditState: Setter +}) { const It = useMemo(() => dynamic( () => import("@/app/map"), { @@ -12,4 +67,37 @@ export function Map(props: {data: MapData}) { } ), []) return ; +} + +export function Toolbar(props: { editState: EditStates, setEditState: Setter }) { + const addPOIAction = useAction(addPOI, { onSuccess: () => { alert("Doing stuff I guess"); addPOIAction.reset() } }) + return <> +
+ { + [ + { 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 => + + ) + } +
+

Current State: {EditStates[props.editState]}

+ +} + +export function Client({ data, id }: { data: MapData, id?: string }) { + const [coords, setCoords]: [Coords, Setter] = useState([0, 0]) + const [editState, setEditState]: [EditStates | undefined, Setter] = useState() + const [curPath, setCurPath]: [Coords[] | undefined, Setter] = useState() + return <> + + { + (!id) ? null : + } + } \ No newline at end of file diff --git a/src/app/db-actions.ts b/src/app/db-actions.ts new file mode 100644 index 0000000..47e3544 --- /dev/null +++ b/src/app/db-actions.ts @@ -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 {}; + }) diff --git a/src/app/globals.css b/src/app/globals.css index 17c8891..92103df 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,33 +1,118 @@ +@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: #fff; + --foreground: #000; + --background-accent: #ccc; + --background-accent2: #bbb; } @media (prefers-color-scheme: dark) { - :root { - --background: #000; - --foreground: #fff; - } + :root { + --background: #000; + --foreground: #fff; + --background-accent: #444; + --background-accent2: #555; + } } #map { - width: 100vw; - height: 90vh; + width: 100vw; + height: 90vh; } body { - color: var(--foreground); - background: var(--background); + color: var(--foreground); + background: var(--background); } * { - box-sizing: border-box; - padding: 0; - margin: 0; + box-sizing: border-box; + padding: 0; + margin: 0; + font-family: 'Andika', sans-serif; } @media (prefers-color-scheme: dark) { - html { - color-scheme: dark; - } + html { + 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); + } +} \ No newline at end of file diff --git a/src/app/map.tsx b/src/app/map.tsx index 1a8fa7b..00604ad 100644 --- a/src/app/map.tsx +++ b/src/app/map.tsx @@ -1,91 +1,60 @@ +"use client"; import { MapContainer, Marker, Rectangle, TileLayer, Tooltip, Polyline, Polygon } from "react-leaflet"; import L, { Icon } from "leaflet"; +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"; -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[]; -} - - -function find_entry(code: string, entries: CountryEntry[]) : CountryEntry|undefined { +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); +function parseCoords(desc: string): [number, number][] { 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, + curPath?: Coords[], + setCurPath: Setter, + editState?: EditStates, + setEditState: Setter +}) { + const { + data, + coords, + setCoords, + curPath, + setCurPath, + editState, + setEditState + } = props; + // refs to dialogs + const poiDialog = useRef(null); + const poiDialogLabel = useRef(null); + const addPOIAction = useAction(addPOI, { onSuccess: () => { console.log("running the action’s 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 ( <> + zoom={0} + ref={map}> - { - data.pois.map(it => - - {it.label} + { + data.pois.map(it => + + {it.label} - ) - } - { - data.settlements.map(it => - - {it.name} + ) + } + { + data.settlements.map(it => + + {it.name} - ) - } - { - data.roads.map(it => - - {find_entry(it.network, data.countries)?.common_name} — {it.name} - - ) - } - { - data.parts.map(it => - - {find_entry(it.country, data.countries)?.common_name} - - ) - } - {data.stops.map(it => {it.id})} + ) + } + { + data.roads.map(it => + + {find_entry(it.network, data.countries)?.common_name} — {it.name} + + ) + } + { + data.parts.map(it => + + {find_entry(it.country, data.countries)?.common_name} + + ) + } + { + data.stops.map(it => + + {it.id} + + ) + } - + { + if (!poiDialogLabel.current) throw Error("WHAT THE FUCK????") + console.log("running the dialog’s 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" }, + ]} + > + + ) } \ No newline at end of file diff --git a/src/app/page.tsx b/src/app/page.tsx index c531571..6262604 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,16 +1,13 @@ 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 { +export async function getCountries(): Promise { 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() - let parsed_text : string = data.parse.text["*"]; + let parsed_text: string = data.parse.text["*"]; let parsed_xml = new JSDOM(parsed_text); let json_text = parsed_xml.window.document.querySelector("#jsondata")?.textContent if (json_text != undefined) @@ -18,29 +15,33 @@ export async function getCountries () : Promise { else return []; } -async function getMapData() : Promise { - const [countries, parts, pois, farms, rails, roads, settlements, stops] : +export async function getMapData(): Promise { + 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(), - sql`SELECT * from country_parts`, - sql`SELECT * from points_of_interest`, - sql`SELECT * from public_farms`, - sql`SELECT * from rail_lines`, - sql`SELECT * from roads`, - sql`SELECT * from settlements`, - sql`SELECT * from transit_stops` - ]) + getCountries(), + sql`SELECT * from country_parts`, + sql`SELECT * from points_of_interest`, + sql`SELECT * from public_farms`, + sql`SELECT * from rail_lines`, + sql`SELECT * from roads`, + sql`SELECT * from settlements`, + sql`SELECT * from transit_stops` + ]) return { countries, parts, pois, farms, rails, roads, settlements, stops } } export default async function Home() { const session = await auth(); + const data = await getMapData() return <> - - {(session) ? null :
{ - 'use server'; - await signIn("discord") - }}>
} + + { + (session) ? null :
{ + 'use server'; + await signIn("discord") + }}>
+ } } diff --git a/src/app/safe-action.ts b/src/app/safe-action.ts new file mode 100644 index 0000000..7f62198 --- /dev/null +++ b/src/app/safe-action.ts @@ -0,0 +1,3 @@ +import { createSafeActionClient } from "next-safe-action"; + +export const actionClient = createSafeActionClient(); diff --git a/src/app/types.ts b/src/app/types.ts new file mode 100644 index 0000000..2efa7b3 --- /dev/null +++ b/src/app/types.ts @@ -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 = (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, +} \ No newline at end of file