initial creation

This commit is contained in:
Ashley Yakeley 2026-02-23 14:35:23 -08:00
parent 1834d54985
commit 0864fd52d4
27 changed files with 4387 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules
dist
*.env

17
client/index.html Normal file
View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AnnotateMap</title>
<link
rel="stylesheet"
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
/>
</head>
<body>
<div id="map"></div>
<div id="auth-bar"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

20
client/package.json Normal file
View File

@ -0,0 +1,20 @@
{
"name": "annotatemap-client",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"annotatemap-shared": "*",
"leaflet": "^1.9.4"
},
"devDependencies": {
"@types/leaflet": "^1.9.14",
"typescript": "~5.7.2",
"vite": "^6.0.0"
}
}

45
client/src/api.ts Normal file
View File

@ -0,0 +1,45 @@
import type { Pin, CreatePinRequest, MeResponse } from "annotatemap-shared";
const BASE = "/api";
export async function login(
username: string,
password: string,
): Promise<MeResponse> {
const res = await fetch(`${BASE}/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (!res.ok) throw new Error("Login failed");
return res.json();
}
export async function logout(): Promise<void> {
await fetch(`${BASE}/logout`, { method: "POST" });
}
export async function getMe(): Promise<MeResponse> {
const res = await fetch(`${BASE}/me`);
return res.json();
}
export async function getPins(): Promise<Pin[]> {
const res = await fetch(`${BASE}/pins`);
return res.json();
}
export async function createPin(data: CreatePinRequest): Promise<Pin> {
const res = await fetch(`${BASE}/pins`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error("Failed to create pin");
return res.json();
}
export async function deletePin(id: number): Promise<void> {
const res = await fetch(`${BASE}/pins/${id}`, { method: "DELETE" });
if (!res.ok) throw new Error("Failed to delete pin");
}

85
client/src/auth.ts Normal file
View File

@ -0,0 +1,85 @@
import * as api from "./api.ts";
interface User {
id: number;
username: string;
}
let currentUser: User | null = null;
let onAuthChange: (() => void) | null = null;
export function getCurrentUser(): User | null {
return currentUser;
}
export function setOnAuthChange(callback: () => void): void {
onAuthChange = callback;
}
export async function checkAuth(): Promise<void> {
const res = await api.getMe();
currentUser = res.user;
}
export function renderAuthBar(): void {
const bar = document.getElementById("auth-bar")!;
if (currentUser) {
bar.innerHTML = "";
const info = document.createElement("div");
info.className = "user-info";
info.textContent = `Logged in as ${currentUser.username} `;
const logoutBtn = document.createElement("button");
logoutBtn.textContent = "Logout";
logoutBtn.addEventListener("click", async () => {
await api.logout();
currentUser = null;
renderAuthBar();
onAuthChange?.();
});
info.appendChild(logoutBtn);
bar.appendChild(info);
} else {
bar.innerHTML = "";
const form = document.createElement("form");
const userInput = document.createElement("input");
userInput.type = "text";
userInput.placeholder = "Username";
userInput.required = true;
const passInput = document.createElement("input");
passInput.type = "password";
passInput.placeholder = "Password";
passInput.required = true;
const submitBtn = document.createElement("button");
submitBtn.type = "submit";
submitBtn.textContent = "Login";
form.appendChild(userInput);
form.appendChild(passInput);
form.appendChild(submitBtn);
const errorDiv = document.createElement("div");
errorDiv.className = "error";
form.addEventListener("submit", async (e) => {
e.preventDefault();
errorDiv.textContent = "";
try {
const res = await api.login(userInput.value, passInput.value);
currentUser = res.user;
renderAuthBar();
onAuthChange?.();
} catch {
errorDiv.textContent = "Invalid username or password";
}
});
bar.appendChild(form);
bar.appendChild(errorDiv);
}
}

19
client/src/main.ts Normal file
View File

@ -0,0 +1,19 @@
import "./style.css";
import { initMap } from "./map.ts";
import { checkAuth, renderAuthBar, setOnAuthChange } from "./auth.ts";
import { loadPins, setupMapClick } from "./pins.ts";
async function main(): Promise<void> {
initMap();
await checkAuth();
renderAuthBar();
await loadPins();
setupMapClick();
setOnAuthChange(async () => {
renderAuthBar();
await loadPins();
});
}
main();

52
client/src/map.ts Normal file
View File

@ -0,0 +1,52 @@
import L from "leaflet";
const DEFAULT_CENTER: L.LatLngTuple = [51.505, -0.09];
const DEFAULT_ZOOM = 13;
const STORAGE_KEY = "annotatemap-view";
let map: L.Map;
interface StoredView {
lat: number;
lng: number;
zoom: number;
}
export function initMap(): L.Map {
const saved = loadView();
const center: L.LatLngTuple = saved ? [saved.lat, saved.lng] : DEFAULT_CENTER;
const zoom = saved ? saved.zoom : DEFAULT_ZOOM;
map = L.map("map").setView(center, zoom);
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 19,
}).addTo(map);
map.on("moveend", () => {
const c = map.getCenter();
saveView({ lat: c.lat, lng: c.lng, zoom: map.getZoom() });
});
return map;
}
function loadView(): StoredView | null {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
}
function saveView(view: StoredView): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(view));
}
export function getMap(): L.Map {
return map;
}

113
client/src/pins.ts Normal file
View File

@ -0,0 +1,113 @@
import L from "leaflet";
import type { Pin } from "annotatemap-shared";
import * as api from "./api.ts";
import { getCurrentUser } from "./auth.ts";
import { getMap } from "./map.ts";
const pinLayer = L.layerGroup();
function createPinIcon(isOwn: boolean): L.DivIcon {
const color = isOwn ? "#e74c3c" : "#3498db";
return L.divIcon({
className: "custom-pin",
html: `<svg width="25" height="41" viewBox="0 0 25 41">
<path d="M12.5 0C5.6 0 0 5.6 0 12.5C0 21.9 12.5 41 12.5 41S25 21.9 25 12.5C25 5.6 19.4 0 12.5 0Z" fill="${color}"/>
<circle cx="12.5" cy="12.5" r="5" fill="white"/>
</svg>`,
iconSize: [25, 41],
iconAnchor: [12.5, 41],
popupAnchor: [0, -41],
});
}
function buildPinPopup(pin: Pin): string {
const user = getCurrentUser();
const isOwn = user !== null && user.id === pin.userId;
let html = `<div class="pin-popup-info">`;
html += `<strong>${escapeHtml(pin.username)}</strong>`;
if (pin.description) {
html += `<p>${escapeHtml(pin.description)}</p>`;
}
if (isOwn) {
html += `<button class="pin-delete" data-pin-id="${pin.id}">Delete</button>`;
}
html += `</div>`;
return html;
}
function escapeHtml(text: string): string {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
export async function loadPins(): Promise<void> {
const map = getMap();
const pins = await api.getPins();
const user = getCurrentUser();
pinLayer.clearLayers();
pinLayer.addTo(map);
for (const pin of pins) {
const isOwn = user !== null && user.id === pin.userId;
const marker = L.marker([pin.lat, pin.lng], {
icon: createPinIcon(isOwn),
});
marker.bindPopup(buildPinPopup(pin));
marker.on("popupopen", () => {
const deleteBtn = document.querySelector(
`.pin-delete[data-pin-id="${pin.id}"]`,
);
if (deleteBtn) {
deleteBtn.addEventListener("click", async () => {
await api.deletePin(pin.id);
await loadPins();
});
}
});
pinLayer.addLayer(marker);
}
}
export function setupMapClick(): void {
const map = getMap();
map.on("click", (e: L.LeafletMouseEvent) => {
const user = getCurrentUser();
if (!user) return;
const popup = L.popup()
.setLatLng(e.latlng)
.setContent(
`<div class="pin-popup-form">
<textarea id="new-pin-desc" placeholder="Description"></textarea>
<button id="create-pin-btn">Create Pin</button>
</div>`,
)
.openOn(map);
// Wait for popup DOM to be ready
setTimeout(() => {
const btn = document.getElementById("create-pin-btn");
const desc = document.getElementById(
"new-pin-desc",
) as HTMLTextAreaElement;
if (btn && desc) {
btn.addEventListener("click", async () => {
await api.createPin({
lat: e.latlng.lat,
lng: e.latlng.lng,
description: desc.value,
});
map.closePopup(popup);
await loadPins();
});
}
}, 0);
});
}

120
client/src/style.css Normal file
View File

@ -0,0 +1,120 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
}
#map {
width: 100%;
height: 100%;
}
#auth-bar {
position: absolute;
top: 10px;
right: 10px;
z-index: 1000;
background: white;
padding: 10px 14px;
border-radius: 6px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
font-family: system-ui, sans-serif;
font-size: 14px;
}
#auth-bar form {
display: flex;
gap: 6px;
align-items: center;
}
#auth-bar input {
padding: 4px 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
width: 120px;
}
#auth-bar button {
padding: 4px 10px;
border: none;
border-radius: 4px;
background: #3498db;
color: white;
cursor: pointer;
font-size: 14px;
}
#auth-bar button:hover {
background: #2980b9;
}
#auth-bar .user-info {
display: flex;
gap: 10px;
align-items: center;
}
#auth-bar .error {
color: #e74c3c;
margin-top: 4px;
font-size: 12px;
}
.custom-pin {
background: none;
border: none;
}
.pin-popup-form {
display: flex;
flex-direction: column;
gap: 6px;
}
.pin-popup-form textarea {
width: 200px;
height: 60px;
padding: 4px;
border: 1px solid #ccc;
border-radius: 4px;
font-family: system-ui, sans-serif;
font-size: 13px;
resize: vertical;
}
.pin-popup-form button {
padding: 4px 10px;
border: none;
border-radius: 4px;
background: #27ae60;
color: white;
cursor: pointer;
font-size: 13px;
}
.pin-popup-form button:hover {
background: #219a52;
}
.pin-popup-info .pin-delete {
margin-top: 6px;
padding: 2px 8px;
border: none;
border-radius: 4px;
background: #e74c3c;
color: white;
cursor: pointer;
font-size: 12px;
}
.pin-popup-info .pin-delete:hover {
background: #c0392b;
}

11
client/tsconfig.json Normal file
View File

@ -0,0 +1,11 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"allowImportingTsExtensions": true,
"noEmit": true
},
"include": ["src"]
}

9
client/vite.config.ts Normal file
View File

@ -0,0 +1,9 @@
import { defineConfig } from "vite";
export default defineConfig({
server: {
proxy: {
"/api": "http://localhost:3000",
},
},
});

3496
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

15
package.json Normal file
View File

@ -0,0 +1,15 @@
{
"name": "annotatemap",
"private": true,
"workspaces": [
"shared",
"server",
"client"
],
"scripts": {
"dev:server": "npm run dev --workspace=server",
"dev:client": "npm run dev --workspace=client",
"build": "npm run build --workspace=shared && npm run build --workspace=server && npm run build --workspace=client",
"create-user": "npx tsx server/src/cli/create-user.ts"
}
}

28
server/package.json Normal file
View File

@ -0,0 +1,28 @@
{
"name": "annotatemap-server",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"annotatemap-shared": "*",
"bcrypt": "^5.1.1",
"connect-pg-simple": "^10.0.0",
"express": "^5.0.1",
"express-session": "^1.18.1",
"pg": "^8.13.1"
},
"devDependencies": {
"@types/bcrypt": "^5.0.2",
"@types/connect-pg-simple": "^7.0.3",
"@types/express": "^5.0.0",
"@types/express-session": "^1.18.1",
"@types/pg": "^8.11.10",
"tsx": "^4.19.0",
"typescript": "~5.7.2"
}
}

View File

@ -0,0 +1,49 @@
import pg from "pg";
import bcrypt from "bcrypt";
import readline from "readline/promises";
async function main(): Promise<void> {
const username = process.argv[2];
if (!username) {
console.error("Usage: npx tsx server/src/cli/create-user.ts <username>");
process.exit(1);
}
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const password = await rl.question("Password: ");
rl.close();
if (!password) {
console.error("Password cannot be empty");
process.exit(1);
}
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const hash = await bcrypt.hash(password, 10);
try {
await pool.query("INSERT INTO users (username, password) VALUES ($1, $2)", [
username,
hash,
]);
console.log(`User "${username}" created successfully.`);
} catch (err: unknown) {
if (
err instanceof Error &&
"code" in err &&
(err as Record<string, unknown>).code === "23505"
) {
console.error(`User "${username}" already exists.`);
process.exit(1);
} else {
throw err;
}
} finally {
await pool.end();
}
}
main();

7
server/src/db.ts Normal file
View File

@ -0,0 +1,7 @@
import pg from "pg";
const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL,
});
export default pool;

44
server/src/index.ts Normal file
View File

@ -0,0 +1,44 @@
import express from "express";
import session from "express-session";
import connectPgSimple from "connect-pg-simple";
import path from "path";
import { fileURLToPath } from "url";
import pool from "./db.js";
import authRoutes from "./routes/auth.js";
import pinRoutes from "./routes/pins.js";
const app = express();
const PORT = parseInt(process.env.PORT || "3000", 10);
const PgStore = connectPgSimple(session);
app.use(express.json());
app.use(
session({
store: new PgStore({ pool }),
secret: process.env.SESSION_SECRET || "dev-secret-change-in-production",
resave: false,
saveUninitialized: false,
cookie: {
maxAge: 7 * 24 * 60 * 60 * 1000, // 1 week
httpOnly: true,
sameSite: "lax",
},
}),
);
app.use("/api", authRoutes);
app.use("/api/pins", pinRoutes);
// In production, serve the built client files
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const clientDist = path.join(__dirname, "../../client/dist");
app.use(express.static(clientDist));
app.get("*", (_req, res) => {
res.sendFile(path.join(clientDist, "index.html"));
});
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});

View File

@ -0,0 +1,13 @@
import { Request, Response, NextFunction } from "express";
export function requireAuth(
req: Request,
res: Response,
next: NextFunction,
): void {
if (!req.session.userId) {
res.status(401).json({ error: "Not authenticated" });
return;
}
next();
}

57
server/src/routes/auth.ts Normal file
View File

@ -0,0 +1,57 @@
import { Router } from "express";
import bcrypt from "bcrypt";
import pool from "../db.js";
import type { LoginRequest, MeResponse } from "annotatemap-shared";
const router = Router();
router.post("/login", async (req, res) => {
const { username, password } = req.body as LoginRequest;
if (!username || !password) {
res.status(400).json({ error: "Username and password required" });
return;
}
const result = await pool.query(
"SELECT id, username, password FROM users WHERE username = $1",
[username],
);
if (result.rows.length === 0) {
res.status(401).json({ error: "Invalid credentials" });
return;
}
const user = result.rows[0];
const valid = await bcrypt.compare(password, user.password);
if (!valid) {
res.status(401).json({ error: "Invalid credentials" });
return;
}
req.session.userId = user.id;
req.session.username = user.username;
const response: MeResponse = {
user: { id: user.id, username: user.username },
};
res.json(response);
});
router.post("/logout", (req, res) => {
req.session.destroy(() => {
res.json({ ok: true });
});
});
router.get("/me", (req, res) => {
const response: MeResponse = {
user: req.session.userId
? { id: req.session.userId, username: req.session.username! }
: null,
};
res.json(response);
});
export default router;

79
server/src/routes/pins.ts Normal file
View File

@ -0,0 +1,79 @@
import { Router } from "express";
import pool from "../db.js";
import { requireAuth } from "../middleware/requireAuth.js";
import type { CreatePinRequest, Pin } from "annotatemap-shared";
const router = Router();
router.get("/", async (_req, res) => {
const result = await pool.query(
`SELECT pins.id, pins.user_id, users.username, pins.lat, pins.lng,
pins.description, pins.created_at
FROM pins
JOIN users ON pins.user_id = users.id
ORDER BY pins.created_at DESC`,
);
const pins: Pin[] = result.rows.map((row) => ({
id: row.id,
userId: row.user_id,
username: row.username,
lat: row.lat,
lng: row.lng,
description: row.description,
createdAt: row.created_at,
}));
res.json(pins);
});
router.post("/", requireAuth, async (req, res) => {
const { lat, lng, description } = req.body as CreatePinRequest;
if (lat == null || lng == null) {
res.status(400).json({ error: "lat and lng are required" });
return;
}
const result = await pool.query(
`INSERT INTO pins (user_id, lat, lng, description)
VALUES ($1, $2, $3, $4)
RETURNING id, user_id, lat, lng, description, created_at`,
[req.session.userId, lat, lng, description || ""],
);
const row = result.rows[0];
const pin: Pin = {
id: row.id,
userId: row.user_id,
username: req.session.username!,
lat: row.lat,
lng: row.lng,
description: row.description,
createdAt: row.created_at,
};
res.status(201).json(pin);
});
router.delete("/:id", requireAuth, async (req, res) => {
const rawId = req.params.id;
const pinId = parseInt(Array.isArray(rawId) ? rawId[0] : rawId, 10);
if (isNaN(pinId)) {
res.status(400).json({ error: "Invalid pin ID" });
return;
}
const result = await pool.query(
"DELETE FROM pins WHERE id = $1 AND user_id = $2 RETURNING id",
[pinId, req.session.userId],
);
if (result.rowCount === 0) {
res.status(404).json({ error: "Pin not found or not owned by you" });
return;
}
res.json({ ok: true });
});
export default router;

25
server/src/schema.sql Normal file
View File

@ -0,0 +1,25 @@
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS pins (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
lat DOUBLE PRECISION NOT NULL,
lng DOUBLE PRECISION NOT NULL,
description TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_pins_user_id ON pins(user_id);
CREATE TABLE IF NOT EXISTS session (
sid VARCHAR NOT NULL COLLATE "default",
sess JSON NOT NULL,
expire TIMESTAMP(6) NOT NULL,
PRIMARY KEY (sid)
);
CREATE INDEX IF NOT EXISTS idx_session_expire ON session(expire);

8
server/src/types.d.ts vendored Normal file
View File

@ -0,0 +1,8 @@
import "express-session";
declare module "express-session" {
interface SessionData {
userId: number;
username: string;
}
}

11
server/tsconfig.json Normal file
View File

@ -0,0 +1,11 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"outDir": "dist",
"rootDir": "src",
"esModuleInterop": true
},
"include": ["src"]
}

14
shared/package.json Normal file
View File

@ -0,0 +1,14 @@
{
"name": "annotatemap-shared",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "dist/types.js",
"types": "dist/types.d.ts",
"scripts": {
"build": "tsc"
},
"devDependencies": {
"typescript": "~5.7.2"
}
}

24
shared/src/types.ts Normal file
View File

@ -0,0 +1,24 @@
export interface Pin {
id: number;
userId: number;
username: string;
lat: number;
lng: number;
description: string;
createdAt: string;
}
export interface CreatePinRequest {
lat: number;
lng: number;
description: string;
}
export interface MeResponse {
user: { id: number; username: string } | null;
}
export interface LoginRequest {
username: string;
password: string;
}

11
shared/tsconfig.json Normal file
View File

@ -0,0 +1,11 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"declaration": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}

12
tsconfig.base.json Normal file
View File

@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2020",
"strict": true,
"skipLibCheck": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"isolatedModules": true,
"moduleDetection": "force"
}
}