maps_view.js 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496
  1. const { form, button, div, h2, h3, p, section, input, label, br, a, span, textarea, select, option, img, strong } =
  2. require("../server/node_modules/hyperaxe");
  3. const moment = require("../server/node_modules/moment");
  4. const { template, i18n, userLink} = require("./main_views");
  5. const { config } = require("../server/SSB_server.js");
  6. const { renderMapWithPins, renderZoomedMapWithPins, getViewportBounds, latLngToPx, pxToLatLng, MAP_W, MAP_H, getMaxTileZoom } = require("../maps/map_renderer");
  7. const { sanitizeHtml } = require('../backend/sanitizeHtml');
  8. const userId = config.keys.id;
  9. const safeArr = (v) => (Array.isArray(v) ? v : []);
  10. const safeText = (v) => String(v || "").trim();
  11. const buildReturnTo = (filter, params = {}) => {
  12. const f = safeText(filter || "all");
  13. const q = safeText(params.q || "");
  14. const parts = [`filter=${encodeURIComponent(f)}`];
  15. if (q) parts.push(`q=${encodeURIComponent(q)}`);
  16. return `/maps?${parts.join("&")}`;
  17. };
  18. const renderPMButton = (recipient) => {
  19. const r = safeText(recipient);
  20. if (!r || String(r) === String(userId)) return null;
  21. return form({ method: "GET", action: "/pm" },
  22. input({ type: "hidden", name: "recipients", value: r }),
  23. button({ type: "submit", class: "filter-btn" }, i18n.privateMessage));
  24. };
  25. const renderTags = (tags) => {
  26. const list = safeArr(tags).map((t) => String(t || "").trim()).filter(Boolean);
  27. return list.length
  28. ? div({ class: "card-tags" }, list.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)))
  29. : null;
  30. };
  31. const renderMapFavoriteToggle = (mapObj, returnTo = "") =>
  32. form({
  33. method: "POST",
  34. action: mapObj.isFavorite ? `/maps/favorites/remove/${encodeURIComponent(mapObj.key)}` : `/maps/favorites/add/${encodeURIComponent(mapObj.key)}`
  35. },
  36. returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
  37. button({ type: "submit", class: "filter-btn" }, mapObj.isFavorite ? i18n.mapRemoveFavoriteButton : i18n.mapAddFavoriteButton));
  38. let areaCounter = 0;
  39. const buildAreas = (clickUrl, latParam = "lat", lngParam = "lng", viewport = null) => {
  40. const GRID = 16;
  41. const cellW = MAP_W / GRID;
  42. const cellH = MAP_H / GRID;
  43. const areas = [];
  44. for (let gy = 0; gy < GRID; gy++) {
  45. for (let gx = 0; gx < GRID; gx++) {
  46. let c;
  47. if (viewport) {
  48. const lat = viewport.latMax - (gy + 0.5) / GRID * (viewport.latMax - viewport.latMin);
  49. const lng = viewport.lngMin + (gx + 0.5) / GRID * (viewport.lngMax - viewport.lngMin);
  50. c = { lat: Math.round(lat * 10000) / 10000, lng: Math.round(lng * 10000) / 10000 };
  51. } else {
  52. const cx = Math.round(gx * cellW + cellW / 2);
  53. const cy = Math.round(gy * cellH + cellH / 2);
  54. c = pxToLatLng(cx, cy);
  55. }
  56. const x1 = Math.round(gx * cellW);
  57. const y1 = Math.round(gy * cellH);
  58. const x2 = Math.round((gx + 1) * cellW);
  59. const y2 = Math.round((gy + 1) * cellH);
  60. areas.push(`<area shape="rect" coords="${x1},${y1},${x2},${y2}" href="${clickUrl}${latParam}=${c.lat}&amp;${lngParam}=${c.lng}" alt="${c.lat},${c.lng}">`);
  61. }
  62. }
  63. return areas;
  64. };
  65. const renderMap = (markers, clickUrl, mainIdx, opts = {}) => {
  66. areaCounter++;
  67. const mapName = `m${areaCounter}`;
  68. const latParam = opts.latParam || "lat";
  69. const lngParam = opts.lngParam || "lng";
  70. const pinLabels = opts.pinLabels || [];
  71. const pinImages = opts.pinImages || [];
  72. const pfx = opts.pinPrefix || `pin${areaCounter}`;
  73. const zoom = parseInt(opts.zoom) || 2;
  74. const centerLat = typeof opts.centerLat === "number" ? opts.centerLat : 0;
  75. const centerLng = typeof opts.centerLng === "number" ? opts.centerLng : 0;
  76. const pinList = safeArr(markers).filter((m) => m && typeof m.lat === "number" && typeof m.lng === "number");
  77. const useZoom = zoom > 2;
  78. const mapFile = useZoom
  79. ? renderZoomedMapWithPins(centerLat, centerLng, zoom, pinList, mainIdx)
  80. : (pinList.length > 0 ? renderMapWithPins(pinList, mainIdx) : null);
  81. const imgSrc = mapFile ? `/mapcache/${mapFile}` : "/assets/images/worldmap-z2.png";
  82. const viewport = useZoom && clickUrl ? getViewportBounds(centerLat, centerLng, zoom) : null;
  83. const useMap = clickUrl || pinLabels.length > 0;
  84. const mapTag = useMap ? mapName : "";
  85. let gridAreasHtml = "";
  86. if (clickUrl) {
  87. const clickUrlWithZoom = zoom > 2 ? `${clickUrl}zoom=${zoom}&` : clickUrl;
  88. gridAreasHtml = buildAreas(clickUrlWithZoom, latParam, lngParam, viewport).join("");
  89. }
  90. let popupAreasHtml = "";
  91. let popupsHtml = "";
  92. if (pinLabels.length > 0) {
  93. const vp = useZoom ? getViewportBounds(centerLat, centerLng, zoom) : null;
  94. pinList.forEach((m, i) => {
  95. const lbl = pinLabels[i] || "";
  96. let px;
  97. if (vp) {
  98. px = {
  99. x: ((m.lng - vp.lngMin) / (vp.lngMax - vp.lngMin)) * MAP_W,
  100. y: ((vp.latMax - m.lat) / (vp.latMax - vp.latMin)) * MAP_H
  101. };
  102. } else {
  103. px = latLngToPx(m.lat, m.lng);
  104. }
  105. const escaped = lbl.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
  106. const withLinks = escaped.replace(/https?:\/\/[^\s&"<>]+/g, (url) => {
  107. const clean = url.replace(/&amp;/g, "&");
  108. return `<a href="${clean}" class="map-popup-link" target="_blank" rel="noopener">${url}</a>`;
  109. }).replace(/\n/g, "<br>");
  110. const sz = 20 * Math.pow(2, Math.max(0, zoom - getMaxTileZoom()));
  111. const x1 = Math.max(0, px.x - sz);
  112. const y1 = Math.max(0, px.y - sz);
  113. const x2 = Math.min(MAP_W, px.x + sz);
  114. const y2 = Math.min(MAP_H, px.y + sz);
  115. const popupId = `${pfx}_${i}`;
  116. const latStr = typeof m.lat === "number" ? m.lat.toFixed(4) : "";
  117. const lngStr = typeof m.lng === "number" ? m.lng.toFixed(4) : "";
  118. const imgBlobId = pinImages[i] && String(pinImages[i]).startsWith("&") ? pinImages[i] : "";
  119. const imgHtml = imgBlobId ? `<img src="/blob/${encodeURIComponent(imgBlobId)}" class="map-popup-img" alt="">` : "";
  120. popupAreasHtml += `<area shape="rect" coords="${x1},${y1},${x2},${y2}" title="${escaped}" alt="${escaped}" href="#${popupId}">`;
  121. popupsHtml += `<div id="${popupId}" class="map-popup"><div class="map-popup-box"><a href="#" class="map-popup-close">&#x2715;</a>${imgHtml}<div class="map-popup-label">${sanitizeHtml(withLinks)}</div><div class="map-popup-coords">${latStr}, ${lngStr}</div></div></div>`;
  122. });
  123. }
  124. const mapHtml = useMap ? `<map name="${mapTag}">${popupAreasHtml}${gridAreasHtml}</map>` : "";
  125. const useAttr = useMap ? ` usemap="#${mapTag}"` : "";
  126. const mapWrapHtml = `<div class="map-wrap"><img src="${imgSrc}" class="map-img" alt="map"${useAttr}>${mapHtml}</div>`;
  127. const viewerEl = div({ class: "map-viewer" }, { innerHTML: mapWrapHtml });
  128. if (!popupsHtml) return viewerEl;
  129. return div({ class: "map-zone" }, viewerEl, div({ class: "map-popup-container", innerHTML: popupsHtml }));
  130. };
  131. const renderCoordPreview = (lat, lng) => {
  132. if (!lat && !lng) return null;
  133. return span({ class: "map-coord-inline" },
  134. span({ class: "map-coord-pin" }, "📍"),
  135. strong(`${lat}, ${lng}`));
  136. };
  137. const renderLocalEmbed = (lat, lng) => {
  138. const la = parseFloat(lat) || 0;
  139. const lo = parseFloat(lng) || 0;
  140. if (!la && !lo) return null;
  141. return renderMap([{ lat: la, lng: lo }], null, 0);
  142. };
  143. const renderMapUrl = (mapObj) =>
  144. div({ class: "map-url-container" },
  145. span({ class: "card-label" }, i18n.mapUrlLabel + ": "),
  146. a({ href: `/maps/${encodeURIComponent(mapObj.key)}`, class: "map-url-link" },
  147. `/maps/${encodeURIComponent(mapObj.key)}`));
  148. const renderMapOwnerActions = (filter, mapObj, params = {}) => {
  149. const returnTo = buildReturnTo(filter, params);
  150. if (String(mapObj.author) !== String(userId)) return [];
  151. return [
  152. form({ method: "GET", action: `/maps/edit/${encodeURIComponent(mapObj.key)}` },
  153. input({ type: "hidden", name: "returnTo", value: returnTo }),
  154. button({ class: "update-btn", type: "submit" }, i18n.mapUpdateButton)),
  155. form({ method: "POST", action: `/maps/delete/${encodeURIComponent(mapObj.key)}` },
  156. input({ type: "hidden", name: "returnTo", value: returnTo }),
  157. button({ class: "delete-btn", type: "submit" }, i18n.mapDeleteButton))
  158. ];
  159. };
  160. const renderFilters = (filter, q) =>
  161. div({ class: "filters" },
  162. form({ method: "GET", action: "/maps", class: "ui-toolbar ui-toolbar--filters" },
  163. input({ type: "hidden", name: "q", value: q || "" }),
  164. button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.mapFilterAll),
  165. button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.mapFilterMine),
  166. button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.mapFilterRecent),
  167. button({ type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" }, i18n.mapFilterFavorites),
  168. button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.mapUploadButton)));
  169. const renderMapForm = (filter, mapId, mapToEdit, params = {}) => {
  170. const returnFilter = filter === "create" ? "all" : params.filter || "all";
  171. const returnTo = safeText(params.returnTo) || buildReturnTo(returnFilter, params);
  172. const latVal = params.lat !== undefined ? String(params.lat) : String(mapToEdit?.lat || "");
  173. const lngVal = params.lng !== undefined ? String(params.lng) : String(mapToEdit?.lng || "");
  174. const titleVal = params.title || mapToEdit?.title || "";
  175. const descVal = params.description || mapToEdit?.description || "";
  176. const markerLabelVal = params.markerLabel !== undefined ? params.markerLabel : (mapToEdit?.markerLabel || "");
  177. const tagsValue = params.tags !== undefined ? params.tags : safeArr(mapToEdit?.tags).join(", ");
  178. const mapTypeVal = params.mapType || mapToEdit?.mapType || "SINGLE";
  179. const maxTileZoom = getMaxTileZoom();
  180. const zoomVal = parseInt(params.zoom) || 2;
  181. const cleanUrl = `/maps?filter=create${params.tribeId ? '&tribeId=' + encodeURIComponent(params.tribeId) : ''}`;
  182. const pickerMarkers = latVal && lngVal ? [{ lat: parseFloat(latVal), lng: parseFloat(lngVal) }] : [];
  183. return div({ class: "map-create-layout" },
  184. div({ class: "map-form map-form-full" },
  185. form({
  186. action: filter === "edit" ? `/maps/update/${encodeURIComponent(mapId)}` : "/maps/create",
  187. method: "POST",
  188. enctype: "multipart/form-data"
  189. },
  190. input({ type: "hidden", name: "returnTo", value: returnTo }),
  191. input({ type: "hidden", name: "filter", value: "create" }),
  192. params.tribeId ? input({ type: "hidden", name: "tribeId", value: params.tribeId }) : null,
  193. label(i18n.title || "Title"),
  194. input({ type: "text", name: "title", placeholder: i18n.mapTitlePlaceholder || "Map title", value: titleVal }),
  195. label(i18n.mapDescriptionLabel),
  196. textarea({ name: "description", placeholder: i18n.mapDescriptionPlaceholder, rows: "3" }, descVal),
  197. label(i18n.mapTagsLabel),
  198. input({ type: "text", name: "tags", placeholder: i18n.mapTagsPlaceholder, value: tagsValue }),
  199. label(i18n.mapTypeLabel),
  200. select({ name: "mapType" },
  201. option({ value: "SINGLE", ...(mapTypeVal === "SINGLE" ? { selected: true } : {}) }, "SINGLE"),
  202. option({ value: "OPEN", ...(mapTypeVal === "OPEN" ? { selected: true } : {}) }, "OPEN"),
  203. option({ value: "CLOSED", ...(mapTypeVal === "CLOSED" ? { selected: true } : {}) }, "CLOSED")),
  204. br(),br(),
  205. label(i18n.mapMarkerLabelField),
  206. textarea({ name: "markerLabel", placeholder: i18n.mapMarkerLabelPlaceholder, rows: "3" }, markerLabelVal),
  207. label(i18n.markerImageLabel || "Marker Image"),
  208. input({ type: "file", name: "image", accept: "image/*" }),
  209. br(), br(),
  210. label(i18n.mapLatLabel),
  211. input({ type: "text", name: "lat", placeholder: i18n.mapLatPlaceholder, value: latVal }),
  212. label(i18n.mapLngLabel),
  213. input({ type: "text", name: "lng", placeholder: i18n.mapLngPlaceholder, value: lngVal }),
  214. div({ class: "map-form-row" },
  215. button({ type: "submit", attrs: { formmethod: "GET" }, formaction: "/maps", class: "filter-btn" }, i18n.mapAddMarkerButton || "Add Marker"),
  216. a({ href: cleanUrl, class: "filter-btn" }, i18n.mapCleanMarkerButton || "Clean Marker")),
  217. renderCoordPreview(latVal, lngVal),
  218. label(i18n.mapZoomLabel || "Zoom"),
  219. select({ name: "zoom" },
  220. [2, 3, 4, 5, 6, 7, 8].map(z =>
  221. option({ value: String(z), ...(zoomVal === z ? { selected: true } : {}) }, String(z)))),
  222. br(),br(),
  223. button({ type: "submit", attrs: { formmethod: "GET" }, formaction: "/maps", class: "filter-btn" }, i18n.mapApplyZoom || "Apply Zoom"),
  224. div({ class: "map-form-map-slot" },
  225. renderMap(pickerMarkers, null, 0, { zoom: zoomVal, centerLat: parseFloat(latVal) || 0, centerLng: parseFloat(lngVal) || 0 })),
  226. button({ type: "submit", class: "create-button" }, filter === "edit" ? i18n.mapUpdateButton : i18n.mapCreateButton))));
  227. };
  228. const renderMarkerForm = (mapObj, returnTo, params = {}, tribeMembers = []) => {
  229. if (mapObj.mapType === "SINGLE") return null;
  230. if (mapObj.mapType === "CLOSED" && String(mapObj.author) !== String(userId)) return null;
  231. if (mapObj.mapType === "OPEN" && mapObj.tribeId && !tribeMembers.includes(userId)) return null;
  232. const mkLat = params.mkLat || "";
  233. const mkLng = params.mkLng || "";
  234. const zoomVal = parseInt(params.zoom) || 2;
  235. const existingMarkers = [{ lat: mapObj.lat, lng: mapObj.lng }].concat(
  236. safeArr(mapObj.markers).map((m) => ({ lat: m.lat, lng: m.lng })));
  237. if (mkLat && mkLng) existingMarkers.push({ lat: parseFloat(mkLat), lng: parseFloat(mkLng) });
  238. const pinLabels = [mapObj.markerLabel || mapObj.description || mapObj.title || ""].concat(
  239. safeArr(mapObj.markers).map((m) => m.label || ""));
  240. const mkCleanUrl = `/maps/${encodeURIComponent(mapObj.key)}?filter=${encodeURIComponent(params.filter || "all")}`;
  241. const clickUrl = `/maps/${encodeURIComponent(mapObj.key)}?filter=${encodeURIComponent(params.filter || "all")}&zoom=${zoomVal}&`;
  242. return div({ class: "map-marker-form", id: "add-marker" },
  243. h3(i18n.mapAddMarkerTitle),
  244. form({ method: "POST", action: `/maps/${encodeURIComponent(mapObj.key)}/marker`, class: "map-form", enctype: "multipart/form-data" },
  245. returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
  246. label(i18n.mapMarkerLabelField),
  247. textarea({ name: "label", placeholder: i18n.mapMarkerLabelPlaceholder, rows: "3" }, params.mkMarkerLabel || ""),
  248. label(i18n.markerImageLabel || "Marker Image"),
  249. input({ type: "file", name: "image", accept: "image/*" }),
  250. br(),br(),
  251. label(i18n.mapMarkerLatLabel),
  252. input({ type: "text", name: "mkLat", placeholder: i18n.mapLatPlaceholder, value: String(mkLat) }),
  253. label(i18n.mapMarkerLngLabel),
  254. input({ type: "text", name: "mkLng", placeholder: i18n.mapLngPlaceholder, value: String(mkLng) }),
  255. div({ class: "map-form-row" },
  256. button({ type: "submit", attrs: { formmethod: "GET" }, formaction: `/maps/${encodeURIComponent(mapObj.key)}`, class: "filter-btn" }, i18n.mapAddMarkerButton || "Add Marker"),
  257. a({ href: mkCleanUrl, class: "filter-btn" }, i18n.mapCleanMarkerButton || "Clean Marker")),
  258. renderCoordPreview(mkLat, mkLng),
  259. label(i18n.mapZoomLabel || "Zoom"),
  260. select({ name: "zoom" },
  261. [2, 3, 4, 5, 6, 7, 8].map(z =>
  262. option({ value: String(z), ...(zoomVal === z ? { selected: true } : {}) }, String(z)))),
  263. br(),br(),
  264. button({ type: "submit", attrs: { formmethod: "GET" }, formaction: `/maps/${encodeURIComponent(mapObj.key)}`, class: "filter-btn" }, i18n.mapApplyZoom || "Apply Zoom"),
  265. div({ class: "map-form-map-slot" },
  266. renderMap(existingMarkers, clickUrl, 0, { latParam: "mkLat", lngParam: "mkLng", pinLabels, pinPrefix: `mk${areaCounter}`, zoom: zoomVal, centerLat: parseFloat(mkLat) || parseFloat(mapObj.lat) || 0, centerLng: parseFloat(mkLng) || parseFloat(mapObj.lng) || 0 })),
  267. button({ type: "submit", class: "create-button" }, i18n.mapAddMarkerButton)));
  268. };
  269. const renderMarkersList = (markers, mapObj) => {
  270. const allMarkers = [];
  271. if (mapObj) {
  272. allMarkers.push({
  273. lat: mapObj.lat,
  274. lng: mapObj.lng,
  275. label: mapObj.markerLabel || mapObj.description || mapObj.title || i18n.mapMarkerDefault,
  276. author: mapObj.author,
  277. createdAt: mapObj.createdAt
  278. });
  279. }
  280. allMarkers.push(...safeArr(markers));
  281. if (!allMarkers.length) return null;
  282. return div({ class: "map-markers-list" },
  283. h3(i18n.mapMarkersTitle),
  284. br(),
  285. div(allMarkers.flatMap((mk, i) => [
  286. ...(i > 0 ? [br()] : []),
  287. div({ class: "map-marker-info" },
  288. span({ class: "map-marker-dot" }, "ꔌ"),
  289. span({ class: "map-marker-coords" }, `${(typeof mk.lat === 'number' ? mk.lat : 0).toFixed(4)}, ${(typeof mk.lng === 'number' ? mk.lng : 0).toFixed(4)}`),
  290. span({ class: "map-marker-meta" },
  291. userLink(mk.author),
  292. ` · ${moment(mk.createdAt).fromNow()}`))
  293. ])));
  294. };
  295. const renderMapCard = (mapObj, filter, params = {}) => {
  296. const returnTo = buildReturnTo(filter, params);
  297. const ownerActions = renderMapOwnerActions(filter, mapObj, params);
  298. const markerCount = safeArr(mapObj.markers).length;
  299. const thumbMarkers = [{ lat: mapObj.lat, lng: mapObj.lng }].concat(
  300. safeArr(mapObj.markers).map((m) => ({ lat: m.lat, lng: m.lng })));
  301. const thumbFile = renderMapWithPins(thumbMarkers, 0);
  302. const thumbSrc = thumbFile ? `/mapcache/${thumbFile}` : "/assets/images/worldmap-z2.png";
  303. return div({ class: "map-card" },
  304. a({ href: `/maps/${encodeURIComponent(mapObj.key)}?filter=${encodeURIComponent(filter)}`, class: "map-card-thumb-link" },
  305. { innerHTML: `<img src="${thumbSrc}" class="map-card-thumb" alt="map">` }),
  306. div({ class: "map-card-body" },
  307. mapObj.title ? h2(a({ href: `/maps/${encodeURIComponent(mapObj.key)}?filter=${encodeURIComponent(filter)}` }, mapObj.title)) : null,
  308. div({ class: "map-card-header" },
  309. div({ class: "map-card-info" },
  310. span({ class: "map-type-badge" }, mapObj.mapType),
  311. span({ class: "map-coords" }, `📍 ${mapObj.lat.toFixed(4)}, ${mapObj.lng.toFixed(4)}`),
  312. markerCount > 0 ? span({ class: "map-marker-count" }, `▾ ${markerCount}`) : null,
  313. mapObj.key ? renderMapUrl(mapObj) : null),
  314. div({ class: "map-card-actions" },
  315. form({ method: "GET", action: `/maps/${encodeURIComponent(mapObj.key)}` },
  316. input({ type: "hidden", name: "returnTo", value: returnTo }),
  317. input({ type: "hidden", name: "filter", value: filter || "all" }),
  318. button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)),
  319. renderMapFavoriteToggle(mapObj, returnTo),
  320. renderPMButton(mapObj.author),
  321. ...ownerActions)),
  322. safeText(mapObj.description) ? p({ class: "map-description" }, mapObj.description) : null,
  323. p({ class: "card-footer" },
  324. span({ class: "date-link" }, moment(mapObj.createdAt).fromNow()),
  325. span(" · "),
  326. userLink(mapObj.author))));
  327. };
  328. const renderMapList = (maps, filter, params = {}) =>
  329. maps.length
  330. ? maps.map((mapObj) => renderMapCard(mapObj, filter, params))
  331. : p(params.q ? i18n.mapNoMatch : i18n.noMaps);
  332. exports.mapsView = async (maps, filter = "all", mapId = null, params = {}) => {
  333. const title = filter === "mine" ? i18n.mapMineSectionTitle
  334. : filter === "create" ? i18n.mapCreateSectionTitle
  335. : filter === "edit" ? i18n.mapUpdateSectionTitle
  336. : filter === "recent" ? i18n.mapRecentSectionTitle
  337. : filter === "favorites" ? i18n.mapFavoritesSectionTitle
  338. : i18n.mapAllSectionTitle;
  339. const q = safeText(params.q || "");
  340. const list = safeArr(maps);
  341. const mapToEdit = mapId ? list.find((m) => m.key === mapId) : null;
  342. const allMarkers = list.map((m) => ({ lat: m.lat, lng: m.lng, href: `/maps/${encodeURIComponent(m.key)}` }));
  343. return template(title,
  344. section(
  345. div({ class: "tags-header" }, h2(title), p(i18n.mapDescription)),
  346. renderFilters(filter, q)),
  347. section(
  348. filter === "create" || filter === "edit"
  349. ? renderMapForm(filter, mapId, mapToEdit, { ...params, filter })
  350. : section(
  351. div({ class: "maps-search" },
  352. form({ method: "GET", action: "/maps", class: "filter-box" },
  353. input({ type: "hidden", name: "filter", value: filter }),
  354. input({ type: "text", name: "q", value: q, placeholder: i18n.mapSearchPlaceholder, class: "filter-box__input" }),
  355. div({ class: "filter-box__controls" }, button({ type: "submit", class: "filter-box__button" }, i18n.mapSearchButton)))),
  356. div({ class: "maps-list" }, renderMapList(list, filter, { q })))));
  357. };
  358. exports.singleMapView = async (mapObj, filter = "all", params = {}) => {
  359. const q = safeText(params.q || "");
  360. const returnTo = safeText(params.returnTo) || buildReturnTo(filter, { q });
  361. const ownerActions = renderMapOwnerActions(filter, mapObj, { q });
  362. const tribeMembers = safeArr(params.tribeMembers);
  363. const zoomVal = parseInt(params.zoom) || 2;
  364. const allMarkers = [{ lat: mapObj.lat, lng: mapObj.lng }].concat(
  365. safeArr(mapObj.markers).map((m) => ({ lat: m.lat, lng: m.lng })));
  366. const pinLabels = [mapObj.markerLabel || mapObj.description || mapObj.title || ""].concat(
  367. safeArr(mapObj.markers).map((m) => m.label || ""));
  368. const pinImages = [mapObj.image || ""].concat(safeArr(mapObj.markers).map((m) => m.image || ""));
  369. return template(mapObj.title || i18n.mapTitle,
  370. section(renderFilters(filter, q)),
  371. section(
  372. div({ class: "map-detail" },
  373. mapObj.title ? h2(mapObj.title) : null,
  374. safeText(mapObj.description) ? p({ class: "map-description" }, mapObj.description) : null,
  375. div({ class: "map-detail-header" },
  376. div({ class: "map-detail-info" },
  377. span({ class: "map-type-badge" }, mapObj.mapType),
  378. span({ class: "map-coords-detail" }, `📍 ${mapObj.lat.toFixed(6)}, ${mapObj.lng.toFixed(6)}`)),
  379. div({ class: "map-detail-actions" },
  380. renderMapFavoriteToggle(mapObj, returnTo),
  381. renderPMButton(mapObj.author),
  382. ...ownerActions)),
  383. renderMapUrl(mapObj),
  384. br(),
  385. form({ method: "GET", action: `/maps/${encodeURIComponent(mapObj.key)}` },
  386. label(i18n.mapZoomLabel || "Zoom"),
  387. br(),
  388. select({ name: "zoom" },
  389. [2, 3, 4, 5, 6, 7, 8].map(z =>
  390. option({ value: String(z), ...(zoomVal === z ? { selected: true } : {}) }, String(z)))),
  391. br(), br(),
  392. button({ type: "submit", class: "filter-btn" }, i18n.mapApplyZoom || "Apply Zoom")),
  393. br(),
  394. renderMap(allMarkers, null, 0, { pinLabels, pinImages, pinPrefix: `detail${areaCounter}`, zoom: zoomVal, centerLat: parseFloat(mapObj.lat) || 0, centerLng: parseFloat(mapObj.lng) || 0 }),
  395. renderMarkersList(mapObj.markers, mapObj),
  396. renderTags(mapObj.tags),
  397. br(),
  398. p({ class: "card-footer" },
  399. span({ class: "date-link" }, `${moment(mapObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
  400. userLink(mapObj.author),
  401. mapObj.updatedAt && mapObj.updatedAt !== mapObj.createdAt
  402. ? span({ class: "votations-comment-date" }, ` · ${i18n.mapUpdatedAt}: ${moment(mapObj.updatedAt).format("YYYY/MM/DD HH:mm:ss")}`)
  403. : null),
  404. renderMarkerForm(mapObj, returnTo, params, tribeMembers))));
  405. };
  406. exports.renderMapLocationUrl = (mapUrl) => {
  407. if (!mapUrl) return null;
  408. return span({ class: "map-location-inline" },
  409. span({ class: "map-location-icon" }, "ꔌ"),
  410. a({ href: mapUrl, class: "map-location-link" }, mapUrl));
  411. };
  412. exports.renderMapLocationVisitLabel = (mapUrl) => {
  413. if (!mapUrl) return null;
  414. return div({ class: "card-field" },
  415. span({ class: "card-label" }, (i18n.mapLocationTitle || "Map Location") + ":"),
  416. span({ class: "card-value" },
  417. a({ href: mapUrl, class: "map-location-link" }, i18n.mapVisitLabel || "Visit map")));
  418. };
  419. exports.renderMapEmbed = (mapData, mapUrl) => {
  420. if (!mapData || (parseFloat(mapData.lat) === 0 && parseFloat(mapData.lng) === 0))
  421. return exports.renderMapLocationVisitLabel(mapUrl);
  422. return div({ class: "map-embed-section" },
  423. span({ class: "card-label" }, (i18n.mapLocationTitle || "Map Location") + ":"),
  424. span({ class: "card-value map-zoom-info" }, "Zoom: 2"),
  425. renderLocalEmbed(mapData.lat, mapData.lng),
  426. mapUrl ? div({ class: "map-embed-url" },
  427. a({ href: mapUrl, class: "map-location-link" }, mapUrl)) : null);
  428. };
  429. exports.renderMapEmbedWithZoom = (mapData, mapUrl, detailUrl, zoom) => {
  430. if (!mapData || (parseFloat(mapData.lat) === 0 && parseFloat(mapData.lng) === 0))
  431. return exports.renderMapLocationVisitLabel(mapUrl);
  432. const zoomVal = parseInt(zoom) || 2;
  433. const la = parseFloat(mapData.lat) || 0;
  434. const lo = parseFloat(mapData.lng) || 0;
  435. return div({ class: "map-embed-section" },
  436. span({ class: "card-label" }, (i18n.mapLocationTitle || "Map Location") + ":"),
  437. form({ method: "GET", action: detailUrl },
  438. label(i18n.mapZoomLabel || "Zoom"),
  439. br(),
  440. select({ name: "zoom" },
  441. [2, 3, 4, 5, 6, 7, 8].map(z =>
  442. option({ value: String(z), ...(zoomVal === z ? { selected: true } : {}) }, String(z)))),
  443. br(), br(),
  444. button({ type: "submit", class: "filter-btn" }, i18n.mapApplyZoom || "Apply Zoom")),
  445. br(),
  446. renderMap([{ lat: la, lng: lo }], null, 0, { zoom: zoomVal, centerLat: la, centerLng: lo }),
  447. mapUrl ? div({ class: "map-embed-url" },
  448. a({ href: mapUrl, class: "map-location-link" }, mapUrl)) : null);
  449. };
  450. exports.renderMapLocationGrid = (lat, lng) => {
  451. if (lat === undefined || lng === undefined) return null;
  452. return div({ class: "map-location-embed" },
  453. renderMap([{ lat: parseFloat(lat) || 0, lng: parseFloat(lng) || 0 }], null, 0));
  454. };