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