This commit is contained in:
Your Name 2026-01-02 01:12:56 -05:00
parent 842f777c34
commit 3babb277d0
9 changed files with 1089 additions and 491 deletions

122
package-lock.json generated
View File

@ -346,9 +346,9 @@
}
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz",
"integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==",
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
"integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -466,9 +466,9 @@
}
},
"node_modules/@eslint/js": {
"version": "9.39.1",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz",
"integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==",
"version": "9.39.2",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz",
"integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==",
"dev": true,
"license": "MIT",
"engines": {
@ -673,9 +673,9 @@
}
},
"node_modules/@maplibre/maplibre-gl-style-spec": {
"version": "24.3.1",
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.3.1.tgz",
"integrity": "sha512-TUM5JD40H2mgtVXl5IwWz03BuQabw8oZQLJTmPpJA0YTYF+B+oZppy5lNMO6bMvHzB+/5mxqW9VLG3wFdeqtOw==",
"version": "24.4.1",
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.4.1.tgz",
"integrity": "sha512-UKhA4qv1h30XT768ccSv5NjNCX+dgfoq2qlLVmKejspPcSQTYD4SrVucgqegmYcKcmwf06wcNAa/kRd0NHWbUg==",
"license": "ISC",
"dependencies": {
"@mapbox/jsonlint-lines-primitives": "~2.0.2",
@ -702,9 +702,9 @@
}
},
"node_modules/@maplibre/vt-pbf": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.1.0.tgz",
"integrity": "sha512-9LjFAoWtxdGRns8RK9vG3Fcw/fb3eHMxvAn2jffwn3jnVO1k49VOv6+FEza70rK7WzF8GnBiKa0K39RyfevKUw==",
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.2.0.tgz",
"integrity": "sha512-bxrk/kQUwWXZgmqYgwOCnZCMONCRi3MJMqJdza4T3E4AeR5i+VyMnaJ8iDWtWxdfEAJRtrzIOeJtxZSy5mFrFA==",
"license": "MIT",
"dependencies": {
"@mapbox/point-geometry": "^1.1.0",
@ -717,9 +717,9 @@
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.0.tgz",
"integrity": "sha512-Fq6DJW+Bb5jaWE69/qOE0D1TUN9+6uWhCeZpdnSBk14pjLcCWR7Q8n49PTSPHazM37JqrsdpEthXy2xn6jWWiA==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
"integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==",
"dev": true,
"license": "MIT",
"optional": true,
@ -727,6 +727,10 @@
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@oxc-project/runtime": {
@ -1219,9 +1223,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.9.5",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.5.tgz",
"integrity": "sha512-D5vIoztZOq1XM54LUdttJVc96ggEsIfju2JBvht06pSzpckp3C7HReun67Bghzrtdsq9XdMGbSSB3v3GhMNmAA==",
"version": "2.9.11",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz",
"integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@ -1298,9 +1302,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001759",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001759.tgz",
"integrity": "sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==",
"version": "1.0.30001762",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001762.tgz",
"integrity": "sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==",
"dev": true,
"funding": [
{
@ -1556,9 +1560,9 @@
}
},
"node_modules/eslint": {
"version": "9.39.1",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz",
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"version": "9.39.2",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz",
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
@ -1569,7 +1573,7 @@
"@eslint/config-helpers": "^0.4.2",
"@eslint/core": "^0.17.0",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "9.39.1",
"@eslint/js": "9.39.2",
"@eslint/plugin-kit": "^0.4.1",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
@ -1637,9 +1641,9 @@
}
},
"node_modules/eslint-plugin-react-refresh": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.24.tgz",
"integrity": "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==",
"version": "0.4.26",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz",
"integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
@ -1695,9 +1699,9 @@
}
},
"node_modules/esquery": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz",
"integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==",
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
"integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
@ -2212,9 +2216,9 @@
"license": "ISC"
},
"node_modules/keycloak-js": {
"version": "26.2.1",
"resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-26.2.1.tgz",
"integrity": "sha512-bZt6fQj/TLBAmivXSxSlqAJxBx/knNZDQGJIW4ensGYGN4N6tUKV8Zj3Y7/LOV8eIpvWsvqV70fbACihK8Ze0Q==",
"version": "26.2.2",
"resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-26.2.2.tgz",
"integrity": "sha512-ug7pNZ1xNkd7PPkerOJCEU2VnUhS7CYStDOCFJgqCNQ64h53ppxaKrh4iXH0xM8hFu5b1W6e6lsyYWqBMvaQFg==",
"license": "Apache-2.0",
"workspaces": [
"test"
@ -2539,9 +2543,9 @@
}
},
"node_modules/maplibre-gl": {
"version": "5.14.0",
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.14.0.tgz",
"integrity": "sha512-O2ok6N/bQ9NA9nJ22r/PRQQYkUe9JwfDMjBPkQ+8OwsVH4TpA5skIAM2wc0k+rni5lVbAVONVyBvgi1rF2vEPA==",
"version": "5.15.0",
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.15.0.tgz",
"integrity": "sha512-pPeu/t4yPDX/+Uf9ibLUdmaKbNMlGxMAX+tBednYukol2qNk2TZXAlhdohWxjVvTO3is8crrUYv3Ok02oAaKzA==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/geojson-rewind": "^0.5.2",
@ -2551,9 +2555,9 @@
"@mapbox/unitbezier": "^0.0.1",
"@mapbox/vector-tile": "^2.0.4",
"@mapbox/whoots-js": "^3.1.0",
"@maplibre/maplibre-gl-style-spec": "^24.3.1",
"@maplibre/maplibre-gl-style-spec": "^24.4.1",
"@maplibre/mlt": "^1.1.2",
"@maplibre/vt-pbf": "^4.1.0",
"@maplibre/vt-pbf": "^4.2.0",
"@types/geojson": "^7946.0.16",
"@types/geojson-vt": "3.2.5",
"@types/supercluster": "^7.1.3",
@ -2864,9 +2868,9 @@
"license": "ISC"
},
"node_modules/react": {
"version": "19.2.1",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz",
"integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==",
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
"peer": true,
"engines": {
@ -2874,16 +2878,16 @@
}
},
"node_modules/react-dom": {
"version": "19.2.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz",
"integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==",
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.1"
"react": "^19.2.3"
}
},
"node_modules/react-refresh": {
@ -2897,9 +2901,9 @@
}
},
"node_modules/react-router": {
"version": "7.10.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.10.1.tgz",
"integrity": "sha512-gHL89dRa3kwlUYtRQ+m8NmxGI6CgqN+k4XyGjwcFoQwwCWF6xXpOCUlDovkXClS0d0XJN/5q7kc5W3kiFEd0Yw==",
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.11.0.tgz",
"integrity": "sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
@ -2919,12 +2923,12 @@
}
},
"node_modules/react-router-dom": {
"version": "7.10.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.10.1.tgz",
"integrity": "sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw==",
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.11.0.tgz",
"integrity": "sha512-e49Ir/kMGRzFOOrYQBdoitq3ULigw4lKbAyKusnvtDu2t4dBX4AGYPrzNvorXmVuOyeakai6FUPW5MmibvVG8g==",
"license": "MIT",
"dependencies": {
"react-router": "7.10.1"
"react-router": "7.11.0"
},
"engines": {
"node": ">=20.0.0"
@ -3134,9 +3138,9 @@
}
},
"node_modules/update-browserslist-db": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz",
"integrity": "sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==",
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
"dev": true,
"funding": [
{
@ -3299,9 +3303,9 @@
}
},
"node_modules/zod": {
"version": "4.1.13",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz",
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.4.tgz",
"integrity": "sha512-Zw/uYiiyF6pUT1qmKbZziChgNPRu+ZRneAsMUDU6IwmXdWt5JwcUfy2bvLOCUtz5UniaN/Zx5aFttZYbYc7O/A==",
"dev": true,
"license": "MIT",
"peer": true,

View File

@ -108,6 +108,11 @@ async function resolveAssetRefsInPosts(posts) {
[p?.image_small, p?.image_medium, p?.image_large, p?.image, p?.media_url].forEach((v) => {
if (isAssetRef(v)) ids.push(v.replace("asset://", ""));
});
if (Array.isArray(p?.media_urls)) {
p.media_urls.forEach((v) => {
if (isAssetRef(v)) ids.push(v.replace("asset://", ""));
});
}
}
const uniq = Array.from(new Set(ids));
if (!uniq.length) return posts;
@ -127,6 +132,15 @@ async function resolveAssetRefsInPosts(posts) {
if (resolved[id]) clone[k] = resolved[id];
}
});
if (Array.isArray(clone.media_urls)) {
clone.media_urls = clone.media_urls.map((value) => {
if (isAssetRef(value)) {
const id = value.replace("asset://", "");
return resolved[id] || value;
}
return value;
});
}
return clone;
});
}

View File

@ -23,6 +23,92 @@ import UserProfile from "../Profile/UserProfile";
import { fetchPostById, fetchPostByUrl } from "../../api/client";
import { trackEvent } from "../../utils/analytics";
const CONTENT_CREATOR_MODES = [
{
id: "live",
label: "Live video",
icon: "fa-solid fa-broadcast-tower",
hint: "Broadcast a live scene with a headline or link.",
detail: [
"Drop the stream URL or quick headline to pin the feed instantly.",
"Well open the location where you place the crosshair so viewers know where to tune.",
],
},
{
id: "link",
label: "Link story",
icon: "fa-solid fa-link",
hint: "Share a URL and well prefill the preview.",
detail: [
"Paste a news link, playlist, or update so we can fetch an image and snippet.",
"Choose the most relevant pin so the story belongs to the right neighborhood.",
],
},
{
id: "photo",
label: "Photo story",
icon: "fa-solid fa-camera-retro",
hint: "Upload or link an image with context.",
detail: [
"Upload a photo set or paste an image URL from your gallery.",
"Caption quickly so the community knows what theyre looking at.",
],
},
{
id: "text",
label: "Written update",
icon: "fa-solid fa-pen-to-square",
hint: "Type a short report or microblog.",
detail: [
"Write the key message in the title field (or add more context in the body).",
"Use subcategory filters to mark the tone: News, Event, Market, etc.",
],
},
{
id: "other",
label: "Other",
icon: "fa-solid fa-ellipsis",
hint: "Custom content that spans media types.",
detail: [
"Mix media, public drafts, or reference notes in one place.",
"Tag a region or Island to give the update a home.",
],
},
];
const MODE_FLOW_CONFIG = {
live: {
hero: "Pin where the live scene is happening and paste your stream link or headline.",
placeholder: "Streaming link or headline",
hint: "Well treat this as a live broadcast and show it as soon as the pin lands.",
bodyPlaceholder: "Context for viewers, optional.",
},
link: {
hero: "Drop the link, describe what it is, and pin the relevant spot.",
placeholder: "Article or playlist URL",
hint: "Well auto-fetch a preview once the link is detected.",
bodyPlaceholder: "What matters about this link?",
},
photo: {
hero: "Snap or upload a photo and pin the place you captured it.",
placeholder: "Photo caption or image URL",
hint: "Add a quick caption so the community knows what theyre seeing.",
bodyPlaceholder: "Describe the scene in a few words.",
},
text: {
hero: "Type a short update and pin the area youre reporting from.",
placeholder: "Headline or short note",
hint: "Readers should understand the gist before opening the full card.",
bodyPlaceholder: "Provide more context, opinions, or call to actions.",
},
other: {
hero: "Combine media, notes, or references and pin wherever it matters.",
placeholder: "Describe your update",
hint: "This mode is flexible—add whatever helps the reader.",
bodyPlaceholder: "Add links, attachments, or extra context if needed.",
},
};
export default function MapView({
theme = "dark",
onPostsState,
@ -198,7 +284,14 @@ export default function MapView({
const [draftSubCategory, setDraftSubCategory] = useState(
CREATE_CATEGORY_MAP.News[0]
);
const [contentMode, setContentMode] = useState(CONTENT_CREATOR_MODES[0].id);
const currentContentMode =
CONTENT_CREATOR_MODES.find((mode) => mode.id === contentMode) ||
CONTENT_CREATOR_MODES[0];
const currentFlow = MODE_FLOW_CONFIG[contentMode] || MODE_FLOW_CONFIG.text;
const [draftCoords, setDraftCoords] = useState(null);
const [modeSelectorVisible, setModeSelectorVisible] = useState(false);
const [modeChosen, setModeChosen] = useState(false);
const [draftImage, setDraftImage] = useState("");
const [draftImageSmall, setDraftImageSmall] = useState("");
const [draftImageMedium, setDraftImageMedium] = useState("");
@ -888,6 +981,22 @@ export default function MapView({
return () => document.removeEventListener('click', handleAuthorClick);
}, []);
const closeCreateModal = () => {
setIsCreating(false);
setModeSelectorVisible(false);
setModeChosen(false);
};
const handleModeSelect = (modeId) => {
setContentMode(modeId);
setModeSelectorVisible(false);
setModeChosen(true);
};
const handlePrevStep = () => {
setCreateStep((prev) => Math.max(0, prev - 1));
};
const handleOpenCreate = () => {
if (!authenticated) {
try {
@ -946,6 +1055,8 @@ export default function MapView({
const list = CREATE_CATEGORY_MAP[main] || CREATE_CATEGORY_MAP.News;
setDraftSubCategory(list[0]);
setDraftCoords(null);
setModeSelectorVisible(true);
setModeChosen(false);
};
const handleSearchSubmit = (e) => {
@ -1357,55 +1468,53 @@ export default function MapView({
</div>
</div>
{isCreating && (
{isCreating && modeChosen && (
<div className="map-overlay map-crosshair">
<div className="crosshair-aim" />
</div>
)}
{isCreating && (
<div className="map-overlay create-post-panel">
<div className="create-post-header">
<span>Create wire</span>
{modeSelectorVisible && (
<div className="map-overlay mode-selector-widget">
<div className="mode-selector-bg" />
<div className="mode-selector-chips">
{CONTENT_CREATOR_MODES.map((mode) => (
<button
className="create-close"
onClick={() => setIsCreating(false)}
key={mode.id}
className={
"mode-selector-chip" +
(mode.id === contentMode ? " is-active" : "")
}
onClick={() => handleModeSelect(mode.id)}
>
<i className={mode.icon} />
<span>{mode.label}</span>
</button>
))}
</div>
</div>
)}
{isCreating && modeChosen && (
<div className="map-overlay create-panel-compact">
<div className="create-panel-header">
<span>{`Share as ${currentContentMode.label}`}</span>
<button className="create-close" onClick={closeCreateModal}>
</button>
</div>
<div className="create-stepper">
{["Pin", "Details", "Share"].map((label, idx) => (
<div
key={label}
className={
"create-step" +
(idx === createStep ? " create-step-current" : "") +
(idx <= createStep ? " create-step-active" : "")
}
>
<div className="create-step-dot">{idx + 1}</div>
<div className="create-step-label">{label}</div>
</div>
))}
</div>
<div className="create-step-progress">
<span
style={{
width: `${(createStep / 2) * 100}%`,
}}
/>
</div>
<div className="create-panel-body">
<div className="create-step-label">Step {createStep + 1} / 3</div>
<p className="create-panel-hero">{currentFlow.hero}</p>
<div className="create-panel-step">
{createStep === 0 && (
<div className="create-step-body">
<span className="crosshair-label">
Move the map, aim with the crosshair your wire will be pinned
there.
</span>
<div className="create-row">
<p className="create-panel-note">{currentFlow.hint}</p>
)}
{createStep === 1 && (
<>
<div className="create-panel-field">
<label>Category</label>
<div className="create-panel-selects">
<select
value={draftCategory}
onChange={(e) => {
@ -1416,11 +1525,10 @@ export default function MapView({
);
}}
>
{["News", "Friends", "Events", "Market"].map((cat) => (
{['News', 'Friends', 'Events', 'Market'].map((cat) => (
<option key={cat}>{cat}</option>
))}
</select>
<select
value={draftSubCategory}
onChange={(e) => setDraftSubCategory(e.target.value)}
@ -1432,10 +1540,12 @@ export default function MapView({
))}
</select>
</div>
</div>
<div className="create-panel-field">
<label>Title or link</label>
<input
className="create-input"
placeholder="Short title or paste a link..."
className="create-input create-input-narrow"
placeholder={currentFlow.placeholder}
value={draftTitle}
onChange={(e) => {
const next = e.target.value;
@ -1453,8 +1563,8 @@ export default function MapView({
setTitleTouched(true);
}}
/>
<div className="create-hint">
Type a title or paste a link well auto-fill from the URL.
<div className="create-hint create-hint-tight">
{currentFlow.hint}
</div>
{draftUrl && !draftSource && !draftPublishedAt && (
<div className="create-link-meta">
@ -1473,16 +1583,16 @@ export default function MapView({
<div className="create-link-error">{urlPreviewError}</div>
)}
</div>
</>
)}
{createStep === 1 && (
<div className="create-step-body">
{createStep === 2 && (
<>
{(draftImagePreview ||
draftImageLarge ||
draftImageMedium ||
draftImageSmall ||
draftImage) && (
<div className="create-link-preview">
<div className="create-link-preview create-link-preview-tight">
<img
src={
draftImagePreview ||
@ -1501,24 +1611,21 @@ export default function MapView({
</div>
</div>
)}
<textarea
className="create-textarea"
className="create-textarea create-textarea-tight"
rows={3}
placeholder="Context (optional)..."
placeholder={currentFlow.bodyPlaceholder}
value={draftBody}
onChange={(e) => {
setDraftBody(e.target.value);
setBodyTouched(true);
}}
/>
<div className="create-image-choice">
<button
type="button"
className={
"create-image-choice-btn" +
(!useCustomImage ? " is-active" : "")
"create-image-choice-btn" + (!useCustomImage ? " is-active" : "")
}
onClick={() => {
setUseCustomImage(false);
@ -1529,8 +1636,7 @@ export default function MapView({
<button
type="button"
className={
"create-image-choice-btn" +
(useCustomImage ? " is-active" : "")
"create-image-choice-btn" + (useCustomImage ? " is-active" : "")
}
onClick={() => {
setUseCustomImage(true);
@ -1540,25 +1646,8 @@ export default function MapView({
</button>
</div>
<div className="create-muted">
No image? Well use the category icon.
Well use the category icon if you skip images.
</div>
{!useCustomImage && (
<div className="create-image-placeholder">
<div className="create-review-icon">
<i className={getSubcatIcon(draftCategory, draftSubCategory)} />
</div>
<div>
<div className="create-image-placeholder-title">
Category icon
</div>
<div className="create-image-placeholder-sub">
Clean default if you skip images.
</div>
</div>
</div>
)}
{useCustomImage && (
<div className="create-image-panel">
<div className="create-image-row">
@ -1587,7 +1676,6 @@ export default function MapView({
<span className="create-uploading">Uploading...</span>
)}
</div>
{draftImagePreview && (
<div className="create-image-preview-row">
<img src={draftImagePreview} alt="Preview" />
@ -1603,12 +1691,9 @@ export default function MapView({
</button>
</div>
)}
{!draftUrl.trim() && !draftImageFile && !draftImagePreview ? (
<>
<div className="create-muted">
Or paste an image URL:
</div>
<div className="create-muted">Or paste an image URL:</div>
<input
className="create-input"
placeholder="https://example.com/image.jpg"
@ -1622,143 +1707,39 @@ export default function MapView({
/>
</>
) : null}
<div className="create-privacy-row">
<label>Image visibility</label>
<select
value={imageVisibility}
onChange={(e) => {
const next = e.target.value;
setImageVisibility(next);
if (next !== "group") setImageGroupId("");
if (next !== "custom") setImageUsers("");
}}
>
<option value="public">Public</option>
<option value="private">Private</option>
<option value="group">Group</option>
<option value="custom">Custom</option>
</select>
</div>
{imageVisibility === "group" ? (
<input
className="create-input"
placeholder="Group ID"
value={imageGroupId}
onChange={(e) => setImageGroupId(e.target.value)}
/>
) : null}
{imageVisibility === "custom" ? (
<input
className="create-input"
placeholder="Usernames (comma separated)"
value={imageUsers}
onChange={(e) => setImageUsers(e.target.value)}
/>
) : null}
</div>
)}
</div>
)}
{createStep === 2 && (
<div className="create-step-body">
<div className="create-privacy-row">
<label>Post visibility</label>
<select
value={postVisibility}
onChange={(e) => {
const next = e.target.value;
setPostVisibility(next);
if (next !== "group") setPostGroupId("");
if (next !== "custom") setPostUsers("");
}}
>
<option value="public">Public</option>
<option value="private">Private</option>
<option value="group">Group</option>
<option value="custom">Custom</option>
</select>
</div>
{postVisibility === "group" ? (
<input
className="create-input"
placeholder="Group ID"
value={postGroupId}
onChange={(e) => setPostGroupId(e.target.value)}
/>
) : null}
{postVisibility === "custom" ? (
<input
className="create-input"
placeholder="Usernames (comma separated)"
value={postUsers}
onChange={(e) => setPostUsers(e.target.value)}
/>
) : null}
<div className="create-review-card">
<div className="create-review-media">
{useCustomImage && (draftImagePreview || draftImage) ? (
<img src={draftImagePreview || draftImage} alt="Preview" />
) : (
<div className="create-review-icon">
<i className={getSubcatIcon(draftCategory, draftSubCategory)} />
</div>
</>
)}
</div>
<div className="create-review-meta">
<div className="create-review-title">
{draftTitle || "Untitled wire"}
</div>
<div className="create-review-sub">
{draftCategory} · {draftSubCategory}
</div>
<div className="create-review-body">
{draftBody || "Add a quick line of context if you want."}
</div>
<div className="create-review-visibility">
Visibility: {postVisibility}
</div>
</div>
</div>
</div>
)}
{saveError && <div className="create-error">{saveError}</div>}
<div className="create-actions">
<div className="create-panel-actions">
<button
type="button"
className="create-back"
onClick={() => setCreateStep((s) => Math.max(0, s - 1))}
className="chip-pill create-panel-btn"
onClick={handlePrevStep}
disabled={createStep === 0}
>
Back
</button>
<div className="create-actions-right">
{createStep < 2 ? (
<button
type="button"
className="chip-pill"
onClick={() => setCreateStep((s) => Math.min(2, s + 1))}
disabled={!draftTitle.trim() || uploadingImage}
>
Next
</button>
) : (
<button
className="chip-pill"
onClick={handleSubmitPost}
className="chip-pill create-panel-btn primary"
onClick={() => {
if (createStep < 2) {
setCreateStep((prev) => prev + 1);
} else {
handleSubmitPost();
}
}}
disabled={isSaving}
>
{isSaving ? "Posting..." : "Post"}
{createStep === 2 ? (isSaving ? "Posting..." : "Post") : "Next"}
</button>
)}
</div>
</div>
</div>
)}
</div>
{/* User Profile Modal */}

View File

@ -132,6 +132,8 @@ async function getAvatarForUser(name) {
export default function PostCard({ post, selected, onSelect }) {
const [liked, setLiked] = useState(false);
const [authed, setAuthed] = useState(isAuthed());
const [activeImageIndex, setActiveImageIndex] = useState(0);
const [failedImageIndices, setFailedImageIndices] = useState(() => new Set());
const [imgFailed, setImgFailed] = useState(false);
const [showPrivacy, setShowPrivacy] = useState(false);
const [privacySaving, setPrivacySaving] = useState(false);
@ -187,9 +189,31 @@ export default function PostCard({ post, selected, onSelect }) {
setPostGroupId((post?.group_id || "").toString());
}, [post?.visibility, post?.group_id]);
const rawImg =
post?.image_medium || post?.image_large || post?.image_small || post?.image || post?.media_url || "";
const img = normalizeImageUrl(rawImg);
const mediaUrls = useMemo(() => {
const list = [];
const seen = new Set();
const addUrl = (value) => {
const normalized = normalizeImageUrl(value);
if (!normalized || seen.has(normalized)) {
return;
}
seen.add(normalized);
list.push(normalized);
};
if (Array.isArray(post?.media_urls)) {
post.media_urls.forEach(addUrl);
}
[post?.image_large, post?.image_medium, post?.image_small, post?.image, post?.media_url].forEach(addUrl);
return list;
}, [post?.media_urls, post?.image_large, post?.image_medium, post?.image_small, post?.image, post?.media_url]);
const mediaKey = mediaUrls.join("|");
useEffect(() => {
setActiveImageIndex(0);
setFailedImageIndices(new Set());
setImgFailed(false);
}, [mediaKey]);
const title = post?.title || "Untitled";
const snippet = post?.snippet || "";
const author = (post?.author || post?.Author || "anon").toString();
@ -206,6 +230,41 @@ export default function PostCard({ post, selected, onSelect }) {
return parts.join(" • ");
}, [sub, post?.region]);
const safeImageIndex = mediaUrls.length
? Math.min(Math.max(activeImageIndex, 0), mediaUrls.length - 1)
: 0;
const activeImage = mediaUrls[safeImageIndex] || "";
const hasGalleryImage = Boolean(activeImage && !imgFailed);
const findNextAvailableIndex = (current, failed) => {
if (mediaUrls.length === 0) return -1;
for (let offset = 1; offset <= mediaUrls.length; offset += 1) {
const idx = (current + offset) % mediaUrls.length;
if (!failed.has(idx)) {
return idx;
}
}
return -1;
};
const handleImageError = () => {
if (!activeImage) {
setImgFailed(true);
return;
}
const nextFailed = new Set(failedImageIndices);
nextFailed.add(safeImageIndex);
setFailedImageIndices(nextFailed);
if (nextFailed.size >= mediaUrls.length) {
setImgFailed(true);
return;
}
const nextIndex = findNextAvailableIndex(safeImageIndex, nextFailed);
if (nextIndex >= 0) {
setActiveImageIndex(nextIndex);
}
};
useEffect(() => {
let cancelled = false;
const who = (author || "").trim();
@ -241,14 +300,35 @@ export default function PostCard({ post, selected, onSelect }) {
<div className="sw-wall-badge">{cat}</div>
</div>
{img && !imgFailed ? (
{hasGalleryImage ? (
<div className="sw-wall-gallery">
<div className="sw-wall-image">
<img
src={img}
src={activeImage}
alt=""
loading="lazy"
onError={() => setImgFailed(true)}
onError={handleImageError}
/>
{mediaUrls.length > 1 && (
<div className="sw-wall-gallery-count">
{safeImageIndex + 1}/{mediaUrls.length}
</div>
)}
</div>
{mediaUrls.length > 1 && (
<div className="sw-wall-gallery-thumbs">
{mediaUrls.map((value, idx) => (
<button
key={`thumb-${value}-${idx}`}
type="button"
className={`sw-wall-gallery-thumb${idx === safeImageIndex ? " active" : ""}`}
onClick={() => setActiveImageIndex(idx)}
>
<img src={value} alt="" loading="lazy" />
</button>
))}
</div>
)}
</div>
) : (
<div className="sw-wall-image sw-wall-image-fallback" aria-hidden="true">

View File

@ -81,6 +81,9 @@ export default function SmartSearchBar({
const skipSearchRef = useRef(false);
const boxRef = useRef(null);
const inputRef = useRef(null);
const searchCacheRef = useRef(new Map());
const latestQueryRef = useRef("");
const emptySuggestionsFetchedRef = useRef(false);
useEffect(() => {
function onDocDown(e) {
@ -95,23 +98,32 @@ export default function SmartSearchBar({
const triggerSearch = async (searchQuery) => {
if (abortRef.current) abortRef.current.abort();
const cached = searchCacheRef.current.get(searchQuery);
if (cached) {
setItems(cached);
setActive(cached.length ? 0 : -1);
setOpen(true);
setHasSearched(true);
setLoading(false);
return;
}
const ctrl = new AbortController();
abortRef.current = ctrl;
setLoading(true);
setErr("");
setOpen(true);
latestQueryRef.current = searchQuery;
try {
console.log('[SmartSearchBar] Searching for:', searchQuery);
const res = await recoSearch(searchQuery, { signal: ctrl.signal, limit: 12 });
console.log('[SmartSearchBar] Got results:', res?.length || 0, res);
if (latestQueryRef.current !== searchQuery) return;
setItems(res);
setOpen(true);
setActive(res.length ? 0 : -1);
setHasSearched(true);
searchCacheRef.current.set(searchQuery, res);
} catch (e) {
if (ctrl.signal.aborted) return;
console.error('[SmartSearchBar] Search error:', e);
setErr((e && e.message) ? e.message : "Search error");
setItems([]);
setOpen(true);
@ -132,9 +144,16 @@ export default function SmartSearchBar({
if (abortRef.current) abortRef.current.abort();
if (!query) {
setItems([]);
setOpen(false);
setHasSearched(false);
return;
}
const t = setTimeout(() => {
triggerSearch(query);
}, 150); // Debounce 150ms
}, 200); // Debounce 200ms (faster UX)
return () => {
clearTimeout(t);
@ -228,11 +247,11 @@ export default function SmartSearchBar({
onSearch && onSearch("");
return;
}
setOpen(true);
// Si pas encore cherché et query vide, charger suggestions populaires
if (!hasSearched && !q.trim()) {
if (!q.trim() && !emptySuggestionsFetchedRef.current) {
emptySuggestionsFetchedRef.current = true;
triggerSearch("");
}
setOpen(true);
}}
onBlur={() => {
setTimeout(() => setFocused(false), 120);
@ -276,7 +295,7 @@ export default function SmartSearchBar({
{loading && <div className="sw-search__row sw-search__muted">Searching</div>}
{err && <div className="sw-search__row sw-search__error">{err}</div>}
{!loading && !err && items.map((it, idx) => (
{!err && items.map((it, idx) => (
<button
type="button"
key={it.id}

View File

@ -28,8 +28,8 @@ function apiPrefix() {
}
const DEFAULT_PATHS = [
"/suggestions", // NEW: Autocomplete suggestions (cities, posts, profiles)
"/unified-search", // Unified search (posts + profiles + places)
"/suggestions", // FAST: Meilisearch autocomplete (cities, posts, profiles) - PRIMARY
"/unified-search", // Fallback: Unified search (posts + profiles + places)
"/search", // Fallback: Posts only
"/reco/search",
"/reco/suggest",
@ -77,7 +77,22 @@ function normalizeOne(r) {
: (lat != null && lon != null ? [lon, lat] : null);
const type = (r.type ?? r.kind ?? r.entity ?? "").toString().toLowerCase().trim();
const kind = type || (coords ? "place" : "item");
const hasPostSignal =
!!(r.category ||
r.sub_category ||
r.subCategory ||
r.author ||
r.snippet ||
r.body ||
r.content ||
r.url ||
r.cluster_key ||
r.clusterKey ||
r.template ||
r.source);
const kind =
type ||
(coords ? "place" : hasPostSignal ? "post" : "item");
const tags =
Array.isArray(r.event_tags)
? r.event_tags
@ -103,9 +118,10 @@ function normalizeOne(r) {
* Tente une recherche sur une liste de paths (au cas le endpoint exact diffère).
* Retourne [] sur 404/erreur.
*/
async function tryFetchAnyPath(q, { signal } = {}) {
async function tryFetchAnyPath(q, { signal, limit } = {}) {
const query = (q || "").toString().trim();
if (!query) return [];
const isEmptyQuery = query === "";
const maxResults = limit && limit > 0 ? limit : 12;
const cached = cacheGet(query);
if (cached) return cached;
@ -117,23 +133,28 @@ async function tryFetchAnyPath(q, { signal } = {}) {
: "";
const prefix = apiPrefix();
const paths = override ? [override, ...DEFAULT_PATHS] : DEFAULT_PATHS;
const basePaths = isEmptyQuery ? ["/suggestions"] : DEFAULT_PATHS;
const paths = override ? [override, ...basePaths] : basePaths;
const posts = [];
const locations = [];
const profiles = [];
const seen = new Set();
const minLocationResults = Math.min(2, maxResults);
const locationKinds = new Set([
"place", "city", "region", "province", "state",
"country", "area", "district", "poi", "venue", "location",
]);
for (const p of paths) {
try {
const base = p.startsWith("http") ? p : `${prefix}${p}`;
const url = `${base}?q=${encodeURIComponent(query)}`;
console.log('[recoSearch] Trying:', url);
const res = await fetch(url, { credentials: "include", signal });
console.log('[recoSearch] Response status:', res.status);
if (res.status === 404) {
console.log('[recoSearch] 404, trying next path');
continue;
continue; // Try next path
}
if (!res.ok) {
console.log('[recoSearch] Not OK, returning empty');
return []; // évite spam erreurs
return []; // Avoid spam errors
}
const data = await res.json().catch(() => null);
@ -145,24 +166,57 @@ async function tryFetchAnyPath(q, { signal } = {}) {
? data.results
: [];
console.log('[recoSearch] Success! Returning', arr.length, 'results from', p);
const out = arr.map(normalizeOne).filter(Boolean);
cacheSet(query, out, 30 * 1000);
return out;
if (arr.length === 0) {
continue; // Try next path
}
const normalized = arr.map(normalizeOne).filter(Boolean);
if (normalized.length === 0) {
continue; // Try next path
}
for (const it of normalized) {
if (seen.has(it.id)) continue;
seen.add(it.id);
const kind = (it.kind || "").toLowerCase();
if (locationKinds.has(kind)) {
locations.push(it);
} else if (kind === "profile") {
profiles.push(it);
} else {
posts.push(it);
}
}
const totalCollected = posts.length + locations.length + profiles.length;
if (totalCollected >= maxResults && locations.length >= minLocationResults) {
break; // Collected enough results
}
} catch (e) {
// Abort => stop net
// Abort => stop network calls
if (e && (e.name === "AbortError" || e.code === 20)) {
console.log('[recoSearch] Aborted');
return [];
}
// autres erreurs => on teste prochain path
console.log('[recoSearch] Error on', p, ':', e.message, '- trying next');
// Other errors => try next path
continue;
}
}
const totalCollected = posts.length + locations.length + profiles.length;
if (totalCollected === 0) {
return [];
}
const locationSlots = Math.min(minLocationResults, locations.length);
const initialResults = [
...locations.slice(0, locationSlots),
...posts,
...locations.slice(locationSlots),
...profiles,
];
const resultSlice = initialResults.slice(0, maxResults);
cacheSet(query, resultSlice, 30 * 1000);
return resultSlice;
}
/**
* recoSearch(query): renvoie une liste normalisée.

View File

@ -82,6 +82,7 @@
z-index: 500; /* Au-dessus des filtres (z-index: 300) */
}
/* Left / right: filtres */
.map-overlay-left{
left:.7rem;
@ -142,20 +143,120 @@
height:70px;
}
/* Panneau de création */
.create-post-panel{
.create-panel-compact{
position:absolute;
left:50%;
bottom:8%;
transform:translateX(-50%);
width:92%;
max-width:560px;
max-height:62vh;
overflow-y:auto;
background: rgba(15, 23, 42, 0.96);
border-radius:16px;
border:1px solid rgba(148, 163, 184, 0.9);
box-shadow: 0 16px 40px rgba(0,0,0,.9), 0 0 24px rgba(56,189,248,.6);
padding: .6rem .7rem .7rem;
top:45%;
transform:translate(-50%,-45%);
width:300px;
max-height:74vh;
overflow:auto;
background: var(--sw-panel-bg, rgba(7, 10, 14, 0.78));
backdrop-filter: blur(18px);
border-radius:18px;
border:1px solid var(--sw-panel-border, rgba(56,189,248,0.5));
box-shadow: 0 20px 40px rgba(0,0,0,0.65), 0 0 30px rgba(56,189,248,0.25);
padding: .4rem .5rem .55rem;
color: var(--sw-text, #e5e7eb);
}
.create-panel-header{
display:flex;
justify-content:space-between;
align-items:center;
font-size:.8rem;
letter-spacing:.08em;
text-transform:uppercase;
color:var(--sw-text,#e5e7eb);
margin-bottom:.25rem;
}
.create-panel-body{
display:flex;
flex-direction:column;
gap:.4rem;
}
.create-step-label{
font-size:.65rem;
text-transform:uppercase;
letter-spacing:.2em;
color:var(--sw-muted,rgba(255,255,255,0.6));
}
.create-panel-hero{
font-size:.85rem;
color:var(--sw-text,#f8fafc);
margin:0;
}
.create-panel-step{
display:flex;
flex-direction:column;
gap:.35rem;
}
.create-panel-note{
font-size:.75rem;
color:var(--sw-muted,rgba(255,255,255,0.6));
margin:0;
}
.create-panel-field{
display:flex;
flex-direction:column;
gap:.25rem;
font-size:.7rem;
color:var(--sw-text,#e5e7eb);
}
.create-panel-selects select,
.create-input-narrow{
background:rgba(255,255,255,0.04);
color:var(--sw-text,#e5e7eb);
border-radius:.65rem;
border:1px solid var(--sw-panel-border,rgba(255,255,255,0.28));
padding:.35rem .55rem;
}
.create-panel-selects select{
min-height:36px;
}
.create-input-narrow::placeholder{
color:var(--sw-muted,rgba(255,255,255,0.6));
}
.create-textarea-tight{
background:rgba(255,255,255,0.03);
border:1px solid var(--sw-panel-border,rgba(255,255,255,0.28));
color:var(--sw-text,#e5e7eb);
border-radius:.8rem;
padding:.35rem .6rem;
font-size:.75rem;
min-height:100px;
resize:none;
}
.create-panel-actions{
display:flex;
justify-content:space-between;
gap:.5rem;
margin-top:.2rem;
}
.create-panel-btn{
flex:1;
padding:.4rem .6rem;
font-size:.7rem;
border-radius:999px;
border:1px solid var(--sw-panel-border,rgba(226,232,240,0.4));
background:var(--sw-chip-bg,rgba(15,23,42,0.75));
color:var(--sw-text,#e5e7eb);
}
.create-panel-btn.primary{
background:linear-gradient(120deg,#38bdf8,#22c55e);
border-color:transparent;
color:#0f172a;
}
.create-panel-hero,
.create-panel-note,
.create-hint-tight{
margin:0;
}
.create-link-preview-tight{
border-radius:12px;
padding:.25rem;
background:var(--sw-panel-bg-2,rgba(15,23,42,0.8));
border:1px solid var(--sw-panel-border,rgba(148,163,184,0.4));
}
.create-post-header{
display:flex;
@ -165,6 +266,255 @@
margin-bottom:.4rem;
color:#e5e7eb;
}
.create-mode-bar{
display:flex;
flex-direction:column;
gap:.35rem;
margin-bottom:.4rem;
}
.create-mode-bar-label{
font-size:.66rem;
text-transform:uppercase;
letter-spacing:.18em;
color:#94a3b8;
}
.create-mode-chips{
display:flex;
gap:.35rem;
flex-wrap:wrap;
}
.create-mode-chip{
flex:1 1 110px;
display:flex;
align-items:center;
gap:.4rem;
padding:.35rem .65rem;
border-radius:999px;
border:1px solid rgba(148, 163, 184, 0.4);
background:rgba(15, 23, 42, 0.65);
color:#e5e7eb;
font-size:.7rem;
cursor:pointer;
transition:border-color .2s ease, box-shadow .2s ease;
}
.create-mode-chip.is-active{
border-color:transparent;
background:linear-gradient(120deg,#38bdf8,#22c55e);
color:#0f172a;
box-shadow:0 10px 20px rgba(56,189,248,.25);
}
.create-mode-hint{
font-size:.72rem;
color:#cbd5f5;
line-height:1.3;
min-height:1.1rem;
}
.mode-change-btn{
margin-top:.5rem;
align-self:flex-start;
}
.mode-selector-widget{
position:absolute;
left:50%;
top:18%;
transform:translate(-50%, -5%);
pointer-events:auto;
}
.mode-selector-bg{
position:absolute;
inset:0;
border-radius:18px;
background:linear-gradient(135deg,var(--sw-panel-bg-2,rgba(15,23,42,0.95)),var(--sw-panel-bg,rgba(15,23,42,0.8)));
border:1px solid var(--sw-panel-border,rgba(56,189,248,0.4));
box-shadow:0 20px 30px rgba(0,0,0,0.45);
}
.mode-selector-chips{
position:relative;
display:flex;
flex-wrap:wrap;
gap:.5rem;
padding:.5rem;
z-index:1;
}
.mode-selector-chip{
display:flex;
align-items:center;
gap:.35rem;
padding:.5rem .8rem;
border-radius:999px;
border:1px solid var(--sw-panel-border,rgba(255,255,255,0.15));
background:var(--sw-panel-bg,rgba(15,23,42,0.9));
color:var(--sw-text,#e5e7eb);
font-size:.7rem;
cursor:pointer;
transition:transform .15s ease, border-color .15s ease;
}
.mode-selector-chip.is-active{
border-color:rgba(56,189,248,0.8);
background:rgba(56,189,248,0.25);
}
.mode-selector-chip i{
font-size:1rem;
}
.create-mode-details{
display:flex;
flex-direction:column;
gap:.2rem;
font-size:.68rem;
color:#9ca3af;
}
.create-stepper{
background:rgba(255,255,255,0.02);
border-radius:999px;
padding:.2rem .3rem;
margin-bottom:.35rem;
}
.create-step{
font-size:.6rem;
}
.create-mode-detail-line{
display:flex;
gap:.35rem;
}
.create-mode-detail-line::before{
content:"•";
font-size:.9rem;
line-height:1.8;
color:#38bdf8;
}
@media (max-width: 640px){
.create-mode-chips{
flex-wrap:nowrap;
overflow-x:auto;
}
.create-mode-chip{
flex:0 0 auto;
}
}
.create-mode-panel{
margin-bottom:.4rem;
display:flex;
flex-direction:column;
gap:.35rem;
}
.create-mode-summary{
display:none;
align-items:center;
justify-content:space-between;
gap:.35rem;
border-radius:999px;
border:1px solid rgba(148,163,184,0.4);
padding:.35rem .6rem;
background:rgba(15,23,42,0.5);
font-size:.72rem;
color:#e5e7eb;
margin-bottom:.4rem;
}
.create-mode-summary-label{
display:flex;
align-items:center;
gap:.3rem;
}
.create-mode-summary-btn{
border-radius:999px;
border:1px solid rgba(148,163,184,0.6);
background:transparent;
color:#e5e7eb;
padding:.2rem .6rem;
font-size:.65rem;
cursor:pointer;
}
.create-mode-title{
font-size:.68rem;
text-transform:uppercase;
letter-spacing:.18em;
color:#94a3b8;
}
.create-mode-chips{
display:flex;
flex-wrap:wrap;
gap:.4rem;
}
.create-mode-chip{
flex:1 1 110px;
display:flex;
align-items:center;
gap:.35rem;
padding:.35rem .6rem;
border-radius:999px;
border:1px solid rgba(148, 163, 184, 0.5);
background:rgba(15,23,42,0.65);
color:#e5e7eb;
font-size:.7rem;
cursor:pointer;
transition:border-color .2s ease, box-shadow .2s ease;
}
.create-mode-chip:hover{
border-color:rgba(56,189,248,0.8);
}
.create-mode-chip.is-active{
border-color:transparent;
background:linear-gradient(120deg,#38bdf8,#22c55e);
color:#0f172a;
box-shadow:0 6px 16px rgba(34,197,94,0.35);
}
.create-mode-icon{
font-size:0.9rem;
}
.create-mode-helper{
font-size:.72rem;
color:#cbd5f5;
line-height:1.25;
min-height:1.2rem;
}
@media (max-width: 640px){
.create-mode-summary{
display:flex;
}
.create-mode-panel{
display:none;
}
.create-mode-panel.create-mode-visible-mobile{
display:flex;
}
}
@media (max-width: 640px){
.create-post-panel{
bottom:3%;
left:50%;
transform:translateX(-50%);
width:95%;
max-width:100%;
max-height:70vh;
border-radius:14px;
padding:.5rem .6rem .6rem;
}
.create-mode-chips{
gap:.3rem;
flex-wrap:nowrap;
overflow-x:auto;
}
.create-mode-chip{
flex:0 0 auto;
}
.create-mode-chip span{
font-size:.65rem;
}
.create-mode-title{
font-size:.6rem;
}
.create-stepper{
gap:.25rem;
}
.create-step-label{
font-size:.6rem;
}
.create-hint{
font-size:.65rem;
}
}
.create-close{
background:transparent;
border:none;
@ -225,7 +575,8 @@
background:linear-gradient(90deg, #38bdf8, #22c55e);
}
.create-step-body{
margin-top:.5rem;
margin-top:.35rem;
padding-bottom:.4rem;
}
.create-row{
display:flex;
@ -825,3 +1176,6 @@ body[data-theme="light"] .map-action-btn * {
background: transparent !important;
box-shadow: none !important;
}
.map-overlay.mode-selector-widget{
pointer-events:none;
}

View File

@ -118,6 +118,10 @@
overflow:hidden;
border:1px solid rgba(148,163,184,0.25);
background: rgba(15,23,42,0.85);
position:relative;
display:flex;
align-items:center;
justify-content:center;
}
.sw-wall-image-fallback{
display:flex;
@ -150,6 +154,49 @@ body[data-theme="light"] .sw-wall-image-fallback{
object-fit: cover;
display:block;
}
.sw-wall-gallery {
display:flex;
flex-direction:column;
gap:.35rem;
}
.sw-wall-gallery-count {
position:absolute;
top:8px;
right:8px;
padding:.18rem .45rem;
border-radius:999px;
font-size:.65rem;
letter-spacing:.08em;
text-transform: uppercase;
border:1px solid rgba(148,163,184,0.45);
background: rgba(15,23,42,0.75);
}
.sw-wall-gallery-thumbs {
display:flex;
gap:.35rem;
overflow-x:auto;
padding-bottom:.25rem;
}
.sw-wall-gallery-thumb {
width:48px;
height:48px;
border-radius:10px;
border:1px solid transparent;
overflow:hidden;
background: rgba(15,23,42,0.85);
flex:0 0 auto;
cursor:pointer;
}
.sw-wall-gallery-thumb.active {
border-color: rgba(56,189,248,0.9);
}
.sw-wall-gallery-thumb img {
width:100%;
height:100%;
object-fit:cover;
display:block;
}
.sw-wall-title{
font-size: 1rem;
font-weight: 900;

View File

@ -1,5 +1,36 @@
/* SmartSearchBar — built to play nice with themes (uses CSS variables with fallbacks) */
:root {
--sw-panel-bg: rgba(7, 10, 14, 0.88);
--sw-panel-border: rgba(255, 255, 255, 0.14);
--sw-panel-bg-2: rgba(8, 11, 18, 0.95);
--sw-chip-bg: rgba(255, 255, 255, 0.08);
--sw-text: rgba(255, 255, 255, 0.92);
--sw-muted: rgba(255, 255, 255, 0.6);
--sw-hover: rgba(255, 255, 255, 0.08);
--sw-danger: #ff6b6b;
}
body[data-theme="blue"] {
--sw-panel-bg: rgba(6, 10, 22, 0.86);
--sw-panel-border: rgba(56, 189, 248, 0.45);
--sw-panel-bg-2: rgba(5, 7, 15, 0.97);
--sw-chip-bg: rgba(56, 189, 248, 0.18);
--sw-text: #e3f3ff;
--sw-muted: rgba(226, 239, 255, 0.82);
--sw-hover: rgba(56, 189, 248, 0.15);
}
body[data-theme="light"] {
--sw-panel-bg: rgba(255, 255, 255, 0.92);
--sw-panel-border: rgba(148, 163, 184, 0.72);
--sw-panel-bg-2: rgba(255, 255, 255, 0.98);
--sw-chip-bg: rgba(148, 163, 184, 0.2);
--sw-text: #0b1220;
--sw-muted: rgba(91, 103, 122, 0.85);
--sw-hover: rgba(15, 23, 42, 0.08);
}
.sw-search {
position: relative;
width: min(580px, 100%);
@ -147,6 +178,20 @@
background: var(--sw-panel-bg-2, rgba(12, 14, 22, 0.80));
border: 1px solid var(--sw-panel-border, rgba(255, 255, 255, 0.10));
backdrop-filter: blur(12px);
max-height: 360px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.35) transparent;
}
.sw-search__dropdown::-webkit-scrollbar {
width: 6px;
}
.sw-search__dropdown::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.35);
border-radius: 999px;
}
.sw-search__row {