├── app ├── js │ ├── polyfills.js │ ├── modals │ │ ├── comments.js │ │ ├── modal.js │ │ ├── share.js │ │ ├── mapillary.js │ │ └── area-selector.js │ ├── ui │ │ ├── list.js │ │ ├── ui.js │ │ └── map.js │ ├── api │ │ └── openstreetmap.js │ ├── theme.js │ ├── comment.js │ ├── request.js │ ├── toast.js │ ├── effects.js │ ├── elements │ │ └── SingleSelectionButtonGroup.js │ ├── preferences.js │ ├── users.js │ ├── linkify.js │ ├── auth.js │ ├── badges.js │ ├── note.js │ └── localizer.js ├── .gitignore ├── templates │ ├── includes │ │ ├── scripts.hbs │ │ ├── structuredData.json │ │ ├── head.hbs │ │ ├── header.hbs │ │ └── nav.hbs │ ├── modal.hbs │ ├── modals │ │ ├── area-selector.hbs │ │ ├── mapillary.hbs │ │ ├── comments.hbs │ │ ├── filter.hbs │ │ ├── share.hbs │ │ ├── settings.hbs │ │ └── help.hbs │ └── dynamic │ │ ├── actions.hbs │ │ ├── note.hbs │ │ ├── comment.hbs │ │ └── mapillary.hbs ├── public │ ├── icons │ │ ├── icon.png │ │ ├── list.png │ │ ├── map.png │ │ ├── any │ │ │ ├── icon-72x72.png │ │ │ ├── icon-96x96.png │ │ │ ├── icon-128x128.png │ │ │ ├── icon-144x144.png │ │ │ ├── icon-152x152.png │ │ │ ├── icon-192x192.png │ │ │ ├── icon-384x384.png │ │ │ └── icon-512x512.png │ │ ├── icon-512x512.png │ │ └── maskable │ │ │ ├── icon-48x48.png │ │ │ ├── icon-72x72.png │ │ │ ├── icon-96x96.png │ │ │ ├── icon-128x128.png │ │ │ ├── icon-192x192.png │ │ │ ├── icon-384x384.png │ │ │ └── icon-512x512.png │ ├── assets │ │ └── logo.jpg │ ├── landing.html │ └── manifest.json ├── css │ ├── colors.scss │ ├── icons.scss │ ├── markers.scss │ ├── dark.scss │ └── main.scss ├── svg │ ├── icon │ │ ├── flash.svg │ │ ├── download.svg │ │ ├── account.svg │ │ ├── close.svg │ │ ├── filter.svg │ │ ├── search.svg │ │ ├── flag.svg │ │ ├── help.svg │ │ ├── refresh.svg │ │ ├── list.svg │ │ ├── trash.svg │ │ ├── map.svg │ │ ├── external.svg │ │ ├── chat.svg │ │ ├── clipboard.svg │ │ ├── share.svg │ │ ├── settings.svg │ │ └── mapillary.svg │ ├── assets │ │ ├── liberapay.svg │ │ ├── mapillary.svg │ │ └── notesreview-small.svg │ ├── marker │ │ └── template.svg │ └── illustration │ │ └── lost.svg ├── index.html └── locales │ ├── ja.json │ ├── fr.json │ ├── zh-TW.json │ ├── nl.json │ ├── pl.json │ ├── pt-BR.json │ ├── it.json │ └── es.json ├── .gitignore ├── .dockerignore ├── assets └── screenshots │ ├── dark │ ├── list.png │ └── map.png │ └── light │ ├── map.png │ ├── images.png │ ├── list.png │ └── comments.png ├── .browserslistrc ├── Dockerfile ├── .editorconfig ├── .github ├── workflows │ ├── lint.yml │ ├── lighthouse.yml │ └── gh-pages.yml ├── actions │ ├── node-and-npm-cache │ │ └── action.yml │ └── build │ │ └── action.yml └── lighthouse │ └── lighthouserc.json ├── package.json ├── vite.config.js ├── README.md ├── CONTRIBUTING.md └── eslint.config.js /app/js/polyfills.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | .env* 3 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | node_modules 3 | 4 | Dockerfile 5 | npm-debug.log 6 | -------------------------------------------------------------------------------- /app/templates/includes/scripts.hbs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/public/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ENT8R/NotesReview/HEAD/app/public/icons/icon.png -------------------------------------------------------------------------------- /app/public/icons/list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ENT8R/NotesReview/HEAD/app/public/icons/list.png -------------------------------------------------------------------------------- /app/public/icons/map.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ENT8R/NotesReview/HEAD/app/public/icons/map.png -------------------------------------------------------------------------------- /app/public/assets/logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ENT8R/NotesReview/HEAD/app/public/assets/logo.jpg -------------------------------------------------------------------------------- /assets/screenshots/dark/list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ENT8R/NotesReview/HEAD/assets/screenshots/dark/list.png -------------------------------------------------------------------------------- /assets/screenshots/dark/map.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ENT8R/NotesReview/HEAD/assets/screenshots/dark/map.png -------------------------------------------------------------------------------- /assets/screenshots/light/map.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ENT8R/NotesReview/HEAD/assets/screenshots/light/map.png -------------------------------------------------------------------------------- /app/public/icons/any/icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ENT8R/NotesReview/HEAD/app/public/icons/any/icon-72x72.png -------------------------------------------------------------------------------- /app/public/icons/any/icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ENT8R/NotesReview/HEAD/app/public/icons/any/icon-96x96.png -------------------------------------------------------------------------------- /app/public/icons/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ENT8R/NotesReview/HEAD/app/public/icons/icon-512x512.png -------------------------------------------------------------------------------- /assets/screenshots/light/images.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ENT8R/NotesReview/HEAD/assets/screenshots/light/images.png -------------------------------------------------------------------------------- /assets/screenshots/light/list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ENT8R/NotesReview/HEAD/assets/screenshots/light/list.png -------------------------------------------------------------------------------- /app/public/icons/any/icon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ENT8R/NotesReview/HEAD/app/public/icons/any/icon-128x128.png -------------------------------------------------------------------------------- /app/public/icons/any/icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ENT8R/NotesReview/HEAD/app/public/icons/any/icon-144x144.png -------------------------------------------------------------------------------- /app/public/icons/any/icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ENT8R/NotesReview/HEAD/app/public/icons/any/icon-152x152.png -------------------------------------------------------------------------------- /app/public/icons/any/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ENT8R/NotesReview/HEAD/app/public/icons/any/icon-192x192.png -------------------------------------------------------------------------------- /app/public/icons/any/icon-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ENT8R/NotesReview/HEAD/app/public/icons/any/icon-384x384.png -------------------------------------------------------------------------------- /app/public/icons/any/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ENT8R/NotesReview/HEAD/app/public/icons/any/icon-512x512.png -------------------------------------------------------------------------------- /assets/screenshots/light/comments.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ENT8R/NotesReview/HEAD/assets/screenshots/light/comments.png -------------------------------------------------------------------------------- /app/public/icons/maskable/icon-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ENT8R/NotesReview/HEAD/app/public/icons/maskable/icon-48x48.png -------------------------------------------------------------------------------- /app/public/icons/maskable/icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ENT8R/NotesReview/HEAD/app/public/icons/maskable/icon-72x72.png -------------------------------------------------------------------------------- /app/public/icons/maskable/icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ENT8R/NotesReview/HEAD/app/public/icons/maskable/icon-96x96.png -------------------------------------------------------------------------------- /app/public/icons/maskable/icon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ENT8R/NotesReview/HEAD/app/public/icons/maskable/icon-128x128.png -------------------------------------------------------------------------------- /app/public/icons/maskable/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ENT8R/NotesReview/HEAD/app/public/icons/maskable/icon-192x192.png -------------------------------------------------------------------------------- /app/public/icons/maskable/icon-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ENT8R/NotesReview/HEAD/app/public/icons/maskable/icon-384x384.png -------------------------------------------------------------------------------- /app/public/icons/maskable/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ENT8R/NotesReview/HEAD/app/public/icons/maskable/icon-512x512.png -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | # The list of supported browsers is taken from the Vite Documentation: 2 | # https://vitejs.dev/guide/build.html#browser-compatibility 3 | 4 | defaults 5 | chrome >= 107 6 | firefox >= 104 7 | edge >= 107 8 | safari >= 16 9 | not ie <= 11 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node 2 | 3 | COPY . /app 4 | WORKDIR /app 5 | 6 | RUN npm install 7 | EXPOSE 5173 8 | 9 | ENV NOTESREVIEW_API_URL=https://api.notesreview.org 10 | ENV OPENSTREETMAP_SERVER=https://www.openstreetmap.org 11 | 12 | CMD ["npm", "run", "dev", "--", "--host"] 13 | -------------------------------------------------------------------------------- /app/templates/modal.hbs: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | # Unix style files 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.{js,hbs}] 13 | indent_style = space 14 | indent_size = 2 15 | 16 | [app/locales/*.json] 17 | indent_style = space 18 | indent_size = 4 19 | insert_final_newline = false 20 | -------------------------------------------------------------------------------- /app/public/landing.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | lint: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v6 14 | - name: Install Node.js and setup npm cache 15 | uses: ./.github/actions/node-and-npm-cache 16 | - name: Lint 17 | run: | 18 | npm ci 19 | npm run lint 20 | -------------------------------------------------------------------------------- /app/css/colors.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | --green-dark-primary: #2e7d32; 3 | --green-dark-secondary: #286c2b; 4 | 5 | --green-primary: #689f38; 6 | --green-secondary: #5b8c31; 7 | 8 | --lime-primary: #afb42b; 9 | --lime-secondary: #9ca126; 10 | 11 | --amber-primary: #ffa000; 12 | --amber-secondary: #e69000; 13 | 14 | --orange-primary: #f57c00; 15 | --orange-secondary: #d66c00; 16 | 17 | --red-primary: #e53935; 18 | --red-secondary: #e2221d; 19 | } 20 | -------------------------------------------------------------------------------- /app/templates/modals/area-selector.hbs: -------------------------------------------------------------------------------- 1 | 5 | 6 | 10 | -------------------------------------------------------------------------------- /app/templates/modals/mapillary.hbs: -------------------------------------------------------------------------------- 1 | 4 | 5 | 12 | -------------------------------------------------------------------------------- /app/css/icons.scss: -------------------------------------------------------------------------------- 1 | .country-flag { 2 | vertical-align: middle; 3 | } 4 | 5 | /* Class selector is used twice in order to increase specificity 6 | and override additional layout used by Spectre.css 7 | see https://developer.mozilla.org/en-US/docs/Web/CSS/Specificity */ 8 | .icon.icon { 9 | vertical-align: bottom; 10 | width: 24px; 11 | height: 24px; 12 | &.icon-small { 13 | width: 20px; 14 | height: 20px; 15 | } 16 | &.icon-large { 17 | width: 28px; 18 | height: 28px; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.github/actions/node-and-npm-cache/action.yml: -------------------------------------------------------------------------------- 1 | name: Install Node.js and setup npm cache 2 | 3 | runs: 4 | using: 'composite' 5 | steps: 6 | - name: Setup node 7 | uses: actions/setup-node@v6 8 | with: 9 | node-version: 'lts/*' 10 | 11 | - name: Cache dependencies 12 | uses: actions/cache@v5 13 | with: 14 | path: ~/.npm 15 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 16 | restore-keys: | 17 | ${{ runner.os }}-node- 18 | -------------------------------------------------------------------------------- /app/templates/dynamic/actions.hbs: -------------------------------------------------------------------------------- 1 | {{#each .}} 2 | {{#if link}} 3 | 5 | 6 | {{text}} 7 | 8 | {{else}} 9 | 13 | {{/if}} 14 | {{/each}} 15 | -------------------------------------------------------------------------------- /app/svg/icon/flash.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /.github/lighthouse/lighthouserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "ci": { 3 | "collect": { 4 | "url": [ 5 | "https://localhost:4173/" 6 | ], 7 | "startServerCommand": "npm run serve", 8 | "startServerReadyPattern": "running|listen|ready|local", 9 | "numberOfRuns": 3, 10 | "settings": { 11 | "chromeFlags": "--no-sandbox --ignore-certificate-errors", 12 | "preset": "desktop" 13 | } 14 | }, 15 | "upload": { 16 | "target": "temporary-public-storage" 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /app/templates/modals/comments.hbs: -------------------------------------------------------------------------------- 1 | 4 | 5 | 12 | -------------------------------------------------------------------------------- /app/svg/icon/download.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/svg/icon/account.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/svg/icon/close.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/svg/icon/filter.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/svg/icon/search.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/svg/icon/flag.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/templates/dynamic/note.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 | {{{badges.user}}} 6 | {{{badges.age}}} 7 | {{{badges.comments}}} 8 | {{{badges.country}}} 9 |
10 |
11 | {{{badges.report}}} 12 |
13 |
14 |
15 | 16 |
17 | {{{comments.0.html}}} 18 |
19 | 20 | 23 |
24 | -------------------------------------------------------------------------------- /app/svg/icon/help.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/svg/icon/refresh.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/svg/icon/list.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/css/markers.scss: -------------------------------------------------------------------------------- 1 | .marker { 2 | width: 25px; 3 | height: 40px; 4 | 5 | &.green-dark { 6 | --fill-color: var(--green-dark-primary); 7 | --stroke-color: var(--green-dark-secondary); 8 | } 9 | &.green { 10 | --fill-color: var(--green-primary); 11 | --stroke-color: var(--green-secondary); 12 | } 13 | &.lime { 14 | --fill-color: var(--lime-primary); 15 | --stroke-color: var(--lime-secondary); 16 | } 17 | &.amber { 18 | --fill-color: var(--amber-primary); 19 | --stroke-color: var(--amber-secondary); 20 | } 21 | &.orange { 22 | --fill-color: var(--orange-primary); 23 | --stroke-color: var(--orange-secondary) 24 | } 25 | &.red { 26 | --fill-color: var(--red-primary); 27 | --stroke-color: var(--red-secondary); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/svg/icon/trash.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/svg/icon/map.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/svg/icon/external.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/svg/icon/chat.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/templates/modals/filter.hbs: -------------------------------------------------------------------------------- 1 | 4 | 5 | 24 | -------------------------------------------------------------------------------- /.github/workflows/lighthouse.yml: -------------------------------------------------------------------------------- 1 | name: Lighthouse CI 2 | 3 | on: 4 | workflow_run: 5 | workflows: [Deploy to Github Pages] 6 | types: 7 | - completed 8 | workflow_dispatch: 9 | 10 | jobs: 11 | lighthouse: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v6 16 | - name: Build 17 | uses: ./.github/actions/build 18 | with: 19 | notesreview-api-url: ${{ secrets.NOTESREVIEW_API_URL }} 20 | - name: Lighthouse CI 21 | run: | 22 | npm install -g @lhci/cli@0.15.x 23 | lhci autorun --config=".github/lighthouse/lighthouserc.json" 24 | env: 25 | LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }} 26 | 27 | - name: Upload artifact 28 | uses: actions/upload-artifact@v4 29 | with: 30 | name: lighthouse-results 31 | path: .lighthouseci/*.html 32 | -------------------------------------------------------------------------------- /app/svg/icon/clipboard.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/svg/assets/liberapay.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/svg/assets/mapillary.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/svg/icon/share.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /.github/actions/build/action.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | inputs: 4 | notesreview-api-url: 5 | description: 'The URL of the NotesReview API' 6 | openstreetmap-server: 7 | description: 'The URL of the OpenStreetMap server' 8 | openstreetmap-oauth-client-id: 9 | description: 'The OpenStreetMap OAuth client id' 10 | openstreetmap-oauth-client-secret: 11 | description: 'The OpenStreetMap OAuth client secret' 12 | mapillary-client-id: 13 | description: 'The Mapillary client id' 14 | 15 | runs: 16 | using: 'composite' 17 | steps: 18 | - name: Install Node.js and setup npm cache 19 | uses: ./.github/actions/node-and-npm-cache 20 | - name: Install and Build 21 | env: 22 | NOTESREVIEW_API_URL: ${{ inputs.notesreview-api-url }} 23 | OPENSTREETMAP_SERVER: ${{ inputs.openstreetmap-server }} 24 | OPENSTREETMAP_OAUTH_CLIENT_ID: ${{ inputs.openstreetmap-oauth-client-id }} 25 | OPENSTREETMAP_OAUTH_CLIENT_SECRET: ${{ inputs.openstreetmap-oauth-client-secret }} 26 | MAPILLARY_CLIENT_ID: ${{ inputs.mapillary-client-id }} 27 | run: | 28 | npm ci 29 | npm run build 30 | shell: bash 31 | -------------------------------------------------------------------------------- /.github/workflows/gh-pages.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to Github Pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | contents: read 10 | pages: write 11 | id-token: write 12 | 13 | jobs: 14 | deploy: 15 | environment: 16 | name: github-pages 17 | url: ${{ steps.deployment.outputs.page_url }} 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v6 22 | - name: Build 23 | uses: ./.github/actions/build 24 | with: 25 | notesreview-api-url: ${{ secrets.NOTESREVIEW_API_URL }} 26 | openstreetmap-server: ${{ secrets.OPENSTREETMAP_SERVER }} 27 | openstreetmap-oauth-client-id: ${{ secrets.OPENSTREETMAP_OAUTH_CLIENT_ID }} 28 | openstreetmap-oauth-client-secret: ${{ secrets.OPENSTREETMAP_OAUTH_CLIENT_SECRET }} 29 | mapillary-client-id: ${{ secrets.MAPILLARY_CLIENT_ID }} 30 | - name: Upload artifact 31 | uses: actions/upload-pages-artifact@v4 32 | with: 33 | path: './app/dist' 34 | - name: Deploy to GitHub Pages 35 | id: deployment 36 | uses: actions/deploy-pages@v4 37 | -------------------------------------------------------------------------------- /app/templates/includes/structuredData.json: -------------------------------------------------------------------------------- 1 | { 2 | "@context": "https://schema.org", 3 | "@type": "WebApplication", 4 | "name": "NotesReview", 5 | "author": "ENT8R", 6 | "description": "An interface for searching and resolving OpenStreetMap notes", 7 | "logo": "https://notesreview.org/icons/icon.png", 8 | "url": "https://notesreview.org/", 9 | "applicationCategory": "Utility", 10 | "applicationSubCategory": "OpenStreetMap Utility", 11 | "browserRequirements": "Requires JavaScript. Requires HTML5.", 12 | "isAccessibleForFree": true, 13 | "license": "https://www.gnu.org/licenses/gpl-3.0.html", 14 | "operatingSystem": "All", 15 | "releaseNotes": "https://github.com/ENT8R/NotesReview/releases", 16 | "softwareHelp": { 17 | "@type": "CreativeWork", 18 | "url": "https://github.com/ENT8R/NotesReview/issues" 19 | }, 20 | "softwareVersion": "{{version}}", 21 | "screenshot": "https://cdn.jsdelivr.net/gh/ENT8R/NotesReview/assets/screenshots/light/map.png", 22 | "potentialAction": { 23 | "@type": "SearchAction", 24 | "target": "https://notesreview.org/?query={search_term_string}", 25 | "query-input": "required name=search_term_string" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/svg/icon/settings.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/svg/icon/mapillary.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/js/modals/comments.js: -------------------------------------------------------------------------------- 1 | import Modal from './modal.js'; 2 | 3 | import * as Handlebars from 'handlebars'; 4 | import t from '../../templates/dynamic/comment.hbs?raw'; 5 | const template = Handlebars.compile(t); 6 | 7 | export default class Comments extends Modal { 8 | /** 9 | * Show all comments of a given note in a modal 10 | * 11 | * @function 12 | * @private 13 | * @param {Note} note 14 | * @returns {void} 15 | */ 16 | static load(note) { 17 | super.open('comments'); 18 | 19 | const content = document.getElementById('modal-comments-content'); 20 | content.innerHTML = template(note, { 21 | allowedProtoProperties: { 22 | // Explicitly disable the access to the actions property, 23 | // because the actions should not be shown (again) in the comments modal 24 | actions: false, 25 | badges: true 26 | } 27 | }); 28 | content.dataset.noteId = note.id; 29 | document.getElementById('modal-comments-note-link').href = `${OPENSTREETMAP_SERVER}/note/${note.id}`; 30 | 31 | // Clear the note input 32 | const input = content.querySelector('.note-comment'); 33 | input.value = ''; 34 | input.dispatchEvent(new Event('input')); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /app/js/ui/list.js: -------------------------------------------------------------------------------- 1 | import * as Handlebars from 'handlebars'; 2 | import t from '../../templates/dynamic/note.hbs?raw'; 3 | const template = Handlebars.compile(t); 4 | 5 | export default class List { 6 | constructor() { 7 | this.fragment = new DocumentFragment(); 8 | } 9 | 10 | /** 11 | * Add a note to the list as a card 12 | * 13 | * @function 14 | * @param {Note} note 15 | * @returns {Promise} 16 | */ 17 | add(note) { 18 | const div = document.createElement('div'); 19 | div.classList.add('column', 'col-3', 'col-xl-4', 'col-md-6', 'col-sm-12', 'p-1'); 20 | div.innerHTML = template(note, { 21 | allowedProtoProperties: { 22 | actions: true, 23 | badges: true 24 | } 25 | }); 26 | this.fragment.appendChild(div); 27 | } 28 | 29 | /** 30 | * Shows all notes 31 | * 32 | * @function 33 | * @todo Use {@link https://developer.mozilla.org/en-US/docs/Web/API/Element/replaceChildren} if this becomes more established 34 | * chrome >= 86, edge >= 86, firefox >= 78, not ie <= 11, opera >= 72, safari >= 14 35 | * @returns {void} 36 | */ 37 | apply() { 38 | const container = document.getElementById('list'); 39 | while (container.lastChild) { 40 | container.removeChild(container.lastChild); 41 | } 42 | container.appendChild(this.fragment); 43 | 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /app/js/api/openstreetmap.js: -------------------------------------------------------------------------------- 1 | import Preferences from '../preferences.js'; 2 | import Request, { MEDIA_TYPE } from '../request.js'; 3 | 4 | export default class OsmApi { 5 | /** 6 | * Get the details of the user that is currently logged in 7 | * 8 | * @function 9 | * @returns {Promise} 10 | */ 11 | userDetails() { 12 | return Request(`${OPENSTREETMAP_SERVER}/api/0.6/user/details.json`, MEDIA_TYPE.JSON, { 13 | method: 'GET', 14 | headers: { 15 | 'Authorization': `Bearer ${Preferences.get('oauth2_access_token')}` 16 | } 17 | }); 18 | } 19 | 20 | /** 21 | * Post a new comment to a note 22 | * 23 | * @function 24 | * @param {Number} id 25 | * @param {String} text 26 | * @param {String} action 27 | * @returns {Promise} 28 | */ 29 | comment(id, text, action) { 30 | if (!text) { 31 | return Promise.reject(new Error('No text was given')); 32 | } 33 | if (!action) { 34 | action = 'comment'; 35 | } 36 | return Request( 37 | `${OPENSTREETMAP_SERVER}/api/0.6/notes/${id}/${action}.json`, MEDIA_TYPE.JSON, { 38 | method: 'POST', 39 | headers: { 40 | 'Authorization': `Bearer ${Preferences.get('oauth2_access_token')}`, 41 | 'Content-Type': 'application/json' 42 | }, 43 | body: JSON.stringify({ 44 | text 45 | }) 46 | }); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /app/js/theme.js: -------------------------------------------------------------------------------- 1 | import Preferences from './preferences.js'; 2 | 3 | export const LIGHT = 'light'; 4 | export const DARK = 'dark'; 5 | export const SYSTEM = 'system'; 6 | 7 | let theme = Preferences.get('theme') || SYSTEM; 8 | 9 | const query = window.matchMedia('(prefers-color-scheme: dark)'); 10 | document.body.dataset.theme = get(); 11 | 12 | /** 13 | * Set the new theme and apply the changes 14 | * 15 | * @function 16 | * @param {String} newTheme 17 | * @returns {void} 18 | */ 19 | export function set(newTheme) { 20 | if (newTheme === theme) { 21 | return; 22 | } 23 | 24 | theme = newTheme; 25 | changed(); 26 | 27 | if (theme === SYSTEM) { 28 | query.addListener(changed); 29 | } else { 30 | query.removeListener(changed); 31 | } 32 | } 33 | 34 | /** 35 | * Get the current theme 36 | * 37 | * @function 38 | * @returns {String} 39 | */ 40 | export function get() { 41 | switch (theme) { 42 | case LIGHT: 43 | return LIGHT; 44 | case DARK: 45 | return DARK; 46 | case SYSTEM: 47 | return query.matches ? DARK : LIGHT; 48 | default: 49 | return LIGHT; 50 | } 51 | } 52 | 53 | /** 54 | * Listener function which is called when the theme changed 55 | * 56 | * @function 57 | * @returns {void} 58 | */ 59 | function changed() { 60 | document.body.dataset.theme = get(); 61 | document.dispatchEvent(new Event('color-scheme-changed')); 62 | } 63 | -------------------------------------------------------------------------------- /app/templates/dynamic/comment.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 | {{#each comments}} 4 |
5 |
6 |
7 | {{{badges.user}}} 8 | {{{badges.age}}} 9 | {{{badges.status}}} 10 |
11 |

{{{html}}}

12 |
13 |
14 |
15 | {{/each}} 16 | 17 |
18 | 20 | 21 |
22 | 23 | 24 | 25 |
26 |
27 |
28 | 29 |
30 | 31 |
32 | {{> actions actions}} 33 |
34 |
35 | -------------------------------------------------------------------------------- /app/templates/dynamic/mapillary.hbs: -------------------------------------------------------------------------------- 1 | {{#if images.length}} 2 | {{#each images}} 3 |
4 |
5 | 6 | 7 | 8 | 9 | 10 | @{{user}}, {{capturedAt}} 11 | 12 | (CC BY-SA 4.0) 13 | 14 | 15 |
16 |
17 | {{/each}} 18 | {{else}} 19 |
20 |
21 | 22 |
23 |

{{localizer 'mapillary.empty.title'}}

24 |

{{localizer 'mapillary.empty.subtitle'}}

25 |
26 | 27 | 28 | {{localizer 'mapillary.empty.add'}} 29 | 30 |
31 |
32 | {{/if}} 33 | -------------------------------------------------------------------------------- /app/js/modals/modal.js: -------------------------------------------------------------------------------- 1 | export default class Modal { 2 | /** 3 | * Initialize the modal open and close triggers 4 | * 5 | * @function 6 | * @private 7 | * @returns {void} 8 | */ 9 | static init() { 10 | Array.from(document.getElementsByClassName('modal-trigger')).forEach(element => { 11 | element.addEventListener('click', () => { 12 | Modal.open(element.dataset.modal); 13 | }); 14 | }); 15 | 16 | Array.from(document.getElementsByClassName('modal-close')).forEach(element => { 17 | element.addEventListener('click', event => { 18 | Modal.close(event.target.closest('.modal')); 19 | }); 20 | }); 21 | } 22 | 23 | /** 24 | * Open a modal by its identifier 25 | * 26 | * @function 27 | * @param {String} id 28 | * @returns {void} 29 | */ 30 | static open(id) { 31 | const modal = document.querySelector(`.modal[data-modal="${id}"]`); 32 | modal.classList.add('active'); 33 | modal.getElementsByClassName('modal-body')[0].scrollTop = 0; 34 | modal.dispatchEvent(new Event('modal-open')); 35 | } 36 | 37 | /** 38 | * Close a specified modal 39 | * 40 | * @function 41 | * @param {HTMLElement} modal 42 | * @returns {void} 43 | */ 44 | static close(modal) { 45 | modal.classList.remove('active'); 46 | modal.querySelectorAll('.clear-on-modal-close').forEach(element => element.innerHTML = ''); 47 | modal.dispatchEvent(new Event('modal-close')); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /app/js/comment.js: -------------------------------------------------------------------------------- 1 | import * as Badges from './badges.js'; 2 | import Linkify from './linkify.js'; 3 | import * as Localizer from './localizer.js'; 4 | import * as Util from './util.js'; 5 | 6 | export default class Comment { 7 | /** 8 | * Parses a comment of a note and extract the needed information 9 | * 10 | * @constructor 11 | * @param {Object} comment 12 | */ 13 | constructor(comment) { 14 | this.anonymous = comment.user ? false : true; 15 | this.user = this.anonymous ? Localizer.message('note.anonymous') : comment.user; 16 | this.uid = this.anonymous ? null : comment.uid; 17 | this.date = new Date(comment.date); 18 | this.color = Util.parseDate(this.date); 19 | this.action = comment.action; 20 | this.text = null; 21 | this.html = null; 22 | this.images = []; 23 | 24 | if ('text' in comment) { 25 | this.text = comment.text; 26 | 27 | // Escape HTML tags in the comment before proceeding 28 | comment.text = Util.escape(comment.text); 29 | 30 | const { html, images } = Linkify(comment.text); 31 | this.html = html; 32 | this.images = images; 33 | } 34 | } 35 | 36 | /** 37 | * Create all necessary badges dynamically 38 | * 39 | * @function 40 | * @returns {Object} 41 | */ 42 | get badges() { 43 | return { 44 | age: Badges.age(this.color, this.date), 45 | user: Badges.user(this.uid, this.anonymous), 46 | status: Badges.status(this.action) 47 | }; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /app/js/request.js: -------------------------------------------------------------------------------- 1 | export const MEDIA_TYPE = { 2 | TEXT: 'text/plain', 3 | JSON: 'application/json', 4 | XML: 'text/xml', 5 | PROTOBUF: 'application/x-protobuf' 6 | }; 7 | 8 | /** 9 | * Request an external ressource and return it using the specified media type 10 | * 11 | * @function 12 | * @private 13 | * @param {String} url 14 | * @param {MEDIA_TYPE} mediaType 15 | * @param {Object} options 16 | * @param {AbortController} controller 17 | * @returns {Promise} 18 | */ 19 | export default function request(url, mediaType = MEDIA_TYPE.JSON, options = {}, controller = new AbortController()) { 20 | return fetch(url, Object.assign({ 21 | signal: controller.signal 22 | }, options)).then(response => { 23 | switch (mediaType) { 24 | case MEDIA_TYPE.JSON: 25 | return response.json(); 26 | case MEDIA_TYPE.PROTOBUF: 27 | return response.arrayBuffer(); 28 | default: 29 | return response.text(); 30 | } 31 | }).then(text => { 32 | switch (mediaType) { 33 | case MEDIA_TYPE.XML: 34 | return new DOMParser().parseFromString(text, 'text/xml'); 35 | default: 36 | return text; 37 | } 38 | }).catch(error => { 39 | // Catch aborted requests and ignore them, rethrow all other errors 40 | if (error.name === 'AbortError') { 41 | console.log(`Aborted request while fetching file at ${url}: ${error}`); // eslint-disable-line no-console 42 | } else { 43 | console.log(`Error while fetching file at ${url}: ${error}`); // eslint-disable-line no-console 44 | throw error; 45 | } 46 | }); 47 | } 48 | -------------------------------------------------------------------------------- /app/templates/modals/share.hbs: -------------------------------------------------------------------------------- 1 | 4 | 5 | 32 | 33 | 41 | -------------------------------------------------------------------------------- /app/js/toast.js: -------------------------------------------------------------------------------- 1 | import * as Localizer from './localizer.js'; 2 | import { wait, waitForFocus } from './util.js'; 3 | 4 | export default class Toast { 5 | static DURATION_SHORT = 2000; 6 | static DURATION_DEFAULT = 4000; 7 | static DURATION_LONG = 8000; 8 | 9 | /** 10 | * Initialize a small toast notification with the given message 11 | * 12 | * @constructor 13 | * @param {String} message 14 | * @param {String} type 15 | */ 16 | constructor(message, type) { 17 | this.container = document.getElementById('toast-container'); 18 | 19 | this.toast = document.createElement('div'); 20 | this.toast.classList.add('toast', type || 'toast-primary'); 21 | 22 | // Add a close button 23 | this.close = document.createElement('button'); 24 | this.close.classList.add('btn', 'btn-clear', 'float-right'); 25 | this.close.setAttribute('aria-label', Localizer.message('accessibility.closeNotification')); 26 | this.close.addEventListener('click', () => { 27 | this.container.removeChild(this.toast); 28 | }); 29 | this.toast.appendChild(this.close); 30 | 31 | // Add the message of the toast 32 | this.toast.appendChild(document.createTextNode(message)); 33 | } 34 | 35 | /** 36 | * Show the toast notification 37 | * 38 | * @constructor 39 | * @param {Number} duration 40 | */ 41 | async show(duration) { 42 | this.container.appendChild(this.toast); 43 | await waitForFocus(); 44 | await wait(duration || Toast.DURATION_DEFAULT); 45 | if (this.container.contains(this.toast)) { 46 | this.container.removeChild(this.toast); 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /app/js/effects.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Fade an element from the current state to full opacity in a given time. 3 | * 4 | * @function 5 | * @param {HTMLElement} element 6 | * @param {Number} duration Time in milliseconds. 7 | * @returns {Promise} 8 | */ 9 | export function fadeOut(element, duration) { 10 | return new Promise(resolve => { 11 | const step = 25 / (duration || 300); 12 | element.style.opacity = element.style.opacity || 1; 13 | (function fade() { 14 | const opacity = element.style.opacity -= step; 15 | if (opacity < 0) { 16 | element.style.display = 'none'; 17 | return resolve(); 18 | } else { 19 | setTimeout(fade, 25); 20 | } 21 | })(); 22 | }); 23 | } 24 | 25 | /** 26 | * Fade out an element from the current state to full transparency in a given time 27 | * 28 | * @function 29 | * @param {HTMLElement} element 30 | * @param {Number} duration Time in milliseconds. 31 | * @param {String} display Display style of the element after the animation. 32 | * @returns {Promise} 33 | */ 34 | export function fadeIn(element, duration, display) { 35 | return new Promise(resolve => { 36 | const step = 25 / (duration || 300); 37 | element.style.opacity = element.style.opacity || 0; 38 | element.style.display = display || 'block'; 39 | (function fade() { 40 | const opacity = parseFloat(element.style.opacity) + step; 41 | element.style.opacity = opacity; 42 | if (opacity > 1) { 43 | element.style.opacity = 1; 44 | return resolve(); 45 | } else { 46 | setTimeout(fade, 25); 47 | } 48 | })(); 49 | }); 50 | } 51 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "notesreview", 3 | "version": "3.0.0", 4 | "description": "An interface for searching and resolving OpenStreetMap notes", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "serve": "vite preview", 10 | "lint": "eslint app/js/**/*.js", 11 | "test": "npm run lint" 12 | }, 13 | "author": "ENT8R ", 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/ENT8R/NotesReview.git" 17 | }, 18 | "license": "GPL-3.0", 19 | "dependencies": { 20 | "@badgateway/oauth2-client": "^3.3.1", 21 | "@github/relative-time-element": "^5.0.0", 22 | "@mapbox/tilebelt": "^2.0.3", 23 | "@mapbox/vector-tile": "^2.0.4", 24 | "@rapideditor/country-coder": "^5.6.0", 25 | "@rapideditor/location-conflation": "^2.0.1", 26 | "@vitejs/plugin-basic-ssl": "^2.1.0", 27 | "leaflet": "^1.9.4", 28 | "leaflet-control-geocoder": "^3.3.1", 29 | "leaflet-draw": "^1.0.4", 30 | "leaflet.markercluster": "^1.5.3", 31 | "linkify-string": "^4.3.2", 32 | "linkifyjs": "^4.3.2", 33 | "pbf": "^4.0.1", 34 | "relative-time-format": "^1.1.11", 35 | "spectre.css": "^0.5.9" 36 | }, 37 | "devDependencies": { 38 | "@eslint/js": "^9.39.2", 39 | "@stylistic/eslint-plugin": "^5.6.1", 40 | "dotenv": "^17.2.3", 41 | "eslint": "^9.39.2", 42 | "eslint-plugin-jsdoc": "^61.5.0", 43 | "fast-glob": "^3.3.3", 44 | "globals": "^16.5.0", 45 | "handlebars": "^4.7.8", 46 | "sass": "^1.96.0", 47 | "vite": "^7.2.7", 48 | "vite-plugin-handlebars": "^2.0.0", 49 | "vite-plugin-svg-icons": "^2.0.1" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /app/templates/includes/head.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 30 | 31 | NotesReview 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /app/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "NotesReview", 3 | "short_name": "NotesReview", 4 | "description": "An interface for searching and resolving OpenStreetMap notes", 5 | "lang": "en", 6 | "theme_color": "#fff", 7 | "background_color": "#fff", 8 | "display": "standalone", 9 | "orientation": "portrait", 10 | "start_url": "/", 11 | "scope": "/", 12 | "shortcuts": [ 13 | { 14 | "name": "View map", 15 | "short_name": "Map", 16 | "description": "View notes on a map", 17 | "url": "/?view=map", 18 | "icons": [{ "src": "icons/map.png", "sizes": "192x192" }] 19 | }, 20 | { 21 | "name": "View list", 22 | "short_name": "List", 23 | "description": "View notes in a list", 24 | "url": "/?view=list", 25 | "icons": [{ "src": "icons/list.png", "sizes": "192x192" }] 26 | } 27 | ], 28 | "icons": [ 29 | { 30 | "src": "icons/any/icon-72x72.png", 31 | "sizes": "72x72", 32 | "type": "image/png" 33 | }, 34 | { 35 | "src": "icons/any/icon-96x96.png", 36 | "sizes": "96x96", 37 | "type": "image/png" 38 | }, 39 | { 40 | "src": "icons/any/icon-128x128.png", 41 | "sizes": "128x128", 42 | "type": "image/png" 43 | }, 44 | { 45 | "src": "icons/any/icon-144x144.png", 46 | "sizes": "144x144", 47 | "type": "image/png" 48 | }, 49 | { 50 | "src": "icons/any/icon-152x152.png", 51 | "sizes": "152x152", 52 | "type": "image/png" 53 | }, 54 | { 55 | "src": "icons/any/icon-192x192.png", 56 | "sizes": "192x192", 57 | "type": "image/png" 58 | }, 59 | { 60 | "src": "icons/any/icon-384x384.png", 61 | "sizes": "384x384", 62 | "type": "image/png" 63 | }, 64 | { 65 | "src": "icons/any/icon-512x512.png", 66 | "sizes": "512x512", 67 | "type": "image/png" 68 | } 69 | ] 70 | } 71 | -------------------------------------------------------------------------------- /app/svg/marker/template.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 12 | 13 | 18 | 25 | 26 | -------------------------------------------------------------------------------- /app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{> head}} 5 | 6 | 7 | 8 |
9 | 15 | 16 |
17 | The browser you are currently using does not support NotesReview. 18 | Please consider to switch to another browser to be able to use NotesReview. 19 |
20 |
21 | 22 | {{> header}} 23 | 24 |
25 | {{> modal id="filter"}} 26 | {{> modal id="help"}} 27 | {{> modal id="share"}} 28 | {{> modal id="settings"}} 29 | 30 | {{> modal id="area-selector"}} 31 | {{> modal id="comments"}} 32 | {{> modal id="mapillary"}} 33 |
34 | 35 |
36 |
37 |
38 | 39 |
40 | 41 |
42 |
43 |
44 |
45 | 46 |
47 |
48 | 49 | 50 | 51 | 52 | Cancel 53 | 54 |
55 |
56 | 57 | 58 | 59 | {{> scripts}} 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /app/js/elements/SingleSelectionButtonGroup.js: -------------------------------------------------------------------------------- 1 | export default class SingleSelectionButtonGroup extends HTMLElement { 2 | // Identify the element as a form-associated custom element 3 | static formAssociated = true; 4 | 5 | constructor() { 6 | super(); 7 | // TODO: This is not yet supported across major browsers: chrome >= 77, firefox >= 93, edge >= 79, no safari, opera >= 64, no ie 8 | // See https://web.dev/more-capable-form-controls/#restoring-form-state and https://caniuse.com/mdn-api_htmlelement_attachinternals 9 | this._internals = 'attachInternals' in this ? this.attachInternals() : null; 10 | 11 | this.activeButton = this.querySelector('button.active'); 12 | } 13 | 14 | connectedCallback() { 15 | this.querySelectorAll('button').forEach(button => { 16 | button.addEventListener('click', () => { 17 | this.value = button.dataset.value || null; 18 | }); 19 | }); 20 | this.value = this.activeButton.dataset.value || null; 21 | } 22 | 23 | formStateRestoreCallback(state) { 24 | this.value = state; 25 | } 26 | 27 | get value() { 28 | return this._v; 29 | } 30 | 31 | set value(v) { 32 | // Find the next button to select 33 | const selector = v === null ? 'button:not([data-value])' : `button[data-value="${v}"]`; 34 | const nextButton = this.querySelector(selector); 35 | // If the button does not exist, the value can not be selected 36 | if (nextButton == null) { 37 | return; 38 | } 39 | 40 | // Reset the currently active button 41 | this.activeButton.classList.remove('active'); 42 | 43 | // Set the next button to an active state 44 | this.activeButton = nextButton; 45 | this.activeButton.classList.add('active'); 46 | 47 | // Set the new value and fire events 48 | this._v = v; 49 | this._internals && 'setFormValue' in this._internals ? this._internals.setFormValue(this._v) : null; // eslint-disable-line no-unused-expressions 50 | this.dispatchEvent(new Event('change')); 51 | this.dispatchEvent(new Event('input')); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | import { resolve } from 'path'; 3 | 4 | import dotenv from 'dotenv'; 5 | 6 | import basicSsl from '@vitejs/plugin-basic-ssl'; 7 | import handlebars from 'vite-plugin-handlebars'; 8 | import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'; 9 | 10 | import { version } from './package.json'; 11 | import structuredData from './app/templates/includes/structuredData.json'; 12 | structuredData.softwareVersion = version; 13 | 14 | export default () => { 15 | const root = resolve(__dirname, 'app'); 16 | dotenv.config({ 17 | path: resolve(root, '.env'), 18 | quiet: true 19 | }); 20 | 21 | const globals = { 22 | __VERSION__: JSON.stringify(version), 23 | NOTESREVIEW_API_URL: JSON.stringify(process.env.NOTESREVIEW_API_URL), 24 | OPENSTREETMAP_SERVER: JSON.stringify(process.env.OPENSTREETMAP_SERVER), 25 | OPENSTREETMAP_OAUTH_CLIENT_ID: JSON.stringify(process.env.OPENSTREETMAP_OAUTH_CLIENT_ID), 26 | OPENSTREETMAP_OAUTH_CLIENT_SECRET: JSON.stringify(process.env.OPENSTREETMAP_OAUTH_CLIENT_SECRET), 27 | MAPILLARY_CLIENT_ID: JSON.stringify(process.env.MAPILLARY_CLIENT_ID) 28 | }; 29 | 30 | return { 31 | base: '/', 32 | root, 33 | define: globals, 34 | build: { 35 | sourcemap: true, 36 | rollupOptions: { 37 | output: { 38 | manualChunks: { 39 | countryCoder: ['@rapideditor/country-coder', '@rapideditor/location-conflation'], 40 | leaflet: ['leaflet', 'leaflet-control-geocoder', 'leaflet-draw', 'leaflet.markercluster'], 41 | } 42 | } 43 | } 44 | }, 45 | plugins: [ 46 | basicSsl(), 47 | handlebars({ 48 | partialDirectory: [ 49 | resolve(root, 'templates'), 50 | resolve(root, 'templates/includes'), 51 | resolve(root, 'templates/modals') 52 | ], 53 | context: { 54 | structuredData: JSON.stringify(structuredData), 55 | version, 56 | api: process.env.NOTESREVIEW_API_URL 57 | } 58 | }), 59 | createSvgIconsPlugin({ 60 | iconDirs: [resolve(root, 'svg')], 61 | symbolId: '[dir]-[name]' 62 | }) 63 | ] 64 | }; 65 | }; 66 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | NotesReview 3 |

4 | 5 | > Tool for solving OpenStreetMap Notes 6 | 7 | Displays OpenStreetMap Notes in a way it is fun working with them 😎 8 | 9 | Notes are a very important part of the OpenStreetMap ecosystem. Every user of the map can report missing things or outdated details. But very often the notes are never resolved at all which could lead to frustrated users 😞. This website provides a better way to resolve notes and improve the map. ✔️ 10 | 11 | ## Features 12 | 13 | - 📍 Notes can be viewed either on a map or in a list 14 | - ✂️ Useful filters to show only the notes you are interested in 15 | - 💬 View all comments of a note directly on the website 16 | - ✍️ Open your favorite editor directly in order to process the note 17 | - 💬 Comment on every note directly using the interface 18 | - 🛣️ Integration of [Mapillary](https://www.mapillary.com/) street-level imagery 19 | - 📷 Automatic detection of images on image hosting servers 20 | - 🔦 A dark mode (for working at night 😉) 21 | 22 | ## Contributing 23 | 24 | You can help by translating the website into your language at POEditor. 25 | 26 | Follow [**this link** to improve the translations](https://poeditor.com/join/project/oVilUChBdf): 27 | 28 | [![POEditor](https://poeditor.com/public/images/logo_small.png)](https://poeditor.com/join/project/oVilUChBdf) 29 | 30 | You found an issue or want to propose a new feature? Then please use the [issue tracker](https://github.com/ENT8R/NotesReview/issues/) of this repository. 31 | 32 | --- 33 | 34 | For further information on how to contribute you might also want to read the [contributing guidelines](https://github.com/ENT8R/NotesReview/blob/main/CONTRIBUTING.md). 35 | 36 | ## Screenshots 37 | 38 | 39 | 40 | 41 | 42 | ## License 43 | 44 | NotesReview is available under the [GNU GPL-3.0 License](https://opensource.org/licenses/GPL-3.0). 45 | See the [LICENSE](LICENSE) file for more details. 46 | -------------------------------------------------------------------------------- /app/js/modals/share.js: -------------------------------------------------------------------------------- 1 | import * as Localizer from '../localizer.js'; 2 | import Modal from './modal.js'; 3 | import { AREA } from '../query.js'; 4 | import Toast from '../toast.js'; 5 | 6 | export default class Share extends Modal { 7 | /** 8 | * Initializes the sharing modal 9 | * 10 | * @constructor 11 | * @param {Query} query 12 | * @returns {void} 13 | */ 14 | constructor(query) { 15 | super(); 16 | 17 | // Update links if the share modal is opened 18 | document.querySelector('.modal[data-modal="share"]').addEventListener('modal-open', () => { 19 | document.getElementById('permalink').value = query.permalink; 20 | 21 | document.getElementById('download').href = URL.createObjectURL(new Blob([JSON.stringify(query.result, null, 2)], { 22 | type: 'application/json', 23 | })); 24 | document.getElementById('download').download = `NotesReview-${query.history[query.history.length - 1].time.toISOString()}.json`; 25 | 26 | // Only show the checkbox for adding the polygon shape if it would have an effect 27 | // (i.e. a custom area is used, no countries are selected but something was drawn on the map) 28 | document.getElementById('share-polygon-checkbox').style.display = 29 | (query.data.area === AREA.CUSTOM && query.data.countries === null && query.data.polygon !== null) ? 'block' : 'none'; 30 | }); 31 | 32 | // Update links if a parameter changed 33 | Array.from(document.getElementsByClassName('update-permalink')).forEach(element => { 34 | element.addEventListener('change', () => { 35 | document.getElementById('permalink').value = query.permalink; 36 | }); 37 | }); 38 | 39 | document.getElementById('permalink').addEventListener('click', document.getElementById('permalink').select); 40 | document.getElementById('permalink').addEventListener('dblclick', () => this.copy()); 41 | document.getElementById('permalink-copy').addEventListener('click', () => this.copy()); 42 | 43 | // Free memory if the share modal is closed 44 | document.querySelector('.modal[data-modal="share"]').addEventListener('modal-close', () => { 45 | URL.revokeObjectURL(document.getElementById('download').href); 46 | }); 47 | } 48 | 49 | /** 50 | * Copy permalink to clipboard 51 | * 52 | * @function 53 | * @returns {void} 54 | */ 55 | copy() { 56 | document.getElementById('permalink').select(); 57 | document.execCommand('copy'); 58 | new Toast(Localizer.message('action.copyLinkSuccess'), 'toast-success').show(); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing guidelines 2 | Thank you for your interest in contributing to NotesReview! 3 | 4 | If you want to work on improving the application, the following tips and hints may be useful for you. 😉 5 | 6 | ## Building and running 7 | NotesReview is a web application and is therefore mainly written in Javascript. `npm` is used as the preferred package manager, [`Vite` ⚡](https://github.com/vitejs/vite) is used as a module bundler. 8 | 9 | You may want to take a look at the [`package.json`](https://github.com/ENT8R/NotesReview/blob/main/package.json) file in order to get an overview on which modules are being used and what commands to run. 10 | 11 | Before running the application, make sure the file `app/.env` with the following required environment variables exists: 12 | ```shell 13 | NOTESREVIEW_API_URL=https://api.notesreview.org 14 | OPENSTREETMAP_SERVER=https://www.openstreetmap.org 15 | ``` 16 | There are a few more environment variables that are used for additional features, but they are not mandatory to run the application, so in most cases the configuration above should be sufficient. 17 | 18 | To setup, change and run the application locally, simply follow these steps: 19 | ```shell 20 | # 1. Install all necessary dependencies using npm 21 | npm install 22 | # 2. Start the application by running the following command 23 | # and visiting http://localhost:5173 in your browser 24 | npm run dev 25 | # 3. Now it's your turn — change, fix or improve something! 26 | ``` 27 | That's it already! Now you can submit your change as a [pull request on Github](https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/about-pull-requests). 28 | 29 | ### Docker 30 | If you want to run the application in a docker container, you can use the following commands instead: 31 | ```shell 32 | docker build --pull -t notesreview . 33 | docker run -t -i -p 3000:5173 notesreview 34 | ``` 35 | 36 | ## Translating 37 | If you want to translate the website into your language or fix a wrong term, you can do so by visiting the project at POEditor. 38 | 39 | Follow [**this link** to improve the translations](https://poeditor.com/join/project/oVilUChBdf): 40 | 41 | [![POEditor](https://poeditor.com/public/images/logo_small.png)](https://poeditor.com/join/project/oVilUChBdf) 42 | 43 | The use of POEditor is preferred over proposing new translations via pull requests. 44 | 45 | ## Testing and reporting bugs 46 | The easiest way to contribute is of course to use and test the application and report bugs on Github using the [issue tracker](https://github.com/ENT8R/NotesReview/issues/). Just let me know if you spot something that looks suspicious. 🙃 47 | -------------------------------------------------------------------------------- /app/js/preferences.js: -------------------------------------------------------------------------------- 1 | const JSON_REGEX = /^({|\[)[\s\S]*(}|\])$/m; 2 | 3 | const DEFAULTS = { 4 | view: 'map', 5 | map: { 6 | center: [0, 0], 7 | zoom: 2 8 | }, 9 | query: {}, 10 | theme: 'system', 11 | tools: { 12 | openstreetmap: true, 13 | mapillary: false, 14 | deepl: false 15 | }, 16 | editors: { 17 | id: true, 18 | rapid: false, 19 | josm: false, 20 | level0: true 21 | } 22 | }; 23 | 24 | export default class Preferences { 25 | /** 26 | * Get a specific preference by its key and if it's not available, return the default value 27 | * 28 | * @function 29 | * @param {String} key 30 | * @param {Boolean} temporary 31 | * @returns {String|Boolean|Object|Array} 32 | */ 33 | static get(key, temporary) { 34 | const storage = temporary ? window.sessionStorage : window.localStorage; 35 | 36 | let value = storage.getItem(key); 37 | 38 | if (JSON_REGEX.test(value)) { 39 | value = JSON.parse(value); 40 | } 41 | 42 | return value || DEFAULTS[key]; 43 | } 44 | 45 | /** 46 | * Set or change the value of a preference 47 | * 48 | * @function 49 | * @param {Object} preferences 50 | * @param {boolean} temporary 51 | * @returns {void} 52 | */ 53 | static set(preferences, temporary) { 54 | const storage = temporary ? window.sessionStorage : window.localStorage; 55 | 56 | for (const key in preferences) { 57 | if (Object.prototype.hasOwnProperty.call(preferences, key)) { 58 | const preference = preferences[key]; 59 | 60 | if (Array.isArray(preference)) { 61 | storage.setItem(key, JSON.stringify(preference)); 62 | } else if (typeof preference === 'object') { 63 | let value = storage.getItem(key); 64 | 65 | if (JSON_REGEX.test(value)) { 66 | value = JSON.parse(value); 67 | } else { 68 | value = {}; 69 | } 70 | 71 | storage.setItem(key, JSON.stringify(Object.assign(value, preference))); 72 | } else { 73 | storage.setItem(key, preference); 74 | } 75 | } 76 | } 77 | } 78 | 79 | /** 80 | * Remove a specific item from the storage 81 | * 82 | * @function 83 | * @param {String} key 84 | * @param {Boolean} temporary 85 | * @returns {void} 86 | */ 87 | static remove(key, temporary) { 88 | const storage = temporary ? window.sessionStorage : window.localStorage; 89 | storage.removeItem(key); 90 | } 91 | 92 | /** 93 | * Set all values to the default values 94 | * 95 | * @function 96 | * @returns {void} 97 | */ 98 | static reset() { 99 | window.sessionStorage.clear(); 100 | window.localStorage.clear(); 101 | Preferences.set(DEFAULTS); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /app/templates/modals/settings.hbs: -------------------------------------------------------------------------------- 1 | 4 | 5 | 55 | 56 | 60 | -------------------------------------------------------------------------------- /app/js/users.js: -------------------------------------------------------------------------------- 1 | import Request, { MEDIA_TYPE } from './request.js'; 2 | import * as Util from './util.js'; 3 | 4 | export default class Users { 5 | // TODO: This could be a private static property of this class 6 | // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/Private_class_fields#browser_compatibility 7 | // chrome >= 74, edge >= 79, firefox >= 90 (!), not ie <= 11, opera >= 62, safari >= 14.1 8 | static all = new Set(); 9 | static ids = new Set(); 10 | 11 | /** 12 | * Parses the XML document and returns all important information 13 | * 14 | * @constructor 15 | * @param {Set} ids 16 | * @returns {Promise} 17 | */ 18 | static load(ids) { 19 | const requests = []; 20 | ids = Util.chunk(Array.from(ids).filter(x => !Users.ids.has(x)), 500); 21 | for (let i = 0; i < ids.length; i++) { 22 | const url = `${OPENSTREETMAP_SERVER}/api/0.6/users?users=${ids[i].join(',')}`; 23 | const request = Request(url, MEDIA_TYPE.XML).then(xml => { 24 | if (xml && xml.documentElement && xml.querySelector('parsererror') == null) { 25 | Array.from(xml.documentElement.children).forEach(user => { 26 | Users.all.add({ 27 | id: Number.parseInt(user.getAttribute('id')), 28 | name: user.getAttribute('display_name'), 29 | created: new Date(user.getAttribute('account_created')), 30 | description: user.getElementsByTagName('description')[0].innerText, 31 | image: user.querySelector('img[href]') ? user.getElementsByTagName('img')[0].getAttribute('href') : null, 32 | changesets: user.getElementsByTagName('changesets')[0].getAttribute('count') 33 | }); 34 | Users.ids.add(Number.parseInt(user.getAttribute('id'))); 35 | }); 36 | } 37 | }); 38 | requests.push(request); 39 | } 40 | return Promise.all(requests); 41 | } 42 | 43 | /** 44 | * Get a user by its identifier 45 | * 46 | * @function 47 | * @param {Number} id 48 | * @returns {Object} 49 | */ 50 | static get(id) { 51 | if (!id || !Users.ids.has(id)) { 52 | return; 53 | } 54 | return Array.from(Users.all).find(user => user.id === id); 55 | } 56 | 57 | /** 58 | * Load the current user and show the corresponding avatar 59 | * 60 | * @function 61 | * @param {Number|String} uid 62 | * @returns {void} 63 | */ 64 | static async avatar(uid) { 65 | const avatar = document.getElementById('user-avatar'); 66 | if (!uid) { 67 | avatar.style.display = 'none'; 68 | return; 69 | } 70 | await Users.load(new Set([uid])); 71 | const user = Users.get(Number.parseInt(uid)); 72 | if (user) { 73 | avatar.style.display = 'inline-block'; 74 | avatar.dataset.initial = Util.initials(user.name); 75 | avatar.innerHTML = user.image ? `` : ''; 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /app/css/dark.scss: -------------------------------------------------------------------------------- 1 | $main-color: #263238; // blue-grey darken-4 2 | $main-color-surface: #37474f; // blue-grey darken-3 3 | $main-color-surface-light: #455a64; // blue-grey darken-2 4 | $main-color-surface-light-variant: #546e7a; // blue-grey darken-1 5 | $minor-color: #78909c; // blue-grey lighten-1 6 | $minor-color-surface: #90a4ae; // blue-grey lighten-2 7 | 8 | $link-color: #81d4fa; // light-blue lighten-3 9 | $text-color: rgba(255, 255, 255, 0.8); 10 | 11 | body[data-theme="dark"] { 12 | & { 13 | --background-color: #{$main-color}; 14 | --box-shadow-color: #090909; 15 | } 16 | 17 | &, 18 | .modal-container, 19 | .modal-header { 20 | background-color: $main-color; 21 | color: $text-color; 22 | } 23 | 24 | .card, 25 | .empty, 26 | .form-select, 27 | .form-input, 28 | .form-checkbox .form-icon, 29 | .leaflet-popup-tip, 30 | .leaflet-popup-content-wrapper, 31 | .leaflet-bar a, 32 | .leaflet-container, 33 | .leaflet-control-attribution, 34 | .leaflet-control-geocoder, 35 | .leaflet-control-geocoder-icon, 36 | .leaflet-control-geocoder-address-context, 37 | .leaflet-control-geocoder-alternatives li:hover, 38 | .leaflet-draw-actions a { 39 | background-color: $main-color-surface !important; 40 | color: $text-color; 41 | } 42 | 43 | .btn { 44 | background-color: $main-color-surface-light; 45 | color: $text-color; 46 | border-color: $minor-color; 47 | &.btn-primary { 48 | background-color: $main-color-surface-light-variant; 49 | &:focus, 50 | &:hover { 51 | border-color: $minor-color-surface; 52 | } 53 | } 54 | &.btn-link:hover { 55 | color: $text-color; 56 | } 57 | &.btn-clear:not(:focus) { 58 | background-color: transparent; 59 | } 60 | &.active { 61 | background-color: $main-color-surface; 62 | } 63 | } 64 | 65 | ::selection { 66 | background-color: $minor-color; 67 | } 68 | 69 | a { 70 | color: $link-color; 71 | } 72 | 73 | .avatar { 74 | background-color: #0288d1; // light-blue darken-2 75 | } 76 | 77 | .divider { 78 | border-top-color: $main-color-surface-light; 79 | } 80 | 81 | .modal { 82 | &.active .modal-overlay, &:target .modal-overlay { 83 | background: rgba(75, 75, 75, 0.5); 84 | } 85 | } 86 | 87 | .badge:not([data-badge])::after, .badge[data-badge]::after { 88 | background-color: $minor-color; 89 | } 90 | 91 | img:not(.leaflet-tile) { 92 | filter: brightness(.8) contrast(1.2); 93 | } 94 | 95 | .leaflet-control-geocoder-icon { 96 | background-image: url('data:image/svg+xml,'); 97 | } 98 | 99 | .leaflet-draw-toolbar.leaflet-bar a { 100 | background-color: $text-color !important; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /app/js/linkify.js: -------------------------------------------------------------------------------- 1 | import linkify from 'linkify-string'; 2 | 3 | import * as Util from './util.js'; 4 | 5 | const IMAGE_HOSTING_REGEX = { 6 | imgur: /(http(s)?:\/\/)?(i\.)?imgur\.com\/\w+\.(jpg|png)/i, 7 | framapic: /(http(s)?:\/\/)?(www\.)?framapic\.org\/(random\?i=)?\w+\/\w+(\.(jpg|jpeg|png))?/i, 8 | westnordost: /https:\/\/westnordost\.de\/p\/[0-9]+\.jpg/i, 9 | wikimedia: /http(?:s)?:\/\/upload\.wikimedia\.org\/wikipedia\/(.+?)\/(?:thumb\/)?(\w\/\w\w)\/(.+?\.(?:jpg|jpeg|png))(?:\/.+?\.(?:jpg|jpeg|png))?/i, 10 | commons: /http(?:s)?:\/\/commons\.wikimedia\.org\/wiki\/File:(.+?\.(?:jpg|jpeg|png|svg))/i, 11 | openstreetmap: /http(?:s)?:\/\/wiki\.openstreetmap\.org\/wiki\/File:(.+?\.(?:jpg|jpeg|png|svg))/i, 12 | mapillary: /http(?:s)?:\/\/(?:www\.)?mapillary\.com\/map\/im\/(\w+)/i, 13 | all: /(http(s)?:\/\/)?(www\.)?.+\.(jpg|jpeg|png)/i 14 | }; 15 | 16 | const IMAGE_HOSTING_ADDITIONAL_FORMATTING = { 17 | wikimedia: 'https://upload.wikimedia.org/wikipedia/$1/thumb/$2/$3/300px-$3', 18 | commons: 'https://commons.wikimedia.org/wiki/Special:FilePath/$1?width=300', 19 | openstreetmap: 'https://wiki.openstreetmap.org/wiki/Special:FilePath/$1?width=300', 20 | mapillary: 'https://images.mapillary.com/$1/thumb-320.jpg' 21 | }; 22 | 23 | /** 24 | * Linkify a given string (i.e. convert URLs to clickable links and images to image previews) 25 | * 26 | * @function 27 | * @param {String} input 28 | * @returns {String} 29 | */ 30 | export default function replace(input) { 31 | const images = []; 32 | const specialTransform = Object.entries(IMAGE_HOSTING_REGEX).map(([ provider, regex ]) => { 33 | return { 34 | test: regex, 35 | transform: url => { 36 | images.push(url); 37 | const formatting = IMAGE_HOSTING_ADDITIONAL_FORMATTING[provider]; 38 | if (formatting) { 39 | url = url.replace(regex, formatting); 40 | } 41 | url = Util.escape(url); 42 | return { 43 | url, 44 | image: `${url}` 45 | }; 46 | } 47 | }; 48 | }); 49 | 50 | const result = linkify(input, { 51 | nl2br: true, 52 | rel: 'noopener noreferrer', 53 | target: '_blank', 54 | render: ({ tagName, attributes, content }) => { 55 | // Check if any of the regexes match the link and transform accordingly 56 | for (const { test, transform } of specialTransform) { 57 | if (test.test(attributes.href)) { 58 | const result = transform(content); 59 | attributes.href = result.url; 60 | content = result.image; 61 | break; 62 | } 63 | } 64 | // Convert attributes object to string 65 | attributes = Object.entries(attributes).map(([k, v]) => `${k}="${v}"`).join(' '); 66 | return `<${tagName} ${attributes}>${content}`; 67 | } 68 | }).replace(/(\/a>)(
\n?)( 2 | 28 | 29 | 34 | 35 | 62 | 63 | -------------------------------------------------------------------------------- /app/js/ui/ui.js: -------------------------------------------------------------------------------- 1 | import { STATUS } from '../query.js'; 2 | import MapView from './map.js'; 3 | import ListView from './list.js'; 4 | 5 | const Views = { 6 | map: new MapView(), 7 | list: new ListView() 8 | }; 9 | 10 | export default class UI { 11 | /** 12 | * Constructor for controlling the view (e.g. a map or a list) 13 | * 14 | * @constructor 15 | * @param {View} view 16 | */ 17 | constructor(view) { 18 | this.notes = []; 19 | this.query = null; 20 | this.view = view; 21 | } 22 | 23 | set view(view) { 24 | const values = Object.keys(Views); 25 | if (!values.includes(view)) { 26 | throw new TypeError(`Argument must be one of ${values.join(', ')}`); 27 | } 28 | this._view = { 29 | name: view, 30 | handler: Views[view] 31 | }; 32 | this.reload(); 33 | } 34 | 35 | get view() { 36 | return this._view.name; 37 | } 38 | 39 | /** 40 | * Delegate the information to all views 41 | * 42 | * @function 43 | * @param {Array} notes 44 | * @param {Query} query 45 | * @returns {Promise} 46 | */ 47 | show(notes, query) { 48 | this.query = query; 49 | this.notes = notes; 50 | 51 | const amount = notes.length; 52 | const average = notes.reduce((accumulator, current) => accumulator + current.created.getTime(), 0) / amount; 53 | 54 | notes.forEach(note => { 55 | if (this.isNoteVisible(note, query)) { 56 | this._view.handler.add(note, query); 57 | } 58 | }); 59 | this._view.handler.apply(); 60 | 61 | return Promise.resolve({ 62 | amount, 63 | average: new Date(average) 64 | }); 65 | } 66 | 67 | /** 68 | * Check whether a note can be shown 69 | * 70 | * @function 71 | * @param {Note} note Single note which should be checked. 72 | * @param {Query} query The query which was used in order to find the note 73 | * @returns {Boolean} 74 | */ 75 | isNoteVisible(note, query) { 76 | return (query.data.status === STATUS.OPEN ? note.status === STATUS.OPEN : true) && 77 | (query.data.status === STATUS.CLOSED ? note.status === STATUS.CLOSED : true); 78 | } 79 | 80 | /** 81 | * Searches for the note with the specified id and returns it 82 | * 83 | * @function 84 | * @param {Number} id 85 | * @returns {Note} 86 | */ 87 | get(id) { 88 | return this.notes.find(note => note.id === id); 89 | } 90 | 91 | /** 92 | * Updates a single note with new data 93 | * 94 | * @function 95 | * @param {Number} id 96 | * @param {Note} note 97 | * @returns {Promise} 98 | */ 99 | update(id, note) { 100 | const index = this.notes.findIndex(element => element.id === id); 101 | if (index === -1) { 102 | throw new Error(`The note with the id ${id} could not be found in the array`); 103 | } 104 | this.notes[index] = note; 105 | return this.reload(); 106 | } 107 | 108 | /** 109 | * Reload the notes because another event happened like a changed filter 110 | * 111 | * @function 112 | * @returns {Promise} 113 | */ 114 | reload() { 115 | return this.show(this.notes, this.query); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /app/js/modals/mapillary.js: -------------------------------------------------------------------------------- 1 | import * as Localizer from '../localizer.js'; 2 | import Modal from './modal.js'; 3 | import Request, { MEDIA_TYPE } from '../request.js'; 4 | 5 | import * as tilebelt from '@mapbox/tilebelt'; 6 | import { VectorTile } from '@mapbox/vector-tile'; 7 | import Protobuf from 'pbf'; 8 | 9 | import * as Handlebars from 'handlebars'; 10 | import t from '../../templates/dynamic/mapillary.hbs?raw'; 11 | const template = Handlebars.compile(t); 12 | Handlebars.registerHelper('localizer', key => { 13 | return Localizer.message(key); 14 | }); 15 | 16 | export default class Mapillary extends Modal { 17 | /** 18 | * Show all Mapillary images near a given note in a modal 19 | * 20 | * @function 21 | * @param {Note} note 22 | * @returns {void} 23 | */ 24 | static async load(note) { 25 | super.open('mapillary'); 26 | 27 | const content = document.getElementById('mapillary'); 28 | content.classList.add('loading', 'loading-lg'); 29 | 30 | const link = `https://www.mapillary.com/app/?lat=${note.coordinates[0]}&lng=${note.coordinates[1]}&z=17&focus=map`; 31 | document.getElementById('mapillary-link').href = link; 32 | 33 | // The Mapillary image layer is only available at a zoom value of 14 34 | const ZOOM = 14; 35 | const DISTANCE = 50; 36 | const LIMIT = 20; 37 | const tile = tilebelt.pointToTile(note.coordinates[1], note.coordinates[0], ZOOM); 38 | 39 | const data = await Request( 40 | // The documentation of this endpoint can be found here: https://www.mapillary.com/developer/api-documentation/#coverage-tiles 41 | `https://tiles.mapillary.com/maps/vtp/mly1_public/2/${tile[2]}/${tile[0]}/${tile[1]}?access_token=${MAPILLARY_CLIENT_ID}`, 42 | MEDIA_TYPE.PROTOBUF 43 | ); 44 | 45 | let images = []; 46 | const vt = new VectorTile(new Protobuf(data)); 47 | const layer = 'layers' in vt && 'image' in vt.layers ? vt.layers.image : []; 48 | 49 | for (let i = 0; i < layer.length; i++) { 50 | const feature = layer.feature(i); 51 | const geojson = feature.toGeoJSON(tile[0], tile[1], tile[2]); 52 | const coordinates = L.latLng(geojson.geometry.coordinates.reverse()); 53 | 54 | // Filter the available images by their distance to the original note 55 | const distance = L.latLng(note.coordinates).distanceTo(coordinates); 56 | if (distance < DISTANCE) { 57 | images.push({ 58 | id: feature.properties.id, 59 | distance 60 | }); 61 | } 62 | } 63 | 64 | images.sort((a, b) => a.distance - b.distance); 65 | images = images.slice(0, LIMIT); 66 | images = await Promise.all(images.map(async image => { 67 | const url = `https://graph.mapillary.com/${image.id}?access_token=${MAPILLARY_CLIENT_ID}&fields=thumb_1024_url,width,height,creator,captured_at`; 68 | const data = await Request(url); 69 | return Object.assign(image, { 70 | src: data.thumb_1024_url, 71 | width: data.width, 72 | height: data.height, 73 | user: data.creator.username, 74 | capturedAt: new Date(data.captured_at).toLocaleDateString() 75 | }); 76 | })); 77 | 78 | content.innerHTML = template({ 79 | images, 80 | link 81 | }); 82 | 83 | content.classList.remove('loading', 'loading-lg'); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import globals from 'globals'; 2 | import js from '@eslint/js'; 3 | import jsdoc from 'eslint-plugin-jsdoc'; 4 | 5 | import stylistic from '@stylistic/eslint-plugin'; 6 | 7 | export default [ 8 | js.configs.recommended, { 9 | plugins: { 10 | '@stylistic/js': stylistic, 11 | jsdoc: jsdoc 12 | }, 13 | settings: { 14 | jsdoc: { 15 | tagNamePreference: { 16 | returns: 'return' 17 | } 18 | } 19 | }, 20 | languageOptions: { 21 | globals: { 22 | ...globals.browser, 23 | L: 'readonly', 24 | __VERSION__: 'readonly', 25 | NOTESREVIEW_API_URL: 'readonly', 26 | OPENSTREETMAP_SERVER: 'readonly', 27 | OPENSTREETMAP_OAUTH_CLIENT_ID: 'readonly', 28 | OPENSTREETMAP_OAUTH_CLIENT_SECRET: 'readonly', 29 | MAPILLARY_CLIENT_ID: 'readonly', 30 | }, 31 | ecmaVersion: 2023, 32 | sourceType: 'module', 33 | }, 34 | 35 | rules: { 36 | '@stylistic/js/semi': 'warn', 37 | '@stylistic/js/semi-style': 'error', 38 | '@stylistic/js/semi-spacing': 'warn', 39 | '@stylistic/js/quotes': ['warn', 'single', { 40 | avoidEscape: true, 41 | allowTemplateLiterals: 'never', 42 | }], 43 | '@stylistic/js/brace-style': 'error', 44 | // TODO: SwitchCase should already be 0 by default (no indentation), but at least with v5.2.2 of @stylistic/eslint-plugin it complains by default if omitted, 45 | // This should be tested if this is still the case with future versions and maybe remove the option if possible again (as according to documentation, this is already the default) 46 | '@stylistic/js/indent': ['error', 2, { 'SwitchCase': 0 }], 47 | 48 | 'no-eval': 'error', 49 | 'no-implied-eval': 'error', 50 | 51 | 'camelcase': 'error', 52 | 'prefer-const': 'error', 53 | 'no-var': 'warn', 54 | 'prefer-arrow-callback': 'warn', 55 | 'prefer-rest-params': 'error', 56 | 'prefer-spread': 'error', 57 | 'prefer-template': 'warn', 58 | 'template-curly-spacing': 'warn', 59 | 'symbol-description': 'error', 60 | 'object-shorthand': 'warn', 61 | 'prefer-promise-reject-errors': 'error', 62 | 'prefer-destructuring': 'warn', 63 | 'prefer-numeric-literals': 'warn', 64 | 65 | 'no-new-object': 'error', 66 | eqeqeq: ['error', 'smart'], 67 | curly: ['error', 'all'], 68 | 'dot-location': ['error', 'property'], 69 | 'dot-notation': 'error', 70 | 'no-array-constructor': 'error', 71 | 'no-throw-literal': 'error', 72 | 'no-self-compare': 'error', 73 | 'no-useless-call': 'warn', 74 | 'spaced-comment': 'warn', 75 | 'no-multi-spaces': 'warn', 76 | 'no-new-wrappers': 'error', 77 | 'no-script-url': 'error', 78 | 'no-void': 'warn', 79 | 'vars-on-top': 'warn', 80 | yoda: ['error', 'never'], 81 | 'require-await': 'warn', 82 | 83 | 'jsdoc/require-jsdoc': ['error', { 84 | require: { 85 | FunctionDeclaration: true, 86 | MethodDefinition: false, 87 | ClassDeclaration: false, 88 | ArrowFunctionExpression: false, 89 | }, 90 | }], 91 | 'jsdoc/require-returns-type': 'warn', 92 | 'jsdoc/require-description': 'warn', 93 | 'jsdoc/require-param-description': 'off', 94 | 'jsdoc/require-returns-description': 'off', 95 | 96 | 'wrap-iife': ['error', 'inside'], 97 | 'no-unused-expressions': 'error', 98 | 'no-useless-constructor': 'error', 99 | 'no-console': 'error', 100 | 'require-atomic-updates': 'warn', 101 | }, 102 | }]; 103 | -------------------------------------------------------------------------------- /app/js/auth.js: -------------------------------------------------------------------------------- 1 | import Preferences from './preferences.js'; 2 | import Request, { MEDIA_TYPE } from './request.js'; 3 | 4 | import { OAuth2Client, generateCodeVerifier } from '@badgateway/oauth2-client'; 5 | 6 | const client = new OAuth2Client({ 7 | server: OPENSTREETMAP_SERVER, 8 | clientId: OPENSTREETMAP_OAUTH_CLIENT_ID, 9 | tokenEndpoint: '/oauth2/token', 10 | authorizationEndpoint: '/oauth2/authorize', 11 | discoveryEndpoint: '/.well-known/openid-configuration' 12 | }); 13 | 14 | const REDIRECT_URI = `${window.location.origin}${window.location.pathname}landing.html`; 15 | 16 | export default class Auth { 17 | /** 18 | * Start the authorization flow 19 | * 20 | * @function 21 | * @returns {void} 22 | */ 23 | async login() { 24 | // Generate a security code which is needed for PKCE 25 | const codeVerifier = await generateCodeVerifier(); 26 | // Generate a random state parameter 27 | const state = window.crypto.randomUUID(); 28 | // Store both values temporarily in sessionStorage 29 | Preferences.set({ 30 | codeVerifier, 31 | state 32 | }, true); 33 | 34 | // Generate a login URL and redirect the user to it 35 | client.authorizationCode.getAuthorizeUri({ 36 | redirectUri: REDIRECT_URI, 37 | state, 38 | codeVerifier, 39 | scope: ['read_prefs', 'write_notes', 'openid'] 40 | }).then(url => { 41 | window.location.href = url; 42 | }); 43 | } 44 | 45 | /** 46 | * Resume the authorization flow after the redirect 47 | * 48 | * @function 49 | * @param {String} url 50 | * @returns {void} 51 | */ 52 | async resume(url) { 53 | // Retrieve previously stored values that are needed for the verification 54 | const codeVerifier = Preferences.get('codeVerifier', true); 55 | const state = Preferences.get('state', true); 56 | const token = await client.authorizationCode.getTokenFromCodeRedirect(url, { 57 | redirectUri: REDIRECT_URI, 58 | state, 59 | codeVerifier, 60 | }); 61 | 62 | // Use the OIDC id token to initiate the login process for the backend 63 | Request(`${NOTESREVIEW_API_URL}/auth/login`, MEDIA_TYPE.TEXT, { 64 | method: 'GET', 65 | headers: { 66 | 'Authorization': `Bearer ${token.idToken}` 67 | } 68 | }); 69 | 70 | // Store the OAuth and OIDC token for use in following requests 71 | Preferences.set({ 72 | oauth2_access_token: token.accessToken, // eslint-disable-line camelcase 73 | oidc_token: token.idToken // eslint-disable-line camelcase 74 | }); 75 | } 76 | 77 | /** 78 | * Check whether a user is currently logged in 79 | * 80 | * @function 81 | * @returns {Promise} 82 | */ 83 | async isAuthenticated() { 84 | // Check whether there is a (valid) token in the localStorage 85 | const token = Preferences.get('oauth2_access_token'); 86 | if (!token) { 87 | return false; 88 | } 89 | 90 | // And also verify that it works by fetching the /oauth2/userinfo endpoint 91 | try { 92 | await Request(`${OPENSTREETMAP_SERVER}/oauth2/userinfo`, MEDIA_TYPE.JSON, { 93 | method: 'GET', 94 | headers: { 95 | 'Authorization': `Bearer ${token}` 96 | } 97 | }); 98 | return true; 99 | } catch { 100 | return false; 101 | } 102 | } 103 | 104 | /** 105 | * Log out the current user 106 | * 107 | * @function 108 | * @returns {void} 109 | */ 110 | logout() { 111 | // Log out from the backend server before deleting the tokens 112 | Request(`${NOTESREVIEW_API_URL}/auth/logout`, MEDIA_TYPE.TEXT, { 113 | method: 'GET', 114 | headers: { 115 | 'Authorization': `Bearer ${Preferences.get('oidc_token')}` 116 | } 117 | }); 118 | 119 | Preferences.remove('oauth2_access_token'); 120 | Preferences.remove('oidc_token'); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /app/locales/ja.json: -------------------------------------------------------------------------------- 1 | { 2 | "help": { 3 | "colors": { 4 | "title": "それぞれの色の意味", 5 | "description": "地図メモの色は、新しさ・古さによって変わります", 6 | "darkgreen": "ダークグリーン", 7 | "green": "グリーン", 8 | "amber": "アンバー", 9 | "orange": "オレンジ", 10 | "red": "レッド", 11 | "descriptions": { 12 | "darkgreen": "1秒〜1日前", 13 | "green": "31日以内", 14 | "amber": "半年以上1年未満", 15 | "orange": "1〜2年前", 16 | "red": "2年以上前", 17 | "lime": "1〜6ヶ月" 18 | }, 19 | "lime": "ライム" 20 | } 21 | }, 22 | "action": { 23 | "cancel": "キャンセル", 24 | "close": "クローズ", 25 | "copyLinkSuccess": "リンクコピー完了", 26 | "save": "セーブ", 27 | "search": "検索", 28 | "openstreetmap": "OpenStreetMap", 29 | "edit": { 30 | "id": "iD", 31 | "josm": "JOSM", 32 | "level0": "Level0" 33 | }, 34 | "login": "ログイン", 35 | "logout": "ログアウト", 36 | "comment": "コメント", 37 | "commentClose": "コメントしてクローズ", 38 | "commentReopen": "再オープンしてコメント", 39 | "mapillary": "Mapillary", 40 | "report": "地図メモを報告", 41 | "filter": "フィルタ", 42 | "refresh": "再読み込み", 43 | "reset": "リセット" 44 | }, 45 | "header": { 46 | "share": "共有", 47 | "faq": "FAQ", 48 | "about": "概要", 49 | "settings": "設定", 50 | "query": "クエリ", 51 | "limit": "表示上限", 52 | "user": "ユーザ", 53 | "from": "From", 54 | "to": "To", 55 | "sort": "並び替え順", 56 | "status": "ステータス", 57 | "anonymous": "匿名ユーザからの地図メモ" 58 | }, 59 | "description": { 60 | "share": "クエリ共有リンクをコピー:", 61 | "startQuery": "リンクを開いた時点でクエリを実行", 62 | "shareView": "マップ表示地点も共有", 63 | "showList": "リスト表示", 64 | "showMap": "マップ表示", 65 | "nothingFound": "検索結果がありません!", 66 | "autoLimit": "検索結果が多すぎるため、250件までを表示します", 67 | "deprecationWarning": "お使いのブラウザがNotesReviewに対応していません。NoteReviewを利用する場合、他のブラウザを利用してみてください。", 68 | "sort": { 69 | "created": { 70 | "desc": "新しい順", 71 | "asc": "古い順" 72 | }, 73 | "updated": { 74 | "desc": "更新が新しい順", 75 | "asc": "更新が古い順" 76 | } 77 | }, 78 | "query": "地図メモ内の単語やフレーズ", 79 | "status": { 80 | "all": "すべて", 81 | "open": "オープン", 82 | "closed": "クローズド" 83 | }, 84 | "anonymous": { 85 | "include": "含める", 86 | "hide": "非表示" 87 | }, 88 | "statistics": "%s 個の地図メモがありました", 89 | "from": "日付 (UTC) を下限に設定", 90 | "to": "日付 (UTC) を上限に設定" 91 | }, 92 | "settings": { 93 | "choose": "オプション選択", 94 | "theme": { 95 | "title": "テーマ選択", 96 | "light": "ライト", 97 | "dark": "ダーク", 98 | "system": "システム設定を適用" 99 | }, 100 | "tools": { 101 | "title": "ツール", 102 | "description": "よく使うツールを選択", 103 | "editors": "エディタ", 104 | "other": "その他" 105 | } 106 | }, 107 | "note": { 108 | "anonymous": "無記名", 109 | "comment": "1 コメント", 110 | "comments": "%s コメント", 111 | "action": { 112 | "closed": "終了した地図メモ", 113 | "reopened": "再オープンした地図メモ" 114 | } 115 | }, 116 | "map": { 117 | "attribution": "© OpenStreetMap contributors, © CARTO" 118 | }, 119 | "comments": { 120 | "inputPlaceholder": "他ユーザ向けにコメントを残す" 121 | }, 122 | "accessibility": { 123 | "openFaq": "FAQを表示", 124 | "openSettings": "設定を表示", 125 | "shareQuery": "現在のクエリを共有", 126 | "filter": "選択した項目にフィルタを適用" 127 | }, 128 | "mapillary": { 129 | "empty": { 130 | "add": "この場所に写真を追加", 131 | "title": "表示する写真がありません", 132 | "subtitle": "Mapillaryにストリートビューを追加することで他のマッパーへの参考情報にすることができます。" 133 | } 134 | }, 135 | "user": { 136 | "created": "%s 開始ユーザ" 137 | }, 138 | "error": { 139 | "login": "ログインに失敗しました。もう一度試してみてください。", 140 | "comment": "コメント追加に失敗しました。もう一度試してみてください。" 141 | } 142 | } -------------------------------------------------------------------------------- /app/js/badges.js: -------------------------------------------------------------------------------- 1 | import * as Localizer from './localizer.js'; 2 | import Users from './users.js'; 3 | import * as Util from './util.js'; 4 | 5 | import * as CountryCoder from '@rapideditor/country-coder'; 6 | 7 | /** 8 | * Generate a badge for the date a note was created on (the age of it) 9 | * 10 | * @function 11 | * @param {String} color 12 | * @param {Date} date A ISO 8601 date string (e.g. 2010-01-31) 13 | * @param {Boolean} navigation Whether the badge should be shown in the navigation bar 14 | * @returns {String} 15 | */ 16 | export function age(color, date, navigation) { 17 | const location = navigation ? 'top' : 'bottom'; 18 | return ` 19 | 20 | 21 | ${date.toLocaleDateString()} 22 | 23 | `; 24 | } 25 | 26 | /** 27 | * Generate a badge for the amount of comments 28 | * 29 | * @function 30 | * @param {Number} amount 31 | * @returns {String} 32 | */ 33 | export function comments(amount) { 34 | if (amount > 0) { 35 | const text = amount === 1 ? Localizer.message('note.comment') : Localizer.message('note.comments', amount); 36 | return `${text}`; 37 | } 38 | } 39 | 40 | /** 41 | * Generate a badge for the country the note is located in 42 | * 43 | * @function 44 | * @param {Number} coordinates in [ latitude, longitude ] format 45 | * @returns {String} 46 | */ 47 | export function country(coordinates) { 48 | const feature = CountryCoder.feature([...coordinates].reverse(), { 49 | level: 'territory', 50 | withProp: 'emojiFlag' 51 | }); 52 | // Return no badge if the note is not inside a known country or there is no emoji associated with it 53 | if (!feature || !('properties' in feature) || !('emojiFlag' in feature.properties)) { 54 | return; 55 | } 56 | 57 | const emoji = []; 58 | for (const codePoint of feature.properties.emojiFlag) { 59 | emoji.push(codePoint.codePointAt(0).toString(16)); 60 | } 61 | const url = `https://cdn.jsdelivr.net/gh/twitter/twemoji@latest/assets/svg/${emoji.join('-')}.svg`; 62 | return ``; 63 | } 64 | 65 | /** 66 | * Generate a badge for a user who commented or created the note 67 | * 68 | * @function 69 | * @param {Number} uid 70 | * @param {Boolean} anonymous 71 | * @returns {String} 72 | */ 73 | export function user(uid, anonymous) { 74 | if (anonymous) { 75 | return `${Localizer.message('note.anonymous')}`; 76 | } else { 77 | const user = Users.get(uid); 78 | if (!user) { 79 | return `${Localizer.message('note.unknown')}`; 80 | } 81 | 82 | const image = user.image ? `` : ''; 83 | const initials = Util.initials(user.name); 84 | return `
${image}
85 | 86 | 87 | ${user.name} 88 | 89 | `; 90 | } 91 | } 92 | 93 | /** 94 | * Generate a badge for the status which was set with a note (e.g. opened/reopened) 95 | * 96 | * @function 97 | * @param {String} action 98 | * @returns {String} 99 | */ 100 | export function status(action) { 101 | if (action === 'opened') { 102 | return `${Localizer.message('note.action.opened')}`; 103 | } else if (action === 'closed') { 104 | return `${Localizer.message('note.action.closed')}`; 105 | } else if (action === 'reopened') { 106 | return `${Localizer.message('note.action.reopened')}`; 107 | } 108 | } 109 | 110 | /** 111 | * Generate an icon to report a note 112 | * 113 | * @function 114 | * @param {Number} id 115 | * @returns {String} 116 | */ 117 | export function report(id) { 118 | return ` 119 | 120 | 121 | 122 | `; 123 | } 124 | -------------------------------------------------------------------------------- /app/templates/modals/help.hbs: -------------------------------------------------------------------------------- 1 | 99 | 100 | 103 | -------------------------------------------------------------------------------- /app/svg/assets/notesreview-small.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 15 | 16 | 18 | image/svg+xml 19 | 21 | 22 | 23 | 24 | 25 | 27 | 34 | 40 | 47 | 48 | 50 | 57 | 63 | 70 | 71 | 73 | 80 | 86 | 93 | 94 | 95 | -------------------------------------------------------------------------------- /app/js/ui/map.js: -------------------------------------------------------------------------------- 1 | import Leaflet from '../leaflet.js'; 2 | import { STATUS } from '../query.js'; 3 | import * as Util from '../util.js'; 4 | 5 | import * as Handlebars from 'handlebars'; 6 | import t from '../../templates/dynamic/comment.hbs?raw'; 7 | const template = Handlebars.compile(t); 8 | 9 | export default class Map { 10 | constructor() { 11 | this.map = new Leaflet('map-container'); 12 | this.container = document.getElementById('note-container'); 13 | this.center = document.getElementById('center-map-to-results'); 14 | 15 | this.active = null; 16 | 17 | this.map.addGeocoding(); 18 | 19 | this.cluster = L.markerClusterGroup({ 20 | maxClusterRadius: 40, 21 | showCoverageOnHover: false 22 | }); 23 | this.map.addLayer(this.cluster); 24 | this.markers = []; 25 | 26 | this.halo = L.circleMarker([0, 0]); 27 | this.map.addLayer(this.halo); 28 | 29 | this.features = L.geoJSON(); 30 | this.map.addLayer(this.features); 31 | 32 | this.map.onClick(() => this.clear()); 33 | 34 | this.center.addEventListener('click', () => { 35 | const bounds = this.cluster.getBounds(); 36 | if (bounds.isValid()) { 37 | this.map.flyToBounds(bounds); 38 | } 39 | }); 40 | 41 | this.map.onMove(() => { 42 | const mapBounds = this.map.bounds(); 43 | const clusterBounds = this.cluster.getBounds(); 44 | if (!mapBounds.isValid() || !clusterBounds.isValid()) { 45 | return; 46 | } 47 | 48 | // Only show the button to center the map in case the screen covers less than 10% of the bounding box of all markers 49 | if (Util.overlap(mapBounds, clusterBounds) < 0.1) { 50 | this.center.classList.remove('d-hide'); 51 | } else { 52 | this.center.classList.add('d-hide'); 53 | } 54 | }); 55 | } 56 | 57 | /** 58 | * Add a note to the marker layer 59 | * 60 | * @function 61 | * @param {Note} note 62 | * @param {Query} query 63 | * @returns {Promise} 64 | */ 65 | add(note, query) { 66 | let { color } = note; 67 | if (query.data.status === STATUS.ALL) { 68 | color = note.status === STATUS.OPEN ? 'green' : 'red'; 69 | } 70 | 71 | const marker = L.marker(note.coordinates, { 72 | icon: new L.divIcon({ 73 | html: ``, 74 | iconSize: [25, 40],// [width, height] 75 | iconAnchor: [25 / 2, 40], // [width / 2, height] 76 | popupAnchor: [0, -30], 77 | className: 'marker-icon' 78 | }) 79 | }); 80 | 81 | marker.on('click', () => { 82 | if (this.active === note) { 83 | return; 84 | } 85 | 86 | this.clear(); 87 | this.active = note; 88 | 89 | this.container.innerHTML = template(note, { 90 | allowedProtoProperties: { 91 | actions: true, 92 | badges: true 93 | } 94 | }); 95 | 96 | setTimeout(() => { 97 | this.container.classList.remove('out-of-view'); 98 | }, 100); 99 | 100 | // Show halo with the correct style at the position of the note 101 | this.halo = L.circleMarker(note.coordinates, { 102 | color: window.getComputedStyle(document.documentElement).getPropertyValue(`--${color}-primary`), 103 | weight: 1 104 | }); 105 | this.map.addLayer(this.halo); 106 | 107 | // If an element is linked in the note text, show the geometry of it on the map 108 | const { linked } = note; 109 | if (linked) { 110 | const overpass = ` 111 | [out:json]; 112 | ${linked.type}(id:${linked.id}); 113 | convert Feature ::=::,::id=id(),::geom=geom(); 114 | out geom tags;`; 115 | 116 | fetch('https://overpass-api.de/api/interpreter', { 117 | method: 'POST', 118 | headers: { 119 | 'Content-Type': 'application/x-www-form-urlencoded' 120 | }, 121 | body: new URLSearchParams({ 122 | 'data': overpass 123 | }) 124 | }).then(response => { 125 | if (response.ok) { 126 | return response.json(); 127 | } else { 128 | throw new Error(`Error while fetching Overpass API: ${response.status} ${response.statusText}`); 129 | } 130 | }).then(json => { 131 | if (!('elements' in json) || json.elements.length === 0) { 132 | return; 133 | } 134 | 135 | const [ element ] = json.elements; 136 | // TODO: Points are ignored because they use a similar marker to the other markers by default which might lead to confusion 137 | if (element.geometry.type !== 'Point') { 138 | this.features.addData(element); 139 | } 140 | }); 141 | } 142 | }); 143 | 144 | this.markers.push(marker); 145 | } 146 | 147 | /** 148 | * Remove temporary layers from the map and reset values 149 | * 150 | * @function 151 | * @returns {void} 152 | */ 153 | clear() { 154 | this.active = null; 155 | this.halo.remove(); 156 | this.features.clearLayers(); 157 | this.container.classList.add('out-of-view'); 158 | } 159 | 160 | /** 161 | * Display all notes on the map and zoom the map to show them all 162 | * 163 | * @function 164 | * @returns {void} 165 | */ 166 | apply() { 167 | this.map.resize(); 168 | 169 | this.clear(); 170 | this.cluster.clearLayers(); 171 | this.cluster.addLayers(this.markers); 172 | 173 | this.markers = []; 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /app/js/modals/area-selector.js: -------------------------------------------------------------------------------- 1 | import Leaflet from '../leaflet.js'; 2 | import Modal from './modal.js'; 3 | import * as Util from '../util.js'; 4 | 5 | import * as CountryCoder from '@rapideditor/country-coder'; 6 | import { LocationConflation } from '@rapideditor/location-conflation'; 7 | 8 | const locationConflation = new LocationConflation(); 9 | locationConflation.strict = false; 10 | 11 | export default class AreaSelector extends Modal { 12 | /** 13 | * Initializes the area selector 14 | * 15 | * @constructor 16 | * @param {HTMLElement} countryInput 17 | * @param {HTMLElement} polygonInput 18 | * @returns {void} 19 | */ 20 | constructor(countryInput, polygonInput) { 21 | super(); 22 | 23 | this.countryInput = countryInput; 24 | this.countries = countryInput.value === '' ? [] : countryInput.value.split(','); 25 | 26 | this.polygonInput = polygonInput; 27 | 28 | this.map = new Leaflet('modal-area-selector-content', null, [-180, -90, 180, 90]); 29 | 30 | this.features = new L.FeatureGroup(); 31 | this.map.addLayer(this.features); 32 | 33 | this.drawnFeatures = new L.FeatureGroup(); 34 | this.features.addLayer(this.drawnFeatures); 35 | this.selectedFeatures = new L.GeoJSON(); 36 | this.features.addLayer(this.selectedFeatures); 37 | 38 | this.controls = this.map.addDraw(this.drawnFeatures, () => { 39 | this.currentlyDrawing = true; 40 | }, () => { 41 | this.currentlyDrawing = false; 42 | this.polygon(); 43 | }); 44 | 45 | // If the list of countries is empty, parse the polygon input and notify the draw control about a new layer 46 | if (this.countries.length === 0 && this.polygonInput.value !== '') { 47 | // See https://github.com/Leaflet/Leaflet.draw/issues/276 48 | const geojsonLayer = new L.GeoJSON(JSON.parse(this.polygonInput.value)); 49 | geojsonLayer.eachLayer(layer => { 50 | this.map.fire(L.Draw.Event.CREATED, { 51 | layer 52 | }); 53 | }); 54 | } 55 | 56 | // When clicking on the map, find the corresponding country and add it to the list 57 | this.map.onClick(event => { 58 | // Do not proceed if there is already a drawn shape or the event was fired while drawing 59 | if (this.drawnFeatures.getLayers().length > 0 || this.currentlyDrawing) { 60 | return; 61 | } 62 | 63 | const coordinates = event.latlng; 64 | const qid = CountryCoder.wikidataQID([coordinates.lng, coordinates.lat], { 65 | level: 'territory' 66 | }); 67 | if (qid !== null) { 68 | this.countries = Util.toggle(this.countries, qid); 69 | this.update(this.countries); 70 | } 71 | }); 72 | 73 | // Resize the map and zoom to the current selection if the modal is opened 74 | document.querySelector('.modal[data-modal="area-selector"]').addEventListener('modal-open', () => { 75 | this.map.resize(); 76 | const bounds = this.features.getBounds(); 77 | if (bounds.isValid()) { 78 | this.map.flyToBounds(bounds, null, [4, 4]); 79 | } 80 | }); 81 | 82 | this.update(this.countries); 83 | } 84 | 85 | /** 86 | * Update the map with the current selection of countries 87 | * 88 | * @function 89 | * @param {Array} countries 90 | * @returns {void} 91 | */ 92 | update(countries) { 93 | this.countryInput.value = countries.join(','); 94 | this.countryInput.dispatchEvent(new Event('change')); 95 | 96 | this.selectedFeatures.clearLayers(); 97 | 98 | // Only resolve the location set if there are any entries, 99 | // otherwise the earth will be included by default 100 | if (countries.length > 0) { 101 | // Remove the drawing toolbar 102 | this.map.removeControl(this.controls.draw); 103 | 104 | const result = locationConflation.resolveLocationSet({ 105 | include: countries 106 | }); 107 | this.selectedFeatures.addData(Util.simplify( 108 | // It is necessary to create a deep copy of the resulting JSON because otherwise 109 | // the simplification (in place) would affect the cached version of the library 110 | JSON.parse(JSON.stringify(result.feature)) 111 | )); 112 | } else if (this.drawnFeatures.getLayers().length === 0) { 113 | this.map.addControl(this.controls.draw); 114 | } 115 | 116 | this.polygon(); 117 | } 118 | 119 | /** 120 | * Update the (hidden) polygon input which is used for the request 121 | * 122 | * @function 123 | * @returns {void} 124 | */ 125 | polygon() { 126 | const geojson = this.features.toGeoJSON(); 127 | if (typeof geojson === 'object' && 'features' in geojson && 128 | Array.isArray(geojson.features) && geojson.features.length === 1) { 129 | // TODO: Maybe also check whether drawn features overlap (API will not return any results in that case) 130 | // For more resources (especially regarding the Shamos-Hoey Algorithm), see e.g. 131 | // https://web.archive.org/web/20210506140353/http://geomalgorithms.com/a09-_intersect-3.html#Shamos-Hoey-Algorithm 132 | // https://github.com/rowanwins/shamos-hoey and https://github.com/rowanwins/shamos-hoey/blob/master/ShamosHoey.pdf 133 | // https://github.com/mclaeysb/geojson-polygon-self-intersections 134 | // https://web.archive.org/web/20110604114853/https://compgeom.cs.uiuc.edu/~jeffe/teaching/373/notes/x06-sweepline.pdf 135 | this.polygonInput.value = JSON.stringify(geojson.features[0].geometry); 136 | } else { 137 | this.polygonInput.value = ''; 138 | } 139 | this.polygonInput.dispatchEvent(new Event('change')); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /app/locales/fr.json: -------------------------------------------------------------------------------- 1 | { 2 | "help": { 3 | "colors": { 4 | "title": "Que signifient les couleurs ?", 5 | "description": "Elles représentent l'âge d'une note", 6 | "darkgreen": "Vert foncé", 7 | "green": "Vert", 8 | "amber": "Ambre", 9 | "orange": "Orange", 10 | "red": "Rouge", 11 | "descriptions": { 12 | "darkgreen": "De 1 seconde à 1 jour", 13 | "green": "De 1 à 31 jours", 14 | "amber": "De 6 mois à 1 an", 15 | "orange": "De 1 à 2 ans", 16 | "red": "Plus de 2 ans", 17 | "lime": "De 1 à 6 mois" 18 | }, 19 | "lime": "Lime" 20 | } 21 | }, 22 | "action": { 23 | "cancel": "Annuler", 24 | "close": "Fermer", 25 | "copyLinkSuccess": "Lien copié avec succès", 26 | "save": "Enregistrer", 27 | "search": "Chercher", 28 | "more": "Plus", 29 | "openstreetmap": "OpenStreetMap", 30 | "edit": { 31 | "id": "iD", 32 | "josm": "JOSM", 33 | "level0": "Level0" 34 | }, 35 | "login": "Connexion", 36 | "logout": "Déconnexion", 37 | "comment": "Commenter", 38 | "commentClose": "Commenter et fermer", 39 | "commentReopen": "Réouvrir et commenter", 40 | "mapillary": "Mapillary", 41 | "report": "Signaler une note", 42 | "filter": "Filtrer", 43 | "refresh": "Rafraîchir" 44 | }, 45 | "header": { 46 | "share": "Partager", 47 | "faq": "FAQ", 48 | "about": "À propos", 49 | "settings": "Paramètres", 50 | "query": "Recherche", 51 | "limit": "Limite", 52 | "user": "Utilisateur", 53 | "from": "Du", 54 | "to": "Au", 55 | "sort": "Tri", 56 | "status": "L’état", 57 | "anonymous": "Remarques anonymes" 58 | }, 59 | "description": { 60 | "share": "Copiez ce lien pour partager la recherche courante :", 61 | "startQuery": "Démarrer la recherche automatiquement à l'ouverture du lien", 62 | "shareView": "Partagez aussi l'affichage de la carte", 63 | "showList": "Afficher la liste", 64 | "showMap": "Afficher la carte", 65 | "nothingFound": "Aucun résultat !", 66 | "autoLimit": "Limitation automatique à 250 remarques, les valeurs plus grandes ne sont pas autorisées", 67 | "deprecationWarning": "Votre navigateur ne supporte pas NotesReview. Essayez d'en utiliser un autre pour utiliser NotesReview.", 68 | "sort": { 69 | "created": { 70 | "desc": "Récentes", 71 | "asc": "Anciennes" 72 | }, 73 | "updated": { 74 | "desc": "Récentes mises à jour", 75 | "asc": "Anciennes mise à jour" 76 | } 77 | }, 78 | "query": "Mot ou phrase contenu dans la note", 79 | "status": { 80 | "all": "Tous", 81 | "open": "Ouvert", 82 | "closed": "Fermé" 83 | }, 84 | "anonymous": { 85 | "include": "Inclure", 86 | "hide": "Cacher", 87 | "only": "Seulement" 88 | }, 89 | "statistics": "Trouvé %s remarques" 90 | }, 91 | "settings": { 92 | "choose": "Choisissez une option", 93 | "theme": { 94 | "title": "Selectionnez un thème", 95 | "light": "Clair", 96 | "dark": "Foncé", 97 | "system": "Utilisez les réglages système" 98 | }, 99 | "tools": { 100 | "title": "Outils", 101 | "description": "Choisissez vos outils favoris", 102 | "editors": "Éditeurs", 103 | "other": "Autre" 104 | } 105 | }, 106 | "note": { 107 | "anonymous": "Anonyme", 108 | "comment": "1 commentaire", 109 | "comments": "%s commentaires", 110 | "action": { 111 | "opened": "Note crée", 112 | "closed": "Note fermée", 113 | "reopened": "Note réouverte" 114 | }, 115 | "unknown": "Utilisateur inconnu" 116 | }, 117 | "map": { 118 | "attribution": "© contributeurs OpenStreetMap, © CARTO" 119 | }, 120 | "comments": { 121 | "inputPlaceholder": "Écrivez un commentaire pour les autres contributeurs" 122 | }, 123 | "accessibility": { 124 | "openFaq": "Ouvrez la foire aux questions", 125 | "openSettings": "Ouvrez les réglages", 126 | "shareQuery": "Partagez la recherche courante", 127 | "filter": "Appliquer un filtre sur la sélection" 128 | }, 129 | "mapillary": { 130 | "empty": { 131 | "add": "Ajoutez des photos de ce lieu", 132 | "title": "Il n'y a pas encore de photos de ce lieu", 133 | "subtitle": "Vous pouvez aider les autres contributeurs en ajoutant des photos dans Mapillary." 134 | } 135 | }, 136 | "user": { 137 | "created": "Contributeur depuis %s" 138 | }, 139 | "about": { 140 | "description": "!HTML! NotesReview est un projet open source. Vous utilisez la version {{version}}. Le code source est disponible sur Github.", 141 | "contribution": "!HTML! Vous pouvez aider ce projet en le traduisant ou en décrivant un problème.", 142 | "author": "!HTML! Fait avec ❤️ par ENT8R", 143 | "donate": "Faire un don" 144 | }, 145 | "error": { 146 | "login": "La connexion a échoué, réessayez plus tard s'il vous plaît", 147 | "comment": "Le commentaire a échoué, réessayez plus tard s'il vous plaît" 148 | } 149 | } -------------------------------------------------------------------------------- /app/locales/zh-TW.json: -------------------------------------------------------------------------------- 1 | { 2 | "help": { 3 | "colors": { 4 | "title": "顏色代表什麼了意涵呢?", 5 | "description": "顏色代表註解的存在時間", 6 | "darkgreen": "暗綠色", 7 | "green": "綠色", 8 | "amber": "琥珀色", 9 | "orange": "橘色", 10 | "red": "紅色", 11 | "descriptions": { 12 | "darkgreen": "在一秒與一天之間", 13 | "green": "在一天與 31 天之間", 14 | "amber": "在六個月與一年之間", 15 | "orange": "在一年與兩年之間", 16 | "red": "兩年或以上", 17 | "lime": "在一個月與六個月之間" 18 | }, 19 | "lime": "萊姆綠" 20 | }, 21 | "multipleUsers": { 22 | "title": "有辦法搜尋多個使用者嗎?", 23 | "description": "!HTML! 是,採用逗號來分隔使用者。
\n當搜尋作者的註解時,會嘗試從給定的使用者名稱來搜尋註解。
\n當搜尋使用者的註解時,會嘗試從給定的使用者名稱來搜尋曾回應的註解。" 24 | }, 25 | "excludingUsers": { 26 | "title": "有辦法排除特定使用者嗎?", 27 | "description": "!HTML! 是,不論是在使用者前撰寫 NOT 或是 - 都能排除使用者。
\n而且也有可能用逗號分隔使用者來排除多個使用者。" 28 | } 29 | }, 30 | "action": { 31 | "cancel": "取消", 32 | "close": "關閉", 33 | "copyLinkSuccess": "成功複製連結", 34 | "save": "儲存", 35 | "search": "搜尋", 36 | "more": "更多", 37 | "openstreetmap": "開放街圖", 38 | "edit": { 39 | "id": "iD", 40 | "josm": "JOSM", 41 | "level0": "Level0", 42 | "rapid": "Rapid" 43 | }, 44 | "login": "登入", 45 | "logout": "登出", 46 | "comment": "評論", 47 | "commentClose": "評論並關閉", 48 | "commentReopen": "重新開啟並評論", 49 | "mapillary": "Mapillary", 50 | "report": "回報註解", 51 | "filter": "過瀘器", 52 | "refresh": "重新整理", 53 | "reset": "重設", 54 | "center": "置中地圖到結果", 55 | "deepl": "Deepl", 56 | "download": "下載資料 (.json)" 57 | }, 58 | "header": { 59 | "share": "分享", 60 | "faq": "常見問題", 61 | "about": "關於", 62 | "settings": "設定", 63 | "query": "檢索", 64 | "limit": "限制", 65 | "user": "使用者", 66 | "from": "從", 67 | "to": "到", 68 | "sort": "排序", 69 | "status": "狀態", 70 | "anonymous": "暱名的註解", 71 | "area": "搜尋區域", 72 | "commented": "有留言的註解", 73 | "author": "作者" 74 | }, 75 | "description": { 76 | "share": "複製這連結來分享目前檢索:", 77 | "startQuery": "開啟連結之後直接開始檢索", 78 | "shareView": "分享目前的地圖檢視", 79 | "showList": "以列表顯示", 80 | "showMap": "顯示在地圖上面", 81 | "nothingFound": "什麼都找不到!", 82 | "autoLimit": "自動設定限制為 250,因為不能允許更高的數值", 83 | "deprecationWarning": "你目前使用的瀏覽器並不支援 NotesReview,請考慮換到其他瀏覽器才能使用 NotesReview。", 84 | "sort": { 85 | "created": { 86 | "desc": "最新", 87 | "asc": "最舊" 88 | }, 89 | "updated": { 90 | "desc": "最近更新", 91 | "asc": "最舊更新" 92 | } 93 | }, 94 | "query": "在註解中提及的文字或句子", 95 | "status": { 96 | "all": "所有", 97 | "open": "開啟", 98 | "closed": "已關閉" 99 | }, 100 | "anonymous": { 101 | "include": "包含", 102 | "hide": "隱藏", 103 | "only": "只有" 104 | }, 105 | "statistics": "找到 %s 的註解", 106 | "from": "日期 (UTC) 為最低值", 107 | "to": "日期 (UTC) 為最高值", 108 | "area": { 109 | "global": "全球", 110 | "view": "目前檢視", 111 | "custom": "客製區域" 112 | }, 113 | "commented": { 114 | "include": "包括", 115 | "hide": "隱藏", 116 | "only": "只有" 117 | }, 118 | "author": "作者是新增加註解的使用者", 119 | "user": "使用者是任何留言在註解的使用者\n(包括作者)", 120 | "customArea": "點按國家,或是用左側工具欄繪製客製化形狀來選取國家", 121 | "sharePolygon": "分享客製化區域的選擇" 122 | }, 123 | "settings": { 124 | "choose": "選擇選項", 125 | "theme": { 126 | "title": "選擇主題", 127 | "light": "亮色系", 128 | "dark": "暗色系", 129 | "system": "使用系統設定" 130 | }, 131 | "tools": { 132 | "title": "工具", 133 | "description": "選擇你最愛的工具", 134 | "editors": "編輯器", 135 | "other": "其他" 136 | } 137 | }, 138 | "note": { 139 | "anonymous": "暱名", 140 | "comment": "1 回覆", 141 | "comments": "%s 回覆", 142 | "action": { 143 | "opened": "創建註解", 144 | "closed": "關閉註解", 145 | "reopened": "重新開啟註解" 146 | }, 147 | "unknown": "不知名的使用者" 148 | }, 149 | "map": { 150 | "attribution": "© 開放街圖貢獻者, © CARTO" 151 | }, 152 | "comments": { 153 | "inputPlaceholder": "對所有其他使用者撰寫評論" 154 | }, 155 | "accessibility": { 156 | "openFaq": "開啟常見問題", 157 | "openSettings": "開啟設定", 158 | "shareQuery": "分享目前檢索", 159 | "filter": "替選取套用過濾器", 160 | "closeNotification": "關閉通知" 161 | }, 162 | "mapillary": { 163 | "empty": { 164 | "add": "新增這個地方的照片", 165 | "title": "這個地方還沒有照片", 166 | "subtitle": "你可以藉由上傳街景照片到 Mapillary 來協助其他的圖客。" 167 | } 168 | }, 169 | "user": { 170 | "created": "自 %s 加入的使用者" 171 | }, 172 | "about": { 173 | "description": "!HTML! NotesReview 是開放源碼的專案,你目前使用 {{version}} 版本,原始碼在 Github 上面。", 174 | "contribution": "!HTML! 你可以透過 翻譯或是回報臭蟲來支援此計劃。", 175 | "author": "!HTML! 由ENT8R 用 ❤️ 製作", 176 | "donate": "捐款" 177 | }, 178 | "error": { 179 | "login": "無法登入,請再試一次", 180 | "comment": "無法評論,請再試一次", 181 | "queryTimeout": "時間已到,檢索方式太複雜了", 182 | "queryAbort": "取消檢索,顯示先前的結果" 183 | } 184 | } -------------------------------------------------------------------------------- /app/locales/nl.json: -------------------------------------------------------------------------------- 1 | { 2 | "help": { 3 | "colors": { 4 | "title": "Wat is de betekenis van de kleuren?", 5 | "description": "Elke kleur staat voor de leeftijd van de opmerking", 6 | "darkgreen": "Donkergroen", 7 | "green": "Groen", 8 | "amber": "Amber", 9 | "orange": "Oranje", 10 | "red": "Rood", 11 | "descriptions": { 12 | "darkgreen": "Tussen een seconde en een dag", 13 | "green": "Tussen een en 31 dagen", 14 | "amber": "Tussen zes maanden en een jaar", 15 | "orange": "Tussen een en twee jaar", 16 | "red": "Twee of meer jaar", 17 | "lime": "Tussen een en zes maanden" 18 | }, 19 | "lime": "Limoengroen" 20 | } 21 | }, 22 | "action": { 23 | "cancel": "Annuleren", 24 | "close": "Sluiten", 25 | "copyLinkSuccess": "Link succesvol gekopieerd", 26 | "save": "Opslaan", 27 | "search": "Zoeken", 28 | "more": "Meer", 29 | "openstreetmap": "OpenStreetMap", 30 | "edit": { 31 | "id": "iD", 32 | "josm": "JOSM", 33 | "level0": "Level0" 34 | }, 35 | "login": "Inloggen", 36 | "logout": "Uitloggen", 37 | "comment": "Reageren", 38 | "commentClose": "Reageren en sluiten", 39 | "commentReopen": "Opnieuw openen en reageren", 40 | "mapillary": "Mapillary", 41 | "report": "Opmerking rapporteren", 42 | "filter": "Filteren", 43 | "refresh": "Verversen", 44 | "reset": "Herstel standaardwaarden", 45 | "center": "Kaart op resultaten centreren" 46 | }, 47 | "header": { 48 | "share": "Delen", 49 | "faq": "FAQ", 50 | "about": "Over", 51 | "settings": "Instellingen", 52 | "query": "Zoekvraag", 53 | "limit": "Limiet", 54 | "user": "Gebruiker", 55 | "from": "Van", 56 | "to": "Tot", 57 | "sort": "Sorteren op", 58 | "status": "Status", 59 | "anonymous": "Anonieme opmerkingen" 60 | }, 61 | "description": { 62 | "share": "Kopieer deze link om de huidige zoekvraag te delen:", 63 | "startQuery": "Start de zoekvraag direct na het openen van de link", 64 | "shareView": "Deel de huidige weergave van de kaart", 65 | "showList": "In een lijst weergeven", 66 | "showMap": "Op een kaart weergeven", 67 | "nothingFound": "Niets gevonden!", 68 | "autoLimit": "Limiet automatisch op 250 gezet, omdat hogere waarden niet toegestaan zijn", 69 | "deprecationWarning": "De browser die u momenteel gebruikt ondersteund NotesReview niet. Overweeg om naar een andere browser over te schakelen om NotesReview te kunnen zien.", 70 | "sort": { 71 | "created": { 72 | "desc": "Nieuwste", 73 | "asc": "Oudste" 74 | }, 75 | "updated": { 76 | "desc": "Recent bijgewerkt", 77 | "asc": "Minst recent bijgewerkt" 78 | } 79 | }, 80 | "query": "Een woord of zin dat vermeld staat in de opmerking", 81 | "status": { 82 | "all": "Alle", 83 | "open": "Open", 84 | "closed": "Gesloten" 85 | }, 86 | "anonymous": { 87 | "include": "Opnemen", 88 | "hide": "Verbergen", 89 | "only": "Alleen" 90 | }, 91 | "statistics": "%s opmerkingen gevonden", 92 | "from": "Een datum (UTC) als ondergrens", 93 | "to": "Een datum (UTC) als bovengrens" 94 | }, 95 | "settings": { 96 | "choose": "Kies een optie", 97 | "theme": { 98 | "title": "Thema selecteren", 99 | "light": "Licht", 100 | "dark": "Donker", 101 | "system": "Gebruik systeem-instelling" 102 | }, 103 | "tools": { 104 | "title": "Gereedschappen", 105 | "description": "Uw favoriete gereedschappen selecteren", 106 | "editors": "Bewerkingsprogramma's", 107 | "other": "Andere" 108 | } 109 | }, 110 | "note": { 111 | "anonymous": "anoniem", 112 | "comment": "1 reactie", 113 | "comments": "%s reacties", 114 | "action": { 115 | "opened": "Opmerking aangemaakt", 116 | "closed": "Opmerking gesloten", 117 | "reopened": "Opmerking opnieuw geopend" 118 | }, 119 | "unknown": "Onbekende gebruiker" 120 | }, 121 | "map": { 122 | "attribution": "© OpenStreetMap-bijdragers, © CARTO" 123 | }, 124 | "comments": { 125 | "inputPlaceholder": "Schrijf een reactie voor alle andere gebruikers" 126 | }, 127 | "accessibility": { 128 | "openFaq": "Veel gestelde vragen openen", 129 | "openSettings": "Instellingen openen", 130 | "shareQuery": "De huidige zoekvraag delen", 131 | "filter": "Een filter op de selectie toepassen" 132 | }, 133 | "mapillary": { 134 | "empty": { 135 | "add": "Foto's van deze plaats toevoegen", 136 | "title": "Er zijn nog geen foto's van deze plaats", 137 | "subtitle": "U kunt andere mappers helpen door beelden op straatniveau te uploaden naar Mapillary." 138 | } 139 | }, 140 | "user": { 141 | "created": "Gebruiker sinds %s" 142 | }, 143 | "about": { 144 | "description": "!HTML! NotesReview is een open source project. U gebruikt momenteel versie {{version}}. De broncode is beschikbaar op Github.", 145 | "contribution": "!HTML! U kunt het project ondersteunen door het te vertalen of door fouten te melden.", 146 | "author": "!HTML! Met ❤️ gemaakt door ENT8R", 147 | "donate": "Doneren" 148 | }, 149 | "error": { 150 | "login": "Inloggen mislukt, probeer het later opnieuw", 151 | "comment": "Reageren mislukt, probeer het later opnieuw" 152 | } 153 | } -------------------------------------------------------------------------------- /app/js/note.js: -------------------------------------------------------------------------------- 1 | import * as Badges from './badges.js'; 2 | import Comment from './comment.js'; 3 | import * as Localizer from './localizer.js'; 4 | import * as Util from './util.js'; 5 | 6 | const OPENSTREETMAP_ELEMENT_REGEX = [ 7 | /(?:https?:\/\/)?(?:www\.)?(?:osm|openstreetmap)\.org\/(node|way|relation)\/([0-9]+)/, 8 | /(node|way|relation)\/([0-9]+)/, 9 | /(node|way|relation)(?:\s)?#([0-9]+)/, 10 | /(n|w|r)\/([0-9]+)/ 11 | ]; 12 | 13 | export default class Note { 14 | /** 15 | * Constructor for an OpenStreetMap note 16 | * 17 | * @constructor 18 | * @param {Object} feature 19 | */ 20 | constructor(feature) { 21 | /** Exclude invalid notes 22 | * See {@link https://github.com/openstreetmap/openstreetmap-website/issues/2146} */ 23 | if (feature.comments.length === 0) { 24 | throw new Error(`Note ${feature._id} can not be parsed because there are no comments.`); 25 | } 26 | 27 | this.id = feature._id; 28 | this.status = feature.status; 29 | this.coordinates = feature.coordinates.reverse(); 30 | 31 | this.comments = feature.comments.map(comment => new Comment(comment)); 32 | 33 | this.users = this.comments.filter(comment => comment.uid !== null).map(comment => comment.uid); 34 | this.anonymous = this.comments[0].anonymous; 35 | this.user = this.comments[0].user; 36 | 37 | this.created = this.comments[0].date; 38 | this.color = Util.parseDate(this.created); 39 | } 40 | 41 | /** 42 | * Parse a new note from the main OpenStreetMap API 43 | * 44 | * @function 45 | * @param {Object} note 46 | * @returns {Note} 47 | */ 48 | static parse(note) { 49 | return new Note({ 50 | _id: note.properties.id, 51 | coordinates: note.geometry.coordinates, 52 | status: note.properties.status, 53 | comments: note.properties.comments.map(comment => { 54 | /** The dashes in the date string need to be replaced with slashes 55 | * See {@link https://stackoverflow.com/a/3257513} for the reason */ 56 | comment.date = comment.date.replace(/-/g, '/'); 57 | return comment; 58 | }) 59 | }); 60 | } 61 | 62 | /** 63 | * Return possible note actions which are then added to the note template 64 | * Possible actions are e.g. to open an editor or open the note on openstreetmap.org 65 | * 66 | * @function 67 | * @returns {Array} 68 | */ 69 | get actions() { 70 | const bbox = Util.buffer(this.coordinates); 71 | 72 | const actions = { 73 | osm: { 74 | class: 'link-osm', 75 | link: `${OPENSTREETMAP_SERVER}/note/${this.id}`, 76 | icon: 'external', 77 | text: Localizer.message('action.openstreetmap') 78 | }, 79 | iD: { 80 | class: 'link-editor-id', 81 | link: `${OPENSTREETMAP_SERVER}/edit?editor=id¬e=${this.id}#map=19/${this.coordinates.join('/')}`, 82 | icon: 'external', 83 | text: Localizer.message('action.edit.id') 84 | }, 85 | rapid: { 86 | class: 'link-editor-rapid', 87 | link: `https://rapideditor.org/edit#map=19/${this.coordinates.join('/')}¬e=${this.id}`, 88 | icon: 'external', 89 | text: Localizer.message('action.edit.rapid') 90 | }, 91 | josm: { 92 | class: 'link-editor-josm', 93 | link: `http://127.0.0.1:8111/load_and_zoom?left=${bbox.left}&bottom=${bbox.bottom}&right=${bbox.right}&top=${bbox.top}`, 94 | icon: 'external', 95 | text: Localizer.message('action.edit.josm'), 96 | remote: true 97 | }, 98 | level0: { 99 | class: 'link-editor-level0', 100 | link: `http://level0.osmz.ru/?center=${this.coordinates.join()}`, 101 | icon: 'external', 102 | text: Localizer.message('action.edit.level0') 103 | }, 104 | mapillary: { 105 | class: 'link-tool-mapillary', 106 | icon: 'mapillary', 107 | text: Localizer.message('action.mapillary') 108 | }, 109 | deepl: { 110 | class: 'link-tool-deepl', 111 | link: `https://www.deepl.com/translator#auto/${Localizer.LANGUAGE}/${encodeURIComponent(this.comments[0].text).replace(/%2F/g, '\\%2F')}`, 112 | icon: 'external', 113 | text: Localizer.message('action.deepl') 114 | }, 115 | comment: { 116 | class: 'comments-modal-trigger requires-authentication', 117 | icon: 'chat', 118 | text: Localizer.message('action.comment') 119 | } 120 | }; 121 | 122 | if (this.comments.length > 1) { 123 | delete actions.comment; 124 | } 125 | 126 | if (this.linked) { 127 | const { id, type } = this.linked; 128 | actions.iD.link = `${OPENSTREETMAP_SERVER}/edit?editor=id&${type}=${id}`; 129 | actions.rapid.link = `https://rapideditor.org/edit#id=${type.charAt(0)}${id}`; 130 | actions.josm.link += `&select=${type}${id}`; 131 | actions.level0.link = `http://level0.osmz.ru/?url=${type}/${id}¢er=${this.coordinates.join()}`; 132 | } 133 | 134 | return actions; 135 | } 136 | 137 | /** 138 | * Find the first linked OpenStreetMap element in the first comment 139 | * 140 | * @function 141 | * @returns {Object} 142 | */ 143 | get linked() { 144 | if (!('html' in this.comments[0])) { 145 | return null; 146 | } 147 | 148 | for (const regex of OPENSTREETMAP_ELEMENT_REGEX) { 149 | const match = this.comments[0].html.match(regex); 150 | 151 | if (match && match.length >= 3) { 152 | let [ , type, id ] = match; // eslint-disable-line prefer-const 153 | 154 | switch (type) { 155 | case 'n': 156 | type = 'node'; 157 | break; 158 | case 'w': 159 | type = 'way'; 160 | break; 161 | case 'r': 162 | type = 'relation'; 163 | break; 164 | } 165 | 166 | return { 167 | type, id 168 | }; 169 | } 170 | } 171 | return null; 172 | } 173 | 174 | /** 175 | * Create all necessary badges dynamically 176 | * 177 | * @function 178 | * @returns {Object} 179 | */ 180 | get badges() { 181 | return { 182 | age: Badges.age(this.color, this.created), 183 | comments: Badges.comments(this.comments.length - 1), 184 | country: Badges.country(this.coordinates), 185 | user: Badges.user(this.comments[0].uid, this.anonymous), 186 | report: Badges.report(this.id) 187 | }; 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /app/css/main.scss: -------------------------------------------------------------------------------- 1 | @use 'colors'; 2 | @use 'icons'; 3 | @use 'markers'; 4 | @use 'dark'; 5 | 6 | :root { 7 | --background-color: white; 8 | --box-shadow-color: #546e7a; 9 | 10 | --exponential-ease-out: cubic-bezier(.2, 1, .2, 1); 11 | } 12 | 13 | /*---------- Content ------------*/ 14 | html, body { 15 | height: 100%; 16 | } 17 | 18 | body { 19 | &[data-editor-id="false"] .link-editor-id, 20 | &[data-editor-rapid="false"] .link-editor-rapid, 21 | &[data-editor-josm="false"] .link-editor-josm, 22 | &[data-editor-level0="false"] .link-editor-level0, 23 | 24 | &[data-tool-openstreetmap="false"] .link-osm, 25 | &[data-tool-mapillary="false"] .link-tool-mapillary, 26 | &[data-tool-deepl="false"] .link-tool-deepl, 27 | 28 | &[data-authenticated="true"] .requires-no-authentication, 29 | &[data-authenticated="false"] .requires-authentication, 30 | 31 | &[data-query-changed="true"] .requires-same-query, 32 | &[data-query-changed="false"] .requires-changed-query, 33 | 34 | [data-status="open"] .comment-action[data-action="reopen"], 35 | [data-status="closed"] .comment-action[data-action="comment"], 36 | [data-status="closed"] .comment-action[data-action="close"], 37 | 38 | #note-container .comments-modal-trigger { 39 | display: none; 40 | } 41 | } 42 | 43 | header.navbar { 44 | position: fixed; 45 | width: 100%; 46 | background-color: var(--background-color); 47 | box-shadow: 0 10px 10px -10px var(--box-shadow-color); 48 | z-index: 2; 49 | } 50 | 51 | main { 52 | position: absolute; 53 | height: calc(100% - 44px); 54 | bottom: 0; 55 | width: 100%; 56 | } 57 | 58 | .modal-container { 59 | max-width: 850px; 60 | max-height: 80vh; 61 | } 62 | 63 | .sidebar { 64 | position: absolute; 65 | top: 0; 66 | padding: 12px 12px 0px; 67 | max-height: 100%; 68 | background-color: var(--background-color); 69 | overflow-y: auto; 70 | transition: transform 250ms var(--exponential-ease-out); 71 | box-shadow: 10px 10px 10px -10px var(--box-shadow-color); 72 | } 73 | 74 | #note-container { 75 | .note-actions { 76 | position: sticky; 77 | bottom: 0; 78 | z-index: 1; 79 | padding-top: 20%; 80 | padding-bottom: 12px; 81 | background-image: linear-gradient(to bottom, transparent, var(--background-color) 50%); 82 | } 83 | } 84 | 85 | .note-badges { 86 | font-size: 90%; 87 | } 88 | 89 | .note-actions { 90 | display: flex; 91 | flex-wrap: wrap; 92 | > * { 93 | flex: 1; 94 | margin: 2px; 95 | } 96 | } 97 | 98 | #bottom-container { 99 | position: absolute; 100 | bottom: 16px; 101 | left: 50%; 102 | transform: translateX(-50%); 103 | } 104 | 105 | #modal-area-selector-content { 106 | height: 50vh; 107 | } 108 | 109 | /*---------- Leaflet ------------*/ 110 | #map, #map-container { 111 | height: 100%; 112 | z-index: 0; 113 | } 114 | 115 | .leaflet-control-attribution { 116 | font-size: 12px; 117 | a { 118 | color: #302ecd; 119 | } 120 | } 121 | 122 | .leaflet-touch .leaflet-control-layers, 123 | .leaflet-touch .leaflet-bar { 124 | border: none; 125 | } 126 | 127 | .leaflet-popup-content-wrapper { 128 | padding: 0; 129 | .leaflet-popup-content { 130 | margin: 0; 131 | .card { 132 | border: 0; 133 | } 134 | } 135 | } 136 | 137 | /*---------- Spectre overrides ------------*/ 138 | .badge.badge-small[data-badge]::after { 139 | font-size: .5rem; 140 | height: .5rem; 141 | line-height: .5; 142 | min-width: .5rem; 143 | } 144 | 145 | .badge:not([data-badge]), .badge[data-badge] { 146 | &.badge-small::after { 147 | box-shadow: 0 0 0 .075rem #fff; 148 | } 149 | } 150 | 151 | 152 | /*---------- Other utility classes ------------*/ 153 | body.deprecated-browser #deprecation-warning { 154 | display: block !important; 155 | } 156 | 157 | .img-preview { 158 | display: inline-block; 159 | max-height: 200px; 160 | max-width: 50%; 161 | } 162 | 163 | .c-default { 164 | cursor: default; 165 | } 166 | 167 | .m-auto { 168 | margin: auto; 169 | } 170 | 171 | .flex-grow { 172 | flex-grow: 1; 173 | } 174 | 175 | .text-center { 176 | text-align: center; 177 | } 178 | 179 | input:disabled ~ span { 180 | cursor: not-allowed; 181 | opacity: .5; 182 | } 183 | 184 | textarea { 185 | resize: vertical; 186 | } 187 | 188 | .tooltip::after { 189 | max-width: none; 190 | } 191 | 192 | .faq-color { 193 | border-radius: .1rem; 194 | margin: .25rem 0; 195 | padding: 3rem .5rem .5rem; 196 | min-height: 140px; 197 | } 198 | 199 | .marker-icon { 200 | svg { 201 | filter: drop-shadow(4px 4px 4px rgba(0, 0, 0, 0.3)); 202 | transition: transform 500ms var(--exponential-ease-out); 203 | transform-origin: bottom; 204 | } 205 | &:focus svg { 206 | transform: scale(1.2); 207 | } 208 | } 209 | 210 | .overlay { 211 | position: fixed; 212 | top: 0; 213 | left: 0; 214 | right: 0; 215 | bottom: 0; 216 | background-color: rgba(0, 0, 0, 0.5); 217 | z-index: 500; 218 | } 219 | 220 | .out-of-view { 221 | transform: translateX(-100%); 222 | } 223 | 224 | /*---------- Liberapay ----------*/ 225 | .liberapay-logo { 226 | display: inline-block; 227 | background-color: #f6c915; 228 | border-radius: 4px; 229 | padding: 0px 5px; 230 | b { 231 | color: black; 232 | text-decoration: none; 233 | vertical-align: text-bottom; 234 | } 235 | } 236 | 237 | /*---------- Mapillary ----------*/ 238 | .mapillary-image { 239 | max-width: 320px; 240 | max-height: 240px; 241 | 242 | .mapillary-image-link { 243 | top: 6px; 244 | left: 0px; 245 | } 246 | 247 | .mapillary-image-user { 248 | bottom: 0px; 249 | right: 0px; 250 | font-size: 0.5rem; 251 | } 252 | } 253 | 254 | /*---------- Toasts ------------*/ 255 | #toast-container { 256 | position: fixed; 257 | top: 5%; 258 | right: 5%; 259 | z-index: 500; 260 | > .toast { 261 | margin-top: 10px; 262 | cursor: default; 263 | } 264 | } 265 | 266 | /*---------- Labels ------------*/ 267 | .label-green-dark { 268 | background: var(--green-dark-primary); 269 | color: #fff; 270 | } 271 | .label-green { 272 | background: var(--green-primary); 273 | color: #fff; 274 | } 275 | .label-lime { 276 | background: var(--lime-primary); 277 | color: #fff; 278 | } 279 | .label-amber { 280 | background: var(--amber-primary); 281 | color: #fff; 282 | } 283 | .label-orange { 284 | background: var(--orange-primary); 285 | color: #fff; 286 | } 287 | .label-red { 288 | background: var(--red-primary); 289 | color: #fff; 290 | } 291 | -------------------------------------------------------------------------------- /app/svg/illustration/lost.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/templates/includes/nav.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 | 9 | 10 |
11 | 12 |
13 | 19 | 20 |
21 | 22 |
23 | 29 | 30 |
31 | 32 |
33 | 34 | 35 |
36 | 37 |
38 | 44 | 45 |
46 | 47 |
48 | 54 | 55 |
56 | 57 |
58 | 59 | 65 |
66 | 67 |
68 | 69 | 70 | 71 | 72 | 73 | 74 |
75 | 76 |
77 | 78 | 79 | 80 | 81 | 82 | 83 |
84 | 85 | 86 |
87 | 88 | 89 |
90 | 91 | 92 |
93 | 94 | 95 |
96 | 97 | 98 |
99 | 100 | 101 |
102 | 103 |
104 | 105 | 106 | 107 | 108 | 109 | 110 |
111 | 112 |
113 | 114 | 115 | 116 | 117 | 118 | 119 |
120 |
121 | -------------------------------------------------------------------------------- /app/js/localizer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file was modified from {@link https://github.com/TinyWebEx/Localizer/blob/master/Localizer.js} by @rugk 3 | */ 4 | const I18N_ATTRIBUTE = 'data-i18n'; 5 | const I18N_DATASET = 'i18n'; 6 | 7 | export const LANGUAGE = navigator.language || navigator.userLanguage; 8 | const FALLBACK_LANGUAGE = 'en'; 9 | 10 | const STRINGS = { 11 | main: null, 12 | fallback: null 13 | }; 14 | 15 | const REPLACEMENTS = [ 16 | ['{{version}}', __VERSION__], 17 | ['{{api}}', NOTESREVIEW_API_URL] 18 | ]; 19 | 20 | /** 21 | * Replace the content of a HTMLElement with the localized string 22 | * 23 | * @function 24 | * @param {HTMLElement} element 25 | * @param {String} tag 26 | * @returns {void} 27 | */ 28 | function replaceI18n(element, tag) { 29 | // Localize main content 30 | if (tag !== '') { 31 | replaceWith(element, null, message(tag)); 32 | } 33 | 34 | // Localize attributes 35 | for (const [attribute, value] of Object.entries(element.dataset)) { 36 | if (attribute.substring(0, I18N_DATASET.length) !== I18N_DATASET || attribute === I18N_DATASET) { 37 | continue; 38 | } 39 | 40 | const replaceAttribute = convertDatasetToAttribute(attribute.slice(I18N_DATASET.length)); 41 | replaceWith(element, replaceAttribute, message(value)); 42 | } 43 | } 44 | 45 | /** 46 | * Converts a dataset value back to a real attribute. 47 | * 48 | * @private 49 | * @param {String} dataSetValue 50 | * @returns {String} 51 | * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/dataset#Name_conversion} 52 | */ 53 | function convertDatasetToAttribute(dataSetValue) { 54 | // if beginning of string is capital letter, only lowercase that 55 | /** {@link https://regex101.com/r/GaVoVi/1} **/ 56 | dataSetValue = dataSetValue.replace(/^[A-Z]/, (char) => char.toLowerCase()); 57 | 58 | // replace all other capital letters with dash in front of them 59 | /** {@link https://regex101.com/r/GaVoVi/3} **/ 60 | dataSetValue = dataSetValue.replace(/[A-Z]/, (char) => { 61 | return `-${char.toLowerCase()}`; 62 | }); 63 | 64 | return dataSetValue; 65 | } 66 | 67 | /** 68 | * Replaces attribute or inner text of a specified element with a string. 69 | * 70 | * @private 71 | * @param {HTMLElement} element 72 | * @param {String} attribute 73 | * @param {String} translatedMessage 74 | * @returns {void} 75 | */ 76 | function replaceWith(element, attribute, translatedMessage) { 77 | if (!translatedMessage) { 78 | return; 79 | } 80 | 81 | const isHTML = translatedMessage.substring(0, 6) === '!HTML!'; 82 | if (isHTML) { 83 | translatedMessage = translatedMessage.replace(/^!HTML!(\s+)?/, ''); 84 | } 85 | 86 | if (attribute) { 87 | element.setAttribute(attribute, translatedMessage); 88 | } else { 89 | if (translatedMessage !== '') { 90 | if (isHTML) { 91 | element.innerHTML = translatedMessage; 92 | } else { 93 | element.textContent = translatedMessage; 94 | } 95 | } 96 | } 97 | } 98 | 99 | /** 100 | * Get the correct value of a given tag and optionally format the string to add dynamic data 101 | * 102 | * @function 103 | * @param {String} tag 104 | * @param {Array} strings 105 | * @returns {String} 106 | */ 107 | export function message(tag, ...strings) { 108 | let value = STRINGS.main === null ? null : getProperty(tag, STRINGS.main); 109 | if (!value || (typeof value !== 'string')) { 110 | value = getProperty(tag, STRINGS.fallback); 111 | } 112 | if (!value || (typeof value !== 'string')) { 113 | return console.error( // eslint-disable-line no-console 114 | new Error(`String with the tag ${tag} could not be found in "${LANGUAGE}.json" and "${FALLBACK_LANGUAGE}.json"`) 115 | ); 116 | } 117 | 118 | // Replace placeholders with variables 119 | strings.forEach(string => { 120 | value = value.replace('%s', string); 121 | }); 122 | 123 | // Replace values which can not be included in the hardcoded string 124 | REPLACEMENTS.forEach(([keyword, replacement]) => { 125 | value = value.replace(keyword, replacement).trim(); 126 | }); 127 | 128 | return value; 129 | } 130 | 131 | /** 132 | * Get a value from a object using a string in the dot notation (e.g. 'action.cancel') 133 | * 134 | * @function 135 | * @param {String} propertyName 136 | * @param {Object} translations Translations in the right language 137 | * @returns {String} 138 | */ 139 | function getProperty(propertyName, translations) { 140 | const parts = propertyName.split('.'); 141 | let property = translations; 142 | for (let i = 0; i < parts.length; i++) { 143 | if (typeof property[parts[i]] !== 'undefined') { 144 | property = property[parts[i]]; 145 | } 146 | } 147 | return property; 148 | } 149 | 150 | /** 151 | * Localize all strings in a given container 152 | * 153 | * @function 154 | * @param {HTMLElement} container 155 | * @returns {void} 156 | */ 157 | export function localize(container) { 158 | container.querySelectorAll(`[${I18N_ATTRIBUTE}]`).forEach(element => { 159 | const string = element.dataset[I18N_DATASET]; 160 | replaceI18n(element, string); 161 | }); 162 | } 163 | 164 | /** 165 | * Start the localization process 166 | * 167 | * @function 168 | * @returns {Promise} 169 | */ 170 | export async function init() { 171 | try { 172 | const { default: main } = await import(`../locales/${LANGUAGE}.json`); 173 | STRINGS.main = main; 174 | } catch (error) { // eslint-disable-line no-unused-vars 175 | console.error( // eslint-disable-line no-console 176 | new Error(`${LANGUAGE}.json does not exist, ${FALLBACK_LANGUAGE}.json is used instead`) 177 | ); 178 | } 179 | 180 | const { default: fallback } = await import(`../locales/${FALLBACK_LANGUAGE}.json`); 181 | STRINGS.fallback = fallback; 182 | 183 | localize(document.body); 184 | 185 | // Replace html lang attribute after translation 186 | document.querySelector('html').setAttribute('lang', LANGUAGE); 187 | 188 | if (!('Intl' in window) || !('RelativeTimeFormat' in window.Intl)) { 189 | let locale; 190 | switch (LANGUAGE.split('-')[0]) { 191 | case 'de': 192 | locale = await import('relative-time-format/locale/de'); 193 | break; 194 | case 'es': 195 | locale = await import('relative-time-format/locale/es'); 196 | break; 197 | case 'fr': 198 | locale = await import('relative-time-format/locale/fr'); 199 | break; 200 | case 'it': 201 | locale = await import('relative-time-format/locale/it'); 202 | break; 203 | case 'nl': 204 | locale = await import('relative-time-format/locale/nl'); 205 | break; 206 | case 'pl': 207 | locale = await import('relative-time-format/locale/pl'); 208 | break; 209 | case 'pt': 210 | locale = await import('relative-time-format/locale/pt'); 211 | break; 212 | case 'uk': 213 | locale = await import('relative-time-format/locale/uk'); 214 | break; 215 | case 'zh': 216 | locale = await import('relative-time-format/locale/zh'); 217 | break; 218 | default: 219 | locale = await import('relative-time-format/locale/en'); 220 | } 221 | 222 | // chrome >= 71, edge >= 79, firefox >= 65, not ie <= 11, opera >= 58, safari >= 14 223 | const { default: RelativeTimeFormat } = await import('relative-time-format'); 224 | RelativeTimeFormat.addLocale(locale.default); 225 | RelativeTimeFormat.setDefaultLocale(locale.default.locale); 226 | 227 | if (!('Intl' in window)) { 228 | window.Intl = {}; 229 | } 230 | window.Intl.RelativeTimeFormat = RelativeTimeFormat; 231 | } 232 | 233 | return Promise.resolve(); 234 | } 235 | -------------------------------------------------------------------------------- /app/locales/pl.json: -------------------------------------------------------------------------------- 1 | { 2 | "help": { 3 | "colors": { 4 | "title": "Jakie jest znaczenie kolorów?", 5 | "description": "Każdy color oznacza wiek uwagi", 6 | "darkgreen": "Ciemnozielony", 7 | "green": "Zielony", 8 | "amber": "Bursztynowy", 9 | "orange": "Pomarańczowy", 10 | "red": "Czerwony", 11 | "descriptions": { 12 | "darkgreen": "Pomiędzy sekundą i dobą", 13 | "green": "Pomiędzy jednym i 31 dniami", 14 | "amber": "Pomiędzy 6 miesięcy i rok", 15 | "orange": "Pomiędzy rok i dwa lata", 16 | "red": "Dwa lub więcej lat", 17 | "lime": "Pomiędzy jednym i sześcioma miesiącami" 18 | }, 19 | "lime": "Limonkowy" 20 | }, 21 | "multipleUsers": { 22 | "title": "Czy jest możliwe szukanie wielu użytkowników naraz?", 23 | "description": "!HTML! Tak, użyj przecinka do oddzielenia użytkowników.
\nPodczas szukania autora uwagi, zostaną znalezione uwagi stworzone przez dowolnego z użytkowników wpisanych.
\nSzukając użytkowników uwagi, zostaną znalezione wszystkie uwagi, gdzie wszyscy użytkownicy wpisani komentowali." 24 | }, 25 | "excludingUsers": { 26 | "title": "Czy jest możliwe pominięcie niektórych użytkowników?", 27 | "description": "!HTML! Tak, za pomocą napisania NOT lub - przed nazwą użytkownika.
\nJest także możliwe wyfiltrowanie wielu użytkowników za pomocą odzielenia ich przecinkami." 28 | } 29 | }, 30 | "action": { 31 | "cancel": "Anuluj", 32 | "close": "Zamknij", 33 | "copyLinkSuccess": "Udało się skopiować link", 34 | "save": "Zapisz", 35 | "search": "Szukaj", 36 | "more": "Więcej", 37 | "openstreetmap": "OpenStreetMap", 38 | "edit": { 39 | "id": "iD", 40 | "josm": "JOSM", 41 | "level0": "Level0" 42 | }, 43 | "login": "Logowanie", 44 | "logout": "Wylogowanie", 45 | "comment": "Komentuj", 46 | "commentClose": "Komentuj i zamknij uwagę", 47 | "commentReopen": "Otwórz ponownie i komentuj", 48 | "mapillary": "Mapillary", 49 | "report": "Zgłoś uwagę", 50 | "filter": "Filtruj", 51 | "refresh": "Odśwież", 52 | "reset": "Resetuj", 53 | "center": "Wyśrodkuj mapę do wyników", 54 | "deepl": "Deepl" 55 | }, 56 | "header": { 57 | "share": "Udostępnij", 58 | "faq": "Najcześciej zadawane pytania (FAQ)", 59 | "about": "O projekcie", 60 | "settings": "Ustawienia", 61 | "query": "Zapytanie", 62 | "limit": "Limit", 63 | "user": "Użytkownik", 64 | "from": "Od", 65 | "to": "Do", 66 | "sort": "Sortuj po", 67 | "status": "Status", 68 | "anonymous": "Anonimowe uwagi", 69 | "area": "Szukaj w obszarze", 70 | "commented": "Komentowane uwagi", 71 | "author": "Autor" 72 | }, 73 | "description": { 74 | "share": "Skopiuj ten link, aby udostępnij aktualne zapytanie:", 75 | "startQuery": "Zacznij zapytanie po otwarciu linku", 76 | "shareView": "Udostępnij aktualny widok mapy", 77 | "showList": "Pokaż na liście", 78 | "showMap": "Pokaż na mapie", 79 | "nothingFound": "Nic nie znaleziono!", 80 | "autoLimit": "Automatycznie ustawiono limit 250, bo większe wartości nie są dozwolone", 81 | "deprecationWarning": "Przeglądarka, którą obecnie używasz nie wspiera NotesReview. Proszę rozważ zmianę przeglądarki, aby móc używać NotesReview.", 82 | "sort": { 83 | "created": { 84 | "desc": "Najnowsze", 85 | "asc": "Najstarsze" 86 | }, 87 | "updated": { 88 | "desc": "Niedawno aktualizowane", 89 | "asc": "Najdawniej aktualizowane" 90 | } 91 | }, 92 | "query": "Wyraz lub fraza zawarta w uwadze", 93 | "status": { 94 | "all": "Wszystkie", 95 | "open": "Otwarte", 96 | "closed": "Zamknięte" 97 | }, 98 | "anonymous": { 99 | "include": "Uwzględnij", 100 | "hide": "Schowaj", 101 | "only": "Tylko" 102 | }, 103 | "statistics": "Znaleziono %s uwag", 104 | "from": "Data (UTC) jako dolne ograniczenie", 105 | "to": "Data (UTC) jako górne ograniczenie", 106 | "area": { 107 | "global": "Globalnie", 108 | "view": "W aktualnym widoku", 109 | "custom": "W wybranym obszarze" 110 | }, 111 | "commented": { 112 | "include": "Uwzględnij", 113 | "hide": "Schowaj", 114 | "only": "Tylko" 115 | }, 116 | "author": "Autor to użytkownik, który stworzył uwagę", 117 | "user": "Użytkownik to dowolna osoba, która komentowała uwagę\n(także autor)", 118 | "customArea": "Wybierz kraje klikając na nich lub narysuj kształt za pomocą paska narzędzi po lewej" 119 | }, 120 | "settings": { 121 | "choose": "Wybierz opcję", 122 | "theme": { 123 | "title": "Wybierz motyw", 124 | "light": "Jasny", 125 | "dark": "Ciemny", 126 | "system": "Użyj ustawień systemu" 127 | }, 128 | "tools": { 129 | "title": "Narzędzia", 130 | "description": "Wybierz swoje ulubione narzędzie(a)", 131 | "editors": "Edytory", 132 | "other": "Inne" 133 | } 134 | }, 135 | "note": { 136 | "anonymous": "anonimowa", 137 | "comment": "1 komentarz", 138 | "comments": "%s komentarzy", 139 | "action": { 140 | "opened": "Utworzono uwagę", 141 | "closed": "Zamknięto uwagę", 142 | "reopened": "Otwarto uwagę ponownie" 143 | }, 144 | "unknown": "Nieznany użytkownik" 145 | }, 146 | "map": { 147 | "attribution": "© autorzy OpenStreetMap, © CARTO" 148 | }, 149 | "comments": { 150 | "inputPlaceholder": "Napisz komentarz dla wszystkich użytkowników" 151 | }, 152 | "accessibility": { 153 | "openFaq": "Otwórz najczęściej zadawane pytania", 154 | "openSettings": "Otwórz ustawienia", 155 | "shareQuery": "Udostępnij aktualne zapytanie", 156 | "filter": "Zaaplikuj filtr do zaznaczenia" 157 | }, 158 | "mapillary": { 159 | "empty": { 160 | "add": "Dodaj zdjęcia tego miejsca", 161 | "title": "Nie ma jeszcze zdjęć tego miejsca", 162 | "subtitle": "Możesz pomóc innych mapującym dzięki udostępnieniu zdjęć z poziomu ulicy do Mapillary." 163 | } 164 | }, 165 | "user": { 166 | "created": "Użytkownik od %s" 167 | }, 168 | "about": { 169 | "description": "!HTML! NotesReview to projekt otwartoźródłowy. Aktualnie korzystasz z wersji {{version}}. Kod źródłowy dostępny jest na Githubie.", 170 | "contribution": "!HTML! Możesz pomóc w rozwoju projektu tłumacząc lub zgłaszając błędy.", 171 | "author": "!HTML! Stworzone z ❤️ przez ENT8R", 172 | "donate": "Darowizna" 173 | }, 174 | "error": { 175 | "login": "Logowanie się nie powiodło, spróbuj ponownie później", 176 | "comment": "Wysyłanie komentarza się nie powiodło, spróbuj ponownie później" 177 | } 178 | } -------------------------------------------------------------------------------- /app/locales/pt-BR.json: -------------------------------------------------------------------------------- 1 | { 2 | "help": { 3 | "colors": { 4 | "title": "Qual é o significado das cores?", 5 | "description": "Cada cor representa a idade da nota", 6 | "darkgreen": "Verde escuro", 7 | "green": "Verde", 8 | "amber": "Âmbar", 9 | "orange": "Laranja", 10 | "red": "Vermelho", 11 | "descriptions": { 12 | "darkgreen": "Entre um segundo e um dia", 13 | "green": "Entre um e 31 dias", 14 | "amber": "Entre seis meses e um ano", 15 | "orange": "Entre um e dois anos", 16 | "red": "Dois ou mais anos", 17 | "lime": "Entre um e seis meses" 18 | }, 19 | "lime": "Lima" 20 | }, 21 | "multipleUsers": { 22 | "title": "É possível buscar múltiplos usuários?", 23 | "description": "!HTML! Sim, usando uma vírgula para separar cada usuário.
\nAo pesquisar o autor de uma nota, ele tentará encontrar as notas que foram abertas por qualquer um dos usuários indicados.
\nAo pesquisar os usuários de uma nota, ele tentará encontrar as notas que todos os usuários comentaram." 24 | }, 25 | "excludingUsers": { 26 | "title": "É possível excluir certos usuários?", 27 | "description": "!HTML! Sim, escrevendo NOT ou - na frente do usuário a ser excluído.
\nTambém é possível excluir vários usuários separando cada usuário com uma vírgula." 28 | } 29 | }, 30 | "action": { 31 | "cancel": "Cancelar", 32 | "close": "Fechar", 33 | "copyLinkSuccess": "Link copiado com sucesso", 34 | "save": "Salvar", 35 | "search": "Pesquisar", 36 | "more": "Mais", 37 | "openstreetmap": "OpenStreetMap", 38 | "edit": { 39 | "id": "iD", 40 | "josm": "JOSM", 41 | "level0": "Level0" 42 | }, 43 | "login": "Entrar", 44 | "logout": "Sair", 45 | "comment": "Comentário", 46 | "commentClose": "Comentar e fechar", 47 | "commentReopen": "Reabrir e comentar", 48 | "mapillary": "Mapillary", 49 | "report": "Reportar nota", 50 | "filter": "Filtro", 51 | "refresh": "Recarregar", 52 | "reset": "Redefinir", 53 | "center": "Centralizar o mapa nos resultados", 54 | "deepl": "DeepL", 55 | "download": "Baixar dados (.json)" 56 | }, 57 | "header": { 58 | "share": "Compartilhar", 59 | "faq": "Perguntas frequentes", 60 | "about": "Sobre", 61 | "settings": "Configurações", 62 | "query": "Consulta", 63 | "limit": "Limite", 64 | "user": "Usuário", 65 | "from": "De", 66 | "to": "Para", 67 | "sort": "Ordenar por", 68 | "status": "Estado", 69 | "anonymous": "Notas anônimas", 70 | "area": "Área de busca", 71 | "commented": "Notas comentadas", 72 | "author": "Autor" 73 | }, 74 | "description": { 75 | "share": "Copiar este link para compartilhar a consulta atual:", 76 | "startQuery": "Iniciar a consulta diretamente após abrir o link", 77 | "shareView": "Compartilhar a visão atual do mapa", 78 | "showList": "Mostrar em uma lista", 79 | "showMap": "Mostrar em um mapa", 80 | "nothingFound": "Nada encontrado!", 81 | "autoLimit": "Definir limite automaticamente para 250, porque valores mais altos não são permitidos", 82 | "deprecationWarning": "O navegador que você está usando atualmente não oferece suporte a NotesReview. Considere mudar para outro navegador para poder usar o NotesReview.", 83 | "sort": { 84 | "created": { 85 | "desc": "Mais recente", 86 | "asc": "Mais antigo" 87 | }, 88 | "updated": { 89 | "desc": "Atualizado recentemente", 90 | "asc": "Menos atualizado recentemente" 91 | } 92 | }, 93 | "query": "Uma palavra ou frase mencionada na nota", 94 | "status": { 95 | "all": "Todas", 96 | "open": "Abertas", 97 | "closed": "Fechadas" 98 | }, 99 | "anonymous": { 100 | "include": "Incluir", 101 | "hide": "Ocultar", 102 | "only": "Apenas" 103 | }, 104 | "statistics": "%s notas encontradas", 105 | "from": "Uma data (UTC) como limite inferior", 106 | "to": "Uma data (UTC) como limite superior", 107 | "area": { 108 | "global": "Global", 109 | "view": "Vista atual", 110 | "custom": "Área personalizada" 111 | }, 112 | "commented": { 113 | "include": "Incluir", 114 | "hide": "Ocultar", 115 | "only": "Apenas" 116 | }, 117 | "author": "O autor é o usuário que criou a nota", 118 | "user": "Um usuário é qualquer usuário que deixou um comentário na nota\n(incluindo o autor)", 119 | "customArea": "Selecione os países clicando neles ou desenhe uma forma personalizada usando a barra de ferramentas à esquerda", 120 | "sharePolygon": "Compartilhar forma para seleção de área personalizada" 121 | }, 122 | "settings": { 123 | "choose": "Escolher uma opção", 124 | "theme": { 125 | "title": "Selecionar tema", 126 | "light": "Claro", 127 | "dark": "Escuro", 128 | "system": "Usar configuração do sistema" 129 | }, 130 | "tools": { 131 | "title": "Ferramentas", 132 | "description": "Selecione sua(s) ferramenta(s) favorita(s)", 133 | "editors": "Editores", 134 | "other": "Outros" 135 | } 136 | }, 137 | "note": { 138 | "anonymous": "anônimo", 139 | "comment": "1 comentário", 140 | "comments": "%s comentários", 141 | "action": { 142 | "opened": "Nota criada", 143 | "closed": "Nota fechada", 144 | "reopened": "Nota reaberta" 145 | }, 146 | "unknown": "Usuário desconhecido" 147 | }, 148 | "map": { 149 | "attribution": "© contribuidores do OpenStreetMap, © CARTO\nattribution" 150 | }, 151 | "comments": { 152 | "inputPlaceholder": "Escreva um comentário para todos os outros usuários" 153 | }, 154 | "accessibility": { 155 | "openFaq": "Abra as perguntas frequentes", 156 | "openSettings": "Abrir configurações", 157 | "shareQuery": "Compartilhar a consulta atual", 158 | "filter": "Aplicar um filtro à seleção" 159 | }, 160 | "mapillary": { 161 | "empty": { 162 | "add": "Adicionar fotos deste lugar", 163 | "title": "Ainda não há fotos deste lugar", 164 | "subtitle": "Você pode ajudar outros mapeadores enviando imagens de ruas para o Mapillary." 165 | } 166 | }, 167 | "user": { 168 | "created": "Usuário desde %s" 169 | }, 170 | "about": { 171 | "description": "!HTML! NotesReview é um projeto de código aberto. Você está usando a versão {{version}}. O código-fonte está disponível em Github.", 172 | "contribution": "!HTML! Você pode apoiar o projeto traduzindo isso ou reportando problemas.", 173 | "author": "!HTML! Feito com ❤️ por ENT8R", 174 | "donate": "Doar" 175 | }, 176 | "error": { 177 | "login": "O login falhou, por favor tente novamente mais tarde", 178 | "comment": "O comentário falhou, tente novamente mais tarde", 179 | "queryTimeout": "Tempo esgotado, a consulta é muito complexa" 180 | } 181 | } -------------------------------------------------------------------------------- /app/locales/it.json: -------------------------------------------------------------------------------- 1 | { 2 | "help": { 3 | "colors": { 4 | "title": "Qual è il significato dei colori?", 5 | "description": "Ogni colore rappresenta l’età della nota", 6 | "darkgreen": "Verde scuro", 7 | "green": "Verde", 8 | "amber": "Ambra", 9 | "orange": "Arancione", 10 | "red": "Rosso", 11 | "descriptions": { 12 | "darkgreen": "Da un secondo a un giorno", 13 | "green": "Da uno a 31 giorni", 14 | "amber": "Da sei mesi a un anno", 15 | "orange": "Da uno a due anni", 16 | "red": "Due o più anni", 17 | "lime": "Da uno a sei mesi" 18 | }, 19 | "lime": "Lime" 20 | }, 21 | "multipleUsers": { 22 | "title": "E possibile cercare più di un utente?", 23 | "description": "!HTML! Sì, usa una virgola per separare ogni utente.
\nQuando cerchi l'autore di una nota, il programma cercherà di trovare le note aperte dagli utenti indicati.
\nQuando cerchi gli utenti di una nota, il programma cercherà le note che gli utenti indicati hanno commentato." 24 | }, 25 | "excludingUsers": { 26 | "title": "È possibile escludere determinati utenti?", 27 | "description": "!HTML! Sì, scrivendo NOT o - prima dell'utente da escludere.
\nÈ anche possibile escludere più di un utente separando ciascun utente con una virgola." 28 | } 29 | }, 30 | "action": { 31 | "cancel": "Annulla", 32 | "close": "Chiudi", 33 | "copyLinkSuccess": "Collegamento copiato correttamente", 34 | "save": "Salva", 35 | "search": "Cerca", 36 | "more": "Altro", 37 | "openstreetmap": "OpenStreetMap", 38 | "edit": { 39 | "id": "iD", 40 | "josm": "JOSM", 41 | "level0": "Level0", 42 | "rapid": "Rapid" 43 | }, 44 | "login": "Accedi", 45 | "logout": "Esci", 46 | "comment": "Commenta", 47 | "commentClose": "Commenta e chiudi", 48 | "commentReopen": "Riapri e commenta", 49 | "mapillary": "Mapillary", 50 | "report": "Segnala nota", 51 | "filter": "Filtra", 52 | "refresh": "Aggiorna", 53 | "reset": "Reimposta", 54 | "center": "Centra la mappa sui risultati", 55 | "deepl": "Deepl", 56 | "download": "Scarica dati (.json)" 57 | }, 58 | "header": { 59 | "share": "Condividi", 60 | "faq": "FAQ", 61 | "about": "Informazioni", 62 | "settings": "Impostazioni", 63 | "query": "Cerca", 64 | "limit": "Limita", 65 | "user": "Utente", 66 | "from": "Da", 67 | "to": "A", 68 | "sort": "Ordina per", 69 | "status": "Stato", 70 | "anonymous": "Note di anonimi", 71 | "area": "Cerca area", 72 | "commented": "Note con commenti", 73 | "author": "Autore" 74 | }, 75 | "description": { 76 | "share": "Copia il collegamento per condividere la richiesta corrente:", 77 | "startQuery": "Fai partire la richiesta non appena si apre il collegamento", 78 | "shareView": "Condividi la vista corrente della mappa", 79 | "showList": "Mostra in una lista", 80 | "showMap": "Mostra in una mappa", 81 | "nothingFound": "Non è stato trovato niente!", 82 | "autoLimit": "Impostato limite automatico a 250 perché valori più grandi non sono ammessi", 83 | "deprecationWarning": "Il browser che stai usando non è compatibile con NotesReview. Prova a cambiare browser per poter usare NotesReview.", 84 | "sort": { 85 | "created": { 86 | "desc": "Più nuove", 87 | "asc": "Più vecchie" 88 | }, 89 | "updated": { 90 | "desc": "Aggiornate di recente", 91 | "asc": "Aggiornate meno di recente" 92 | } 93 | }, 94 | "query": "Una parola o una frase presente nella nota", 95 | "status": { 96 | "all": "Tutte", 97 | "open": "Aperte", 98 | "closed": "Chiuse" 99 | }, 100 | "anonymous": { 101 | "include": "Includi", 102 | "hide": "Nascondi", 103 | "only": "Solo" 104 | }, 105 | "statistics": "Trovate %s note", 106 | "from": "Una data (UTC) come limite inferiore", 107 | "to": "Una data (UTC) come limite superiore", 108 | "area": { 109 | "global": "Globale", 110 | "view": "Vista corrente", 111 | "custom": "Area personalizzata" 112 | }, 113 | "commented": { 114 | "include": "Includi", 115 | "hide": "Nascondi", 116 | "only": "Solo" 117 | }, 118 | "author": "L'autore è l'utente che ha creato la nota", 119 | "user": "Un utente è qualsiasi utente che abbia commentato la nota\n(incluso l'autore)", 120 | "customArea": "Seleziona gli Stati cliccando su di essi o disegna una forma utilizzando la barra strumenti sulla sinistra", 121 | "sharePolygon": "Condividi forma dell'area attualmente selezionata" 122 | }, 123 | "settings": { 124 | "choose": "Scegli un’opzione", 125 | "theme": { 126 | "title": "Seleziona tema", 127 | "light": "Chiaro", 128 | "dark": "Scuro", 129 | "system": "Usa le impostazioni di sistema" 130 | }, 131 | "tools": { 132 | "title": "Strumenti", 133 | "description": "Seleziona i tuoi strumenti preferiti", 134 | "editors": "Editor", 135 | "other": "Altro" 136 | } 137 | }, 138 | "note": { 139 | "anonymous": "anonimo", 140 | "comment": "1 commento", 141 | "comments": "%s commenti", 142 | "action": { 143 | "opened": "Nota creata", 144 | "closed": "Nota chiusa", 145 | "reopened": "Nota riaperta" 146 | }, 147 | "unknown": "Utente sconosciuto" 148 | }, 149 | "map": { 150 | "attribution": "© OpenStreetMap contributors, © CARTO" 151 | }, 152 | "comments": { 153 | "inputPlaceholder": "Scrivi un commento per tutti gli altri utenti" 154 | }, 155 | "accessibility": { 156 | "openFaq": "Accedi alle domande più frequenti", 157 | "openSettings": "Apri impostazioni", 158 | "shareQuery": "Condividi la richiesta corrente", 159 | "filter": "Applica filtro alla selezione", 160 | "closeNotification": "Chiudi notifica" 161 | }, 162 | "mapillary": { 163 | "empty": { 164 | "add": "Aggiungi foto di questo posto", 165 | "title": "Non ci sono ancora foto di qua", 166 | "subtitle": "Puoi aiutare altri mappatori caricando immagini su Mapillary" 167 | } 168 | }, 169 | "user": { 170 | "created": "Utente da %s" 171 | }, 172 | "about": { 173 | "description": "!HTML! NotesReview è un progetto open source. La versione attuale è {{version}}. Il codice sorgente è disponibile su Github.", 174 | "contribution": "!HTML! È possibile supportare questo progetto traducendolo oppure segnalando degli errori.", 175 | "author": "!HTML! Realizzato con ❤️ da ENT8R\n", 176 | "donate": "Dona" 177 | }, 178 | "error": { 179 | "login": "Accesso fallito, riprovare più tardi", 180 | "comment": "Invio del commento fallito, riprovare più tardi", 181 | "queryTimeout": "Timeout, query troppo complessa", 182 | "queryAbort": "Richiesta cancellata, mostro risultati precedenti" 183 | } 184 | } -------------------------------------------------------------------------------- /app/locales/es.json: -------------------------------------------------------------------------------- 1 | { 2 | "help": { 3 | "colors": { 4 | "title": "¿Cuál es el significado de los colores?", 5 | "description": "Todos los colores representan la edad de la nota", 6 | "darkgreen": "Verde Oscuro", 7 | "green": "Verde", 8 | "amber": "Ámbar", 9 | "orange": "Naranja", 10 | "red": "Rojo", 11 | "descriptions": { 12 | "darkgreen": "Entre uno y dos días", 13 | "green": "Entre uno y 31 días", 14 | "amber": "Entre seis meses y un año", 15 | "orange": "Entre uno y dos años", 16 | "red": "Dos o más años", 17 | "lime": "Entre uno y seis meses" 18 | }, 19 | "lime": "Lima" 20 | }, 21 | "multipleUsers": { 22 | "title": "¿Es posible buscar múltiples usuarios?", 23 | "description": "!HTML! Sí, utilizando una coma para separar cada usuario.
\nAl buscar el autor de una nota, intentará encontrar notas que hayan sido abiertas por cualquiera de los usuarios indicados.
\nAl buscar los usuarios de una nota, intentará encontrar notas en las que hayan realizado comentarios todos los usuarios indicados." 24 | }, 25 | "excludingUsers": { 26 | "title": "¿Es posible excluir a ciertos usuarios?", 27 | "description": "!HTML! Sí, escribiendo NOT o - delante del usuario que se va a excluir.
\nTambién es posible excluir a varios usuarios separándolos con una coma." 28 | } 29 | }, 30 | "action": { 31 | "cancel": "Cancelar", 32 | "close": "Cerrar", 33 | "copyLinkSuccess": "Enlace copiado correctamente", 34 | "save": "Guardar", 35 | "search": "Buscar", 36 | "more": "Más", 37 | "openstreetmap": "OpenStreetMap", 38 | "edit": { 39 | "id": "iD", 40 | "josm": "JOSM", 41 | "level0": "Level0", 42 | "rapid": "Rapid" 43 | }, 44 | "login": "Iniciar Sesión", 45 | "logout": "Cerrar Sesión", 46 | "comment": "Comentar", 47 | "commentClose": "Comentar y cerrar", 48 | "commentReopen": "Reactivar y comentar", 49 | "mapillary": "Mapillary", 50 | "report": "Reportar nota", 51 | "filter": "Filtro", 52 | "refresh": "Actualizar", 53 | "reset": "Reset", 54 | "center": "Centrar mapa en resultados", 55 | "deepl": "Deepl", 56 | "download": "Descargar datos (.json)" 57 | }, 58 | "header": { 59 | "share": "Compartir", 60 | "faq": "FAQ", 61 | "about": "Acerca de", 62 | "settings": "Ajustes", 63 | "query": "Consulta", 64 | "limit": "Limite", 65 | "user": "Usuario", 66 | "from": "Desde", 67 | "to": "A", 68 | "sort": "Ordenar por", 69 | "status": "Estado", 70 | "anonymous": "Notas anónimas", 71 | "area": "Área de búsqueda", 72 | "commented": "Notas comentadas", 73 | "author": "Autor" 74 | }, 75 | "description": { 76 | "share": "Copia este enlace para compartir la consulta actual:", 77 | "startQuery": "Iniciar consulta directamente después de abrir el enlace", 78 | "shareView": "Compartir la vista actual del mapa", 79 | "showList": "Mostrar en una lista", 80 | "showMap": "Mostrar en un mapa", 81 | "nothingFound": "¡Nada encontrado!", 82 | "autoLimit": "Se ha establecido automáticamente el límite en 250, no se permiten valores mayores", 83 | "deprecationWarning": "El navegador que está utilizando actualmente no es compatible con NotesReview. Considere cambiar a otro navegador para poder usar NotesReview.", 84 | "sort": { 85 | "created": { 86 | "desc": "Más nuevo", 87 | "asc": "Más viejo" 88 | }, 89 | "updated": { 90 | "desc": "Recientemente actualizado", 91 | "asc": "Actualizado menos recientemente" 92 | } 93 | }, 94 | "query": "Una palabra o frase mencionada en la nota", 95 | "status": { 96 | "all": "Todos", 97 | "open": "Abierta", 98 | "closed": "Cerrada" 99 | }, 100 | "anonymous": { 101 | "include": "Incluir", 102 | "hide": "Esconder", 103 | "only": "Sólo" 104 | }, 105 | "statistics": "Encontradas %s notas", 106 | "from": "Fecha (UTC) como límite inferior", 107 | "to": "Fecha (UTC) como límite superior", 108 | "area": { 109 | "global": "Global", 110 | "view": "Vista actual", 111 | "custom": "Área personalizada" 112 | }, 113 | "commented": { 114 | "include": "Incluir", 115 | "hide": "Esconder", 116 | "only": "Sólo" 117 | }, 118 | "author": "El autor es el usuario que creó la nota", 119 | "user": "Un usuario es cualquier usuario que haya dejado un comentario en la nota\n(incluido el autor)", 120 | "customArea": "Seleccione países haciendo clic en ellos o dibuje una forma personalizada usando la barra de herramientas de la izquierda", 121 | "sharePolygon": "Compartir forma de selección de área personalizada" 122 | }, 123 | "settings": { 124 | "choose": "Elegir una opción", 125 | "theme": { 126 | "title": "Seleccionar tema", 127 | "light": "Claro", 128 | "dark": "Oscuro", 129 | "system": "Usar la configuración del sistema" 130 | }, 131 | "tools": { 132 | "title": "Herramientas", 133 | "description": "Selecciona tu herramienta(s) favorita", 134 | "editors": "Editores", 135 | "other": "Otros" 136 | } 137 | }, 138 | "note": { 139 | "anonymous": "anónima", 140 | "comment": "1 comentario", 141 | "comments": "%s comentarios", 142 | "action": { 143 | "opened": "Crear nota", 144 | "closed": "Resolver nota", 145 | "reopened": "Reactivar nota" 146 | }, 147 | "unknown": "Usuario desconocido" 148 | }, 149 | "map": { 150 | "attribution": "© OpenStreetMap contribuidores, © CARTO" 151 | }, 152 | "comments": { 153 | "inputPlaceholder": "Escribe un comentario para el resto de usuarios" 154 | }, 155 | "accessibility": { 156 | "openFaq": "Abrir preguntas frecuentes", 157 | "openSettings": "Abrir ajustes", 158 | "shareQuery": "Compartir la consulta actual", 159 | "filter": "Aplicar un filtro a la selección", 160 | "closeNotification": "Cerrar Notificación" 161 | }, 162 | "mapillary": { 163 | "empty": { 164 | "add": "Añadir fotos de este lugar", 165 | "title": "Todavía no hay fotos de este lugar", 166 | "subtitle": "Tu puede ayudar a otros mapeadores cargando imágenes a nivel de calle en Mapillary." 167 | } 168 | }, 169 | "user": { 170 | "created": "Usuario desde %s" 171 | }, 172 | "about": { 173 | "description": "!HTML! NotesReview es un proyecto de código abierto. Actualmente está utilizando la versión {{version}}. El código fuente esta disponible en Github.", 174 | "contribution": "!HTML! Puede apoyar el proyecto traduciéndolo o informando de errores.", 175 | "author": "!HTML! Hecho con ❤️ por ENT8R", 176 | "donate": "Donar" 177 | }, 178 | "error": { 179 | "login": "Error a iniciar sesión, vuelva a intentarlo más tarde", 180 | "comment": "Error al comentar, vuelve a intentarlo más tarde.", 181 | "queryTimeout": "Tiempo de espera agotado, la consulta es demasiado compleja" 182 | } 183 | } --------------------------------------------------------------------------------