initial creation
This commit is contained in:
parent
1834d54985
commit
0864fd52d4
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
*.env
|
||||
17
client/index.html
Normal file
17
client/index.html
Normal 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
20
client/package.json
Normal 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
45
client/src/api.ts
Normal 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
85
client/src/auth.ts
Normal 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
19
client/src/main.ts
Normal 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
52
client/src/map.ts
Normal 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:
|
||||
'© <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
113
client/src/pins.ts
Normal 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
120
client/src/style.css
Normal 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
11
client/tsconfig.json
Normal 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
9
client/vite.config.ts
Normal 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
3496
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
package.json
Normal file
15
package.json
Normal 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
28
server/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
49
server/src/cli/create-user.ts
Normal file
49
server/src/cli/create-user.ts
Normal 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
7
server/src/db.ts
Normal 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
44
server/src/index.ts
Normal 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}`);
|
||||
});
|
||||
13
server/src/middleware/requireAuth.ts
Normal file
13
server/src/middleware/requireAuth.ts
Normal 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
57
server/src/routes/auth.ts
Normal 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
79
server/src/routes/pins.ts
Normal 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
25
server/src/schema.sql
Normal 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
8
server/src/types.d.ts
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
import "express-session";
|
||||
|
||||
declare module "express-session" {
|
||||
interface SessionData {
|
||||
userId: number;
|
||||
username: string;
|
||||
}
|
||||
}
|
||||
11
server/tsconfig.json
Normal file
11
server/tsconfig.json
Normal 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
14
shared/package.json
Normal 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
24
shared/src/types.ts
Normal 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
11
shared/tsconfig.json
Normal 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
12
tsconfig.base.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force"
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user