541 lines
28 KiB
JavaScript
541 lines
28 KiB
JavaScript
const { form, button, div, h2, h3, p, section, input, label, br, a, span, textarea, select, option, img, strong } =
|
|
require("../server/node_modules/hyperaxe");
|
|
|
|
const moment = require("../server/node_modules/moment");
|
|
const { template, i18n, userLink, renderLifespanChip, renderSpreadButton, renderContentActions } = require("./main_views");
|
|
const { renderEncryptedChip } = require("./clearnet_view");
|
|
const { config } = require("../server/SSB_server.js");
|
|
const { renderMapWithPins, renderZoomedMapWithPins, getViewportBounds, latLngToPx, pxToLatLng, MAP_W, MAP_H, getMaxTileZoom } = require("../maps/map_renderer");
|
|
const { sanitizeHtml } = require('../backend/sanitizeHtml');
|
|
|
|
const userId = config.keys.id;
|
|
const safeArr = (v) => (Array.isArray(v) ? v : []);
|
|
const safeText = (v) => String(v || "").trim();
|
|
|
|
const buildReturnTo = (filter, params = {}) => {
|
|
const f = safeText(filter || "all");
|
|
const q = safeText(params.q || "");
|
|
const parts = [`filter=${encodeURIComponent(f)}`];
|
|
if (q) parts.push(`q=${encodeURIComponent(q)}`);
|
|
return `/maps?${parts.join("&")}`;
|
|
};
|
|
|
|
const renderPMButton = (recipient) => {
|
|
const r = safeText(recipient);
|
|
if (!r || String(r) === String(userId)) return null;
|
|
return form({ method: "GET", action: "/pm" },
|
|
input({ type: "hidden", name: "recipients", value: r }),
|
|
button({ type: "submit", class: "filter-btn" }, i18n.privateMessage));
|
|
};
|
|
|
|
const renderTags = (tags) => {
|
|
const list = safeArr(tags).map((t) => String(t || "").trim()).filter(Boolean);
|
|
return list.length
|
|
? div({ class: "card-tags" }, list.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)))
|
|
: null;
|
|
};
|
|
|
|
const renderMapFavoriteToggle = (mapObj, returnTo = "") =>
|
|
form({
|
|
method: "POST",
|
|
action: mapObj.isFavorite ? `/maps/favorites/remove/${encodeURIComponent(mapObj.key)}` : `/maps/favorites/add/${encodeURIComponent(mapObj.key)}`
|
|
},
|
|
returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
|
|
button({ type: "submit", class: "filter-btn" }, mapObj.isFavorite ? i18n.mapRemoveFavoriteButton : i18n.mapAddFavoriteButton));
|
|
|
|
let areaCounter = 0;
|
|
|
|
const buildAreas = (clickUrl, latParam = "lat", lngParam = "lng", viewport = null) => {
|
|
const GRID = 16;
|
|
const cellW = MAP_W / GRID;
|
|
const cellH = MAP_H / GRID;
|
|
const areas = [];
|
|
for (let gy = 0; gy < GRID; gy++) {
|
|
for (let gx = 0; gx < GRID; gx++) {
|
|
let c;
|
|
if (viewport) {
|
|
const lat = viewport.latMax - (gy + 0.5) / GRID * (viewport.latMax - viewport.latMin);
|
|
const lng = viewport.lngMin + (gx + 0.5) / GRID * (viewport.lngMax - viewport.lngMin);
|
|
c = { lat: Math.round(lat * 10000) / 10000, lng: Math.round(lng * 10000) / 10000 };
|
|
} else {
|
|
const cx = Math.round(gx * cellW + cellW / 2);
|
|
const cy = Math.round(gy * cellH + cellH / 2);
|
|
c = pxToLatLng(cx, cy);
|
|
}
|
|
const x1 = Math.round(gx * cellW);
|
|
const y1 = Math.round(gy * cellH);
|
|
const x2 = Math.round((gx + 1) * cellW);
|
|
const y2 = Math.round((gy + 1) * cellH);
|
|
areas.push(`<area shape="rect" coords="${x1},${y1},${x2},${y2}" href="${clickUrl}${latParam}=${c.lat}&${lngParam}=${c.lng}" alt="${c.lat},${c.lng}">`);
|
|
}
|
|
}
|
|
return areas;
|
|
};
|
|
|
|
const renderMap = (markers, clickUrl, mainIdx, opts = {}) => {
|
|
areaCounter++;
|
|
const mapName = `m${areaCounter}`;
|
|
const latParam = opts.latParam || "lat";
|
|
const lngParam = opts.lngParam || "lng";
|
|
const pinLabels = opts.pinLabels || [];
|
|
const pinImages = opts.pinImages || [];
|
|
const pfx = opts.pinPrefix || `pin${areaCounter}`;
|
|
const zoom = parseInt(opts.zoom) || 2;
|
|
const centerLat = typeof opts.centerLat === "number" ? opts.centerLat : 0;
|
|
const centerLng = typeof opts.centerLng === "number" ? opts.centerLng : 0;
|
|
|
|
const pinList = safeArr(markers).filter((m) => m && typeof m.lat === "number" && typeof m.lng === "number");
|
|
const useZoom = zoom > 2;
|
|
const mapFile = useZoom
|
|
? renderZoomedMapWithPins(centerLat, centerLng, zoom, pinList, mainIdx)
|
|
: (pinList.length > 0 ? renderMapWithPins(pinList, mainIdx) : null);
|
|
const imgSrc = mapFile ? `/mapcache/${mapFile}` : "/assets/images/worldmap-z2.png";
|
|
const viewport = useZoom && clickUrl ? getViewportBounds(centerLat, centerLng, zoom) : null;
|
|
|
|
const useMap = clickUrl || pinLabels.length > 0;
|
|
const mapTag = useMap ? mapName : "";
|
|
|
|
let gridAreasHtml = "";
|
|
if (clickUrl) {
|
|
const clickUrlWithZoom = zoom > 2 ? `${clickUrl}zoom=${zoom}&` : clickUrl;
|
|
gridAreasHtml = buildAreas(clickUrlWithZoom, latParam, lngParam, viewport).join("");
|
|
}
|
|
let popupAreasHtml = "";
|
|
let popupsHtml = "";
|
|
if (pinLabels.length > 0) {
|
|
const vp = useZoom ? getViewportBounds(centerLat, centerLng, zoom) : null;
|
|
pinList.forEach((m, i) => {
|
|
const lbl = pinLabels[i] || "";
|
|
let px;
|
|
if (vp) {
|
|
px = {
|
|
x: ((m.lng - vp.lngMin) / (vp.lngMax - vp.lngMin)) * MAP_W,
|
|
y: ((vp.latMax - m.lat) / (vp.latMax - vp.latMin)) * MAP_H
|
|
};
|
|
} else {
|
|
px = latLngToPx(m.lat, m.lng);
|
|
}
|
|
const escaped = lbl.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
const withLinks = escaped.replace(/https?:\/\/[^\s&"<>]+/g, (url) => {
|
|
const clean = url.replace(/&/g, "&");
|
|
return `<a href="${clean}" class="map-popup-link" target="_blank" rel="noopener">${url}</a>`;
|
|
}).replace(/\n/g, "<br>");
|
|
const sz = 20 * Math.pow(2, Math.max(0, zoom - getMaxTileZoom()));
|
|
const x1 = Math.max(0, px.x - sz);
|
|
const y1 = Math.max(0, px.y - sz);
|
|
const x2 = Math.min(MAP_W, px.x + sz);
|
|
const y2 = Math.min(MAP_H, px.y + sz);
|
|
const popupId = `${pfx}_${i}`;
|
|
const latStr = typeof m.lat === "number" ? m.lat.toFixed(4) : "";
|
|
const lngStr = typeof m.lng === "number" ? m.lng.toFixed(4) : "";
|
|
const imgBlobId = pinImages[i] && String(pinImages[i]).startsWith("&") ? pinImages[i] : "";
|
|
const imgHtml = imgBlobId ? `<img src="/blob/${encodeURIComponent(imgBlobId)}" class="map-popup-img" alt="">` : "";
|
|
popupAreasHtml += `<area shape="rect" coords="${x1},${y1},${x2},${y2}" title="${escaped}" alt="${escaped}" href="#${popupId}">`;
|
|
popupsHtml += `<div id="${popupId}" class="map-popup"><div class="map-popup-box"><a href="#" class="map-popup-close">✕</a>${imgHtml}<div class="map-popup-label">${sanitizeHtml(withLinks)}</div><div class="map-popup-coords">${latStr}, ${lngStr}</div></div></div>`;
|
|
});
|
|
}
|
|
const mapHtml = useMap ? `<map name="${mapTag}">${popupAreasHtml}${gridAreasHtml}</map>` : "";
|
|
const useAttr = useMap ? ` usemap="#${mapTag}"` : "";
|
|
|
|
const mapWrapHtml = `<div class="map-wrap"><img src="${imgSrc}" class="map-img" alt="map"${useAttr}>${mapHtml}</div>`;
|
|
const viewerEl = div({ class: "map-viewer" }, { innerHTML: mapWrapHtml });
|
|
if (!popupsHtml) return viewerEl;
|
|
return div({ class: "map-zone" }, viewerEl, div({ class: "map-popup-container", innerHTML: popupsHtml }));
|
|
};
|
|
|
|
const renderCoordPreview = (lat, lng) => {
|
|
if (!lat && !lng) return null;
|
|
return span({ class: "map-coord-inline" },
|
|
span({ class: "map-coord-pin" }, "📍"),
|
|
strong(`${lat}, ${lng}`));
|
|
};
|
|
|
|
const renderLocalEmbed = (lat, lng) => {
|
|
const la = parseFloat(lat) || 0;
|
|
const lo = parseFloat(lng) || 0;
|
|
if (!la && !lo) return null;
|
|
return renderMap([{ lat: la, lng: lo }], null, 0);
|
|
};
|
|
|
|
const renderMapUrl = (mapObj) =>
|
|
div({ class: "map-url-container" },
|
|
span({ class: "card-label" }, i18n.mapUrlLabel + ": "),
|
|
a({ href: `/maps/${encodeURIComponent(mapObj.key)}`, class: "map-url-link" },
|
|
`/maps/${encodeURIComponent(mapObj.key)}`));
|
|
|
|
const renderMapOwnerActions = (filter, mapObj, params = {}) => {
|
|
const returnTo = buildReturnTo(filter, params);
|
|
if (String(mapObj.author) !== String(userId)) return [];
|
|
const actions = [
|
|
form({ method: "GET", action: `/maps/edit/${encodeURIComponent(mapObj.key)}` },
|
|
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
|
button({ class: "update-btn", type: "submit" }, i18n.mapUpdateButton)),
|
|
form({ method: "POST", action: `/maps/delete/${encodeURIComponent(mapObj.key)}` },
|
|
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
|
button({ class: "delete-btn", type: "submit" }, i18n.mapDeleteButton))
|
|
];
|
|
const isPrivateMap = !mapObj.tribeId && mapObj.mapType !== "OPEN";
|
|
if (isPrivateMap) {
|
|
actions.push(form({ method: "POST", action: `/maps/generate-invite/${encodeURIComponent(mapObj.key)}` },
|
|
button({ type: "submit", class: "tribe-action-btn" }, i18n.tribeGenerateInvite)));
|
|
}
|
|
const openInvite = Array.isArray(mapObj.invites) ? mapObj.invites.find(inv => typeof inv === "object" && inv.public === true && inv.code) : null;
|
|
if (!mapObj.tribeId) {
|
|
if (openInvite) {
|
|
actions.push(div({ class: 'tribe-open-invite' },
|
|
span({ class: 'card-label' }, i18n.tribeInviteCodeText),
|
|
span({ class: 'tribe-open-invite-code' }, openInvite.code)
|
|
));
|
|
actions.push(form({ method: "POST", action: `/maps/open-invite/remove/${encodeURIComponent(mapObj.key)}` },
|
|
button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.tribeRemoveInvitation)));
|
|
} else {
|
|
actions.push(form({ method: "POST", action: `/maps/open-invite/create/${encodeURIComponent(mapObj.key)}` },
|
|
button({ type: "submit", class: "tribe-action-btn" }, i18n.tribeOpenInvitation)));
|
|
}
|
|
}
|
|
return actions;
|
|
};
|
|
|
|
const renderFilters = (filter, q) =>
|
|
div({ class: "filters" },
|
|
form({ method: "GET", action: "/maps", class: "ui-toolbar ui-toolbar--filters" },
|
|
input({ type: "hidden", name: "q", value: q || "" }),
|
|
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.mapFilterAll),
|
|
button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.mapFilterMine),
|
|
button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.mapFilterRecent),
|
|
button({ type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" }, i18n.mapFilterFavorites),
|
|
button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.mapUploadButton)));
|
|
|
|
const renderMapForm = (filter, mapId, mapToEdit, params = {}) => {
|
|
const returnFilter = filter === "create" ? "all" : params.filter || "all";
|
|
const returnTo = safeText(params.returnTo) || buildReturnTo(returnFilter, params);
|
|
const latVal = params.lat !== undefined ? String(params.lat) : String(mapToEdit?.lat || "");
|
|
const lngVal = params.lng !== undefined ? String(params.lng) : String(mapToEdit?.lng || "");
|
|
const titleVal = params.title || mapToEdit?.title || "";
|
|
const descVal = params.description || mapToEdit?.description || "";
|
|
const markerLabelVal = params.markerLabel !== undefined ? params.markerLabel : (mapToEdit?.markerLabel || "");
|
|
const tagsValue = params.tags !== undefined ? params.tags : safeArr(mapToEdit?.tags).join(", ");
|
|
const mapTypeVal = params.mapType || mapToEdit?.mapType || "SINGLE";
|
|
const maxTileZoom = getMaxTileZoom();
|
|
const zoomVal = parseInt(params.zoom) || 2;
|
|
const cleanUrl = `/maps?filter=create${params.tribeId ? '&tribeId=' + encodeURIComponent(params.tribeId) : ''}`;
|
|
const pickerMarkers = latVal && lngVal ? [{ lat: parseFloat(latVal), lng: parseFloat(lngVal) }] : [];
|
|
|
|
return div({ class: "map-create-layout" },
|
|
div({ class: "map-form map-form-full" },
|
|
form({
|
|
action: filter === "edit" ? `/maps/update/${encodeURIComponent(mapId)}` : "/maps/create",
|
|
method: "POST",
|
|
enctype: "multipart/form-data"
|
|
},
|
|
input({ type: "hidden", name: "returnTo", value: returnTo }),
|
|
input({ type: "hidden", name: "filter", value: "create" }),
|
|
params.tribeId ? input({ type: "hidden", name: "tribeId", value: params.tribeId }) : null,
|
|
label(i18n.title || "Title"),
|
|
input({ type: "text", name: "title", placeholder: i18n.mapTitlePlaceholder || "Map title", value: titleVal }),
|
|
label(i18n.mapDescriptionLabel),
|
|
textarea({ name: "description", placeholder: i18n.mapDescriptionPlaceholder, rows: "3" }, descVal),
|
|
label(i18n.mapTagsLabel),
|
|
input({ type: "text", name: "tags", placeholder: i18n.mapTagsPlaceholder, value: tagsValue }),
|
|
label(i18n.mapTypeLabel),
|
|
select({ name: "mapType" },
|
|
option({ value: "SINGLE", ...(mapTypeVal === "SINGLE" ? { selected: true } : {}) }, "SINGLE"),
|
|
option({ value: "OPEN", ...(mapTypeVal === "OPEN" ? { selected: true } : {}) }, "OPEN"),
|
|
option({ value: "CLOSED", ...(mapTypeVal === "CLOSED" ? { selected: true } : {}) }, "CLOSED")),
|
|
br(),br(),
|
|
label(i18n.mapMarkerLabelField),
|
|
textarea({ name: "markerLabel", placeholder: i18n.mapMarkerLabelPlaceholder, rows: "3" }, markerLabelVal),
|
|
label(i18n.markerImageLabel || "Marker Image"),
|
|
input({ type: "file", name: "image", accept: "image/*" }),
|
|
br(), br(),
|
|
label(i18n.mapLatLabel),
|
|
input({ type: "text", name: "lat", placeholder: i18n.mapLatPlaceholder, value: latVal }),
|
|
label(i18n.mapLngLabel),
|
|
input({ type: "text", name: "lng", placeholder: i18n.mapLngPlaceholder, value: lngVal }),
|
|
div({ class: "map-form-row" },
|
|
button({ type: "submit", attrs: { formmethod: "GET" }, formaction: "/maps", class: "filter-btn" }, i18n.mapAddMarkerButton || "Add Marker"),
|
|
a({ href: cleanUrl, class: "filter-btn" }, i18n.mapCleanMarkerButton || "Clean Marker")),
|
|
renderCoordPreview(latVal, lngVal),
|
|
label(i18n.mapZoomLabel || "Zoom"),
|
|
select({ name: "zoom" },
|
|
[2, 3, 4, 5, 6, 7, 8].map(z =>
|
|
option({ value: String(z), ...(zoomVal === z ? { selected: true } : {}) }, String(z)))),
|
|
br(),br(),
|
|
button({ type: "submit", attrs: { formmethod: "GET" }, formaction: "/maps", class: "filter-btn" }, i18n.mapApplyZoom || "Apply Zoom"),
|
|
div({ class: "map-form-map-slot" },
|
|
renderMap(pickerMarkers, null, 0, { zoom: zoomVal, centerLat: parseFloat(latVal) || 0, centerLng: parseFloat(lngVal) || 0 })),
|
|
button({ type: "submit", class: "create-button" }, filter === "edit" ? i18n.mapUpdateButton : i18n.mapCreateButton))));
|
|
};
|
|
|
|
const renderMarkerForm = (mapObj, returnTo, params = {}, tribeMembers = []) => {
|
|
if (mapObj.mapType === "SINGLE") return null;
|
|
if (mapObj.mapType === "CLOSED" && String(mapObj.author) !== String(userId)) return null;
|
|
if (mapObj.mapType === "OPEN" && mapObj.tribeId && !tribeMembers.includes(userId)) return null;
|
|
const mkLat = params.mkLat || "";
|
|
const mkLng = params.mkLng || "";
|
|
const zoomVal = parseInt(params.zoom) || 2;
|
|
|
|
const existingMarkers = [{ lat: mapObj.lat, lng: mapObj.lng }].concat(
|
|
safeArr(mapObj.markers).map((m) => ({ lat: m.lat, lng: m.lng })));
|
|
if (mkLat && mkLng) existingMarkers.push({ lat: parseFloat(mkLat), lng: parseFloat(mkLng) });
|
|
|
|
const pinLabels = [mapObj.markerLabel || mapObj.description || mapObj.title || ""].concat(
|
|
safeArr(mapObj.markers).map((m) => m.label || ""));
|
|
|
|
const mkCleanUrl = `/maps/${encodeURIComponent(mapObj.key)}?filter=${encodeURIComponent(params.filter || "all")}`;
|
|
const clickUrl = `/maps/${encodeURIComponent(mapObj.key)}?filter=${encodeURIComponent(params.filter || "all")}&zoom=${zoomVal}&`;
|
|
return div({ class: "map-marker-form", id: "add-marker" },
|
|
h3(i18n.mapAddMarkerTitle),
|
|
form({ method: "POST", action: `/maps/${encodeURIComponent(mapObj.key)}/marker`, class: "map-form", enctype: "multipart/form-data" },
|
|
returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
|
|
label(i18n.mapMarkerLabelField),
|
|
textarea({ name: "label", placeholder: i18n.mapMarkerLabelPlaceholder, rows: "3" }, params.mkMarkerLabel || ""),
|
|
label(i18n.markerImageLabel || "Marker Image"),
|
|
input({ type: "file", name: "image", accept: "image/*" }),
|
|
br(),br(),
|
|
label(i18n.mapMarkerLatLabel),
|
|
input({ type: "text", name: "mkLat", placeholder: i18n.mapLatPlaceholder, value: String(mkLat) }),
|
|
label(i18n.mapMarkerLngLabel),
|
|
input({ type: "text", name: "mkLng", placeholder: i18n.mapLngPlaceholder, value: String(mkLng) }),
|
|
div({ class: "map-form-row" },
|
|
button({ type: "submit", attrs: { formmethod: "GET" }, formaction: `/maps/${encodeURIComponent(mapObj.key)}`, class: "filter-btn" }, i18n.mapAddMarkerButton || "Add Marker"),
|
|
a({ href: mkCleanUrl, class: "filter-btn" }, i18n.mapCleanMarkerButton || "Clean Marker")),
|
|
renderCoordPreview(mkLat, mkLng),
|
|
label(i18n.mapZoomLabel || "Zoom"),
|
|
select({ name: "zoom" },
|
|
[2, 3, 4, 5, 6, 7, 8].map(z =>
|
|
option({ value: String(z), ...(zoomVal === z ? { selected: true } : {}) }, String(z)))),
|
|
br(),br(),
|
|
button({ type: "submit", attrs: { formmethod: "GET" }, formaction: `/maps/${encodeURIComponent(mapObj.key)}`, class: "filter-btn" }, i18n.mapApplyZoom || "Apply Zoom"),
|
|
div({ class: "map-form-map-slot" },
|
|
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 })),
|
|
button({ type: "submit", class: "create-button" }, i18n.mapAddMarkerButton)));
|
|
};
|
|
|
|
const renderMarkersList = (markers, mapObj) => {
|
|
const allMarkers = [];
|
|
if (mapObj) {
|
|
allMarkers.push({
|
|
lat: mapObj.lat,
|
|
lng: mapObj.lng,
|
|
label: mapObj.markerLabel || mapObj.description || mapObj.title || i18n.mapMarkerDefault,
|
|
author: mapObj.author,
|
|
createdAt: mapObj.createdAt
|
|
});
|
|
}
|
|
allMarkers.push(...safeArr(markers));
|
|
if (!allMarkers.length) return null;
|
|
return div({ class: "map-markers-list" },
|
|
h3(i18n.mapMarkersTitle),
|
|
br(),
|
|
div(allMarkers.flatMap((mk, i) => [
|
|
...(i > 0 ? [br()] : []),
|
|
div({ class: "map-marker-info" },
|
|
span({ class: "map-marker-dot" }, "ꔌ"),
|
|
span({ class: "map-marker-coords" }, `${(typeof mk.lat === 'number' ? mk.lat : 0).toFixed(4)}, ${(typeof mk.lng === 'number' ? mk.lng : 0).toFixed(4)}`),
|
|
span({ class: "map-marker-meta" },
|
|
userLink(mk.author),
|
|
` · ${moment(mk.createdAt).fromNow()}`))
|
|
])));
|
|
};
|
|
|
|
const renderMapCard = (mapObj, filter, params = {}) => {
|
|
const returnTo = buildReturnTo(filter, params);
|
|
const ownerActions = renderMapOwnerActions(filter, mapObj, params);
|
|
const markerCount = safeArr(mapObj.markers).length;
|
|
|
|
const thumbMarkers = [{ lat: mapObj.lat, lng: mapObj.lng }].concat(
|
|
safeArr(mapObj.markers).map((m) => ({ lat: m.lat, lng: m.lng })));
|
|
const thumbFile = renderMapWithPins(thumbMarkers, 0);
|
|
const thumbSrc = thumbFile ? `/mapcache/${thumbFile}` : "/assets/images/worldmap-z2.png";
|
|
|
|
const chips = [
|
|
renderEncryptedChip(i18n),
|
|
renderLifespanChip(mapObj.lifetime, i18n)
|
|
].filter(Boolean);
|
|
|
|
const isOwn = mapObj.author && String(mapObj.author) === String(userId);
|
|
return div({ class: "trending-card map-card" + (isOwn ? " own-content" : "") },
|
|
div({ class: "card-header activity-card-header" },
|
|
span({ class: "map-type-badge" }, mapObj.mapType),
|
|
renderContentActions(mapObj.key, `/maps/${encodeURIComponent(mapObj.key)}?filter=${encodeURIComponent(filter)}`)),
|
|
div({ class: "card-section maps-card-body" },
|
|
a({ href: `/maps/${encodeURIComponent(mapObj.key)}?filter=${encodeURIComponent(filter)}`, class: "map-card-thumb-link" },
|
|
{ innerHTML: `<img src="${thumbSrc}" class="map-card-thumb" alt="map">` }),
|
|
div({ class: "map-card-body" },
|
|
div({ class: "shop-title-row" },
|
|
mapObj.title ? h2({ class: "tribe-card-title" }, a({ href: `/maps/${encodeURIComponent(mapObj.key)}?filter=${encodeURIComponent(filter)}` }, mapObj.title)) : null
|
|
),
|
|
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
|
|
div({ class: "map-card-header" },
|
|
div({ class: "map-card-info" },
|
|
span({ class: "map-coords" }, `📍 ${mapObj.lat.toFixed(4)}, ${mapObj.lng.toFixed(4)}`),
|
|
markerCount > 0 ? span({ class: "map-marker-count" }, `▾ ${markerCount}`) : null,
|
|
mapObj.key ? renderMapUrl(mapObj) : null),
|
|
div({ class: "map-card-actions" },
|
|
renderMapFavoriteToggle(mapObj, returnTo),
|
|
renderPMButton(mapObj.author),
|
|
...ownerActions)),
|
|
safeText(mapObj.description) ? p({ class: "map-description" }, mapObj.description) : null,
|
|
(() => {
|
|
const btn = renderSpreadButton(mapObj.key, params.spreadMap && params.spreadMap.get(mapObj.key));
|
|
return btn ? div({ class: "card-spread-left" }, btn) : null;
|
|
})(),
|
|
p({ class: "card-footer" },
|
|
span({ class: "date-link" }, moment(mapObj.createdAt).fromNow()),
|
|
span(" · "),
|
|
userLink(mapObj.author)))));
|
|
};
|
|
|
|
const renderMapList = (maps, filter, params = {}) =>
|
|
maps.length
|
|
? maps.map((mapObj) => renderMapCard(mapObj, filter, params))
|
|
: p(params.q ? i18n.mapNoMatch : i18n.noMaps);
|
|
|
|
exports.mapsView = async (maps, filter = "all", mapId = null, params = {}) => {
|
|
const title = filter === "mine" ? i18n.mapMineSectionTitle
|
|
: filter === "create" ? i18n.mapCreateSectionTitle
|
|
: filter === "edit" ? i18n.mapUpdateSectionTitle
|
|
: filter === "recent" ? i18n.mapRecentSectionTitle
|
|
: filter === "favorites" ? i18n.mapFavoritesSectionTitle
|
|
: i18n.mapAllSectionTitle;
|
|
|
|
const q = safeText(params.q || "");
|
|
const list = safeArr(maps);
|
|
const mapToEdit = mapId ? list.find((m) => m.key === mapId) : null;
|
|
const allMarkers = list.map((m) => ({ lat: m.lat, lng: m.lng, href: `/maps/${encodeURIComponent(m.key)}` }));
|
|
|
|
return template(title,
|
|
section(
|
|
div({ class: "tags-header" }, h2(title), p(i18n.mapDescription)),
|
|
renderFilters(filter, q)),
|
|
section(
|
|
filter === "create" || filter === "edit"
|
|
? renderMapForm(filter, mapId, mapToEdit, { ...params, filter })
|
|
: section(
|
|
div({ class: "maps-search" },
|
|
form({ method: "GET", action: "/maps", class: "filter-box" },
|
|
input({ type: "hidden", name: "filter", value: filter }),
|
|
input({ type: "text", name: "q", value: q, placeholder: i18n.mapSearchPlaceholder, class: "filter-box__input" }),
|
|
div({ class: "filter-box__controls" }, button({ type: "submit", class: "filter-box__button" }, i18n.mapSearchButton)))),
|
|
div({ class: "maps-list" }, renderMapList(list, filter, { q })))));
|
|
};
|
|
|
|
exports.singleMapView = async (mapObj, filter = "all", params = {}) => {
|
|
const q = safeText(params.q || "");
|
|
const returnTo = safeText(params.returnTo) || buildReturnTo(filter, { q });
|
|
const ownerActions = renderMapOwnerActions(filter, mapObj, { q });
|
|
const tribeMembers = safeArr(params.tribeMembers);
|
|
const zoomVal = parseInt(params.zoom) || 2;
|
|
|
|
const allMarkers = [{ lat: mapObj.lat, lng: mapObj.lng }].concat(
|
|
safeArr(mapObj.markers).map((m) => ({ lat: m.lat, lng: m.lng })));
|
|
|
|
const pinLabels = [mapObj.markerLabel || mapObj.description || mapObj.title || ""].concat(
|
|
safeArr(mapObj.markers).map((m) => m.label || ""));
|
|
const pinImages = [mapObj.image || ""].concat(safeArr(mapObj.markers).map((m) => m.image || ""));
|
|
|
|
return template(mapObj.title || i18n.mapTitle,
|
|
section(renderFilters(filter, q)),
|
|
section(
|
|
div({ class: "map-detail" },
|
|
mapObj.title ? div({ class: "shop-title-row" },
|
|
h2({ class: "tribe-card-title" }, mapObj.title)
|
|
) : null,
|
|
mapObj.title ? div({ class: "card-chips-row" },
|
|
renderEncryptedChip(i18n),
|
|
renderLifespanChip(mapObj.lifetime, i18n)
|
|
) : null,
|
|
safeText(mapObj.description) ? p({ class: "map-description" }, mapObj.description) : null,
|
|
div({ class: "map-detail-header" },
|
|
div({ class: "map-detail-info" },
|
|
span({ class: "map-type-badge" }, mapObj.mapType),
|
|
span({ class: "map-coords-detail" }, `📍 ${mapObj.lat.toFixed(6)}, ${mapObj.lng.toFixed(6)}`)),
|
|
div({ class: "map-detail-actions" },
|
|
renderMapFavoriteToggle(mapObj, returnTo),
|
|
renderPMButton(mapObj.author),
|
|
(String(mapObj.author) !== String(userId) && !mapObj.tribeId && mapObj.mapType !== "OPEN")
|
|
? a({ class: "tribe-action-btn", href: "/invites#invites-maps" }, i18n.tribeEnterInvite)
|
|
: null,
|
|
...ownerActions)),
|
|
renderMapUrl(mapObj),
|
|
br(),
|
|
form({ method: "GET", action: `/maps/${encodeURIComponent(mapObj.key)}` },
|
|
label(i18n.mapZoomLabel || "Zoom"),
|
|
br(),
|
|
select({ name: "zoom" },
|
|
[2, 3, 4, 5, 6, 7, 8].map(z =>
|
|
option({ value: String(z), ...(zoomVal === z ? { selected: true } : {}) }, String(z)))),
|
|
br(), br(),
|
|
button({ type: "submit", class: "filter-btn" }, i18n.mapApplyZoom || "Apply Zoom")),
|
|
br(),
|
|
renderMap(allMarkers, null, 0, { pinLabels, pinImages, pinPrefix: `detail${areaCounter}`, zoom: zoomVal, centerLat: parseFloat(mapObj.lat) || 0, centerLng: parseFloat(mapObj.lng) || 0 }),
|
|
renderMarkersList(mapObj.markers, mapObj),
|
|
renderTags(mapObj.tags),
|
|
(() => {
|
|
const btn = renderSpreadButton(mapObj.key);
|
|
return btn ? div({ class: "card-spread-centered" }, btn) : null;
|
|
})(),
|
|
br(),
|
|
p({ class: "card-footer" },
|
|
span({ class: "date-link" }, `${moment(mapObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
|
|
userLink(mapObj.author),
|
|
mapObj.updatedAt && mapObj.updatedAt !== mapObj.createdAt
|
|
? span({ class: "votations-comment-date" }, ` · ${i18n.mapUpdatedAt}: ${moment(mapObj.updatedAt).format("YYYY/MM/DD HH:mm:ss")}`)
|
|
: null),
|
|
renderMarkerForm(mapObj, returnTo, params, tribeMembers))));
|
|
};
|
|
|
|
exports.renderMapLocationUrl = (mapUrl) => {
|
|
if (!mapUrl) return null;
|
|
return span({ class: "map-location-inline" },
|
|
span({ class: "map-location-icon" }, "ꔌ"),
|
|
a({ href: mapUrl, class: "map-location-link" }, mapUrl));
|
|
};
|
|
|
|
exports.renderMapLocationVisitLabel = (mapUrl) => {
|
|
if (!mapUrl) return null;
|
|
return div({ class: "card-field" },
|
|
span({ class: "card-label" }, (i18n.mapLocationTitle || "Map Location") + ":"),
|
|
span({ class: "card-value" },
|
|
a({ href: mapUrl, class: "map-location-link" }, i18n.mapVisitLabel || "Visit map")));
|
|
};
|
|
|
|
exports.renderMapEmbed = (mapData, mapUrl) => {
|
|
if (!mapData || (parseFloat(mapData.lat) === 0 && parseFloat(mapData.lng) === 0))
|
|
return exports.renderMapLocationVisitLabel(mapUrl);
|
|
return div({ class: "map-embed-section" },
|
|
span({ class: "card-label" }, (i18n.mapLocationTitle || "Map Location") + ":"),
|
|
span({ class: "card-value map-zoom-info" }, "Zoom: 2"),
|
|
renderLocalEmbed(mapData.lat, mapData.lng),
|
|
mapUrl ? div({ class: "map-embed-url" },
|
|
a({ href: mapUrl, class: "map-location-link" }, mapUrl)) : null);
|
|
};
|
|
|
|
exports.renderMapEmbedWithZoom = (mapData, mapUrl, detailUrl, zoom) => {
|
|
if (!mapData || (parseFloat(mapData.lat) === 0 && parseFloat(mapData.lng) === 0))
|
|
return exports.renderMapLocationVisitLabel(mapUrl);
|
|
const zoomVal = parseInt(zoom) || 2;
|
|
const la = parseFloat(mapData.lat) || 0;
|
|
const lo = parseFloat(mapData.lng) || 0;
|
|
return div({ class: "map-embed-section" },
|
|
span({ class: "card-label" }, (i18n.mapLocationTitle || "Map Location") + ":"),
|
|
form({ method: "GET", action: detailUrl },
|
|
label(i18n.mapZoomLabel || "Zoom"),
|
|
br(),
|
|
select({ name: "zoom" },
|
|
[2, 3, 4, 5, 6, 7, 8].map(z =>
|
|
option({ value: String(z), ...(zoomVal === z ? { selected: true } : {}) }, String(z)))),
|
|
br(), br(),
|
|
button({ type: "submit", class: "filter-btn" }, i18n.mapApplyZoom || "Apply Zoom")),
|
|
br(),
|
|
renderMap([{ lat: la, lng: lo }], null, 0, { zoom: zoomVal, centerLat: la, centerLng: lo }),
|
|
mapUrl ? div({ class: "map-embed-url" },
|
|
a({ href: mapUrl, class: "map-location-link" }, mapUrl)) : null);
|
|
};
|
|
|
|
exports.renderMapLocationGrid = (lat, lng) => {
|
|
if (lat === undefined || lng === undefined) return null;
|
|
return div({ class: "map-location-embed" },
|
|
renderMap([{ lat: parseFloat(lat) || 0, lng: parseFloat(lng) || 0 }], null, 0));
|
|
};
|