├── front ├── src │ ├── routes │ │ ├── adventures │ │ │ ├── [id] │ │ │ │ ├── +layout.ts │ │ │ │ └── +page.svelte │ │ │ ├── +layout.svelte │ │ │ ├── +page.svelte │ │ │ └── airbnb-import │ │ │ │ └── +page.svelte │ │ ├── +layout.ts │ │ ├── +layout.svelte │ │ ├── profile │ │ │ ├── +layout.svelte │ │ │ ├── security │ │ │ │ └── +page.svelte │ │ │ └── account │ │ │ │ └── +page.svelte │ │ ├── auth │ │ │ ├── register │ │ │ │ └── +page.svelte │ │ │ ├── login │ │ │ │ └── +page.svelte │ │ │ └── reset-password │ │ │ │ └── +page.svelte │ │ └── +page.svelte │ ├── lib │ │ ├── json-ld.ts │ │ ├── store.ts │ │ ├── components │ │ │ ├── map │ │ │ │ ├── MapMarker.svelte │ │ │ │ ├── Map.svelte │ │ │ │ └── ClusterMarker.svelte │ │ │ ├── form │ │ │ │ ├── Rating.svelte │ │ │ │ ├── Input.svelte │ │ │ │ ├── EmojiPicker.svelte │ │ │ │ ├── CategoryPicker.svelte │ │ │ │ └── MarkupEditor.svelte │ │ │ ├── Icon.svelte │ │ │ ├── Toast.svelte │ │ │ ├── Head.svelte │ │ │ ├── organization │ │ │ │ ├── ActivityForm.svelte │ │ │ │ ├── LodgingForm.svelte │ │ │ │ ├── TransportationForm.svelte │ │ │ │ └── Organization.svelte │ │ │ ├── NavBar.svelte │ │ │ ├── visit │ │ │ │ ├── VisitForm.svelte │ │ │ │ └── VisitsDragger.svelte │ │ │ └── AdventureForm.svelte │ │ ├── i18n.ts │ │ ├── utils.ts │ │ └── schemas.ts │ ├── app.html │ ├── hooks.client.ts │ ├── app.css │ └── app.d.ts ├── static │ ├── favicon.ico │ ├── icons │ │ ├── minus.svg │ │ ├── plus.svg │ │ ├── user.svg │ │ ├── hotel.svg │ │ ├── arrow.svg │ │ ├── filter.svg │ │ ├── sort.svg │ │ ├── category.svg │ │ ├── transportations │ │ │ ├── boat.svg │ │ │ ├── bus.svg │ │ │ ├── flight.svg │ │ │ ├── car.svg │ │ │ ├── bike.svg │ │ │ └── train.svg │ │ ├── cost.svg │ │ ├── location.svg │ │ ├── search.svg │ │ ├── edit.svg │ │ ├── flag.svg │ │ ├── airbnb.svg │ │ ├── calendar.svg │ │ ├── locale.svg │ │ └── drag.svg │ ├── logo.svg │ └── lang │ │ ├── en.json │ │ └── fr.json ├── tsconfig.json ├── svelte.config.js ├── vite.config.js ├── biome.json ├── package.json └── script │ ├── translate.ts │ └── checkTranslate.ts ├── page └── favicon.ico ├── .dockerignore ├── docker-compose.yml ├── .gitignore ├── docker-compose.dev.yml ├── .github └── workflows │ ├── check.yml │ ├── page.yml │ └── docker.yml ├── Dockerfile ├── README.md ├── back ├── pb_hooks │ └── main.pb.js └── pb_migrations │ └── 1716411453_collections_snapshot.js └── LICENSE /front/src/routes/adventures/[id]/+layout.ts: -------------------------------------------------------------------------------- 1 | export const prerender = false 2 | -------------------------------------------------------------------------------- /page/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxlerebourg/outpin/HEAD/page/favicon.ico -------------------------------------------------------------------------------- /front/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxlerebourg/outpin/HEAD/front/static/favicon.ico -------------------------------------------------------------------------------- /front/src/routes/adventures/+layout.svelte: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | -------------------------------------------------------------------------------- /front/src/routes/+layout.ts: -------------------------------------------------------------------------------- 1 | export const prerender = true 2 | // export const csr = false 3 | export const ssr = false 4 | export const trailingSlash = 'always' 5 | -------------------------------------------------------------------------------- /front/src/lib/json-ld.ts: -------------------------------------------------------------------------------- 1 | export function serializeSchema(schema: unknown) { 2 | return `` 3 | } 4 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | build 2 | node_modules 3 | .svelte 4 | .svelte-kit 5 | .eslintrc* 6 | .gitignore 7 | .prettierignore 8 | docker-compose* 9 | Dockerfile 10 | downloadImage.js 11 | job.hcl 12 | LICENSE 13 | .env 14 | LICENSE 15 | README.md 16 | .gitlab-ci.yml 17 | job.hcl 18 | -------------------------------------------------------------------------------- /front/static/icons/minus.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /front/static/icons/plus.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /front/static/icons/user.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /front/static/icons/hotel.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /front/static/icons/arrow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /front/src/lib/store.ts: -------------------------------------------------------------------------------- 1 | import { writable } from 'svelte/store' 2 | 3 | export const toast = writable({ 4 | show: false, 5 | message: '', 6 | type: '', 7 | }) 8 | 9 | export const currentUserStore = writable(null) 10 | 11 | export const adventuresStore = writable([]) 12 | 13 | export const categoriesStore = writable([]) 14 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | outpin: 5 | restart: unless-stopped 6 | image: ghcr.io/maxlerebourg/outpin 7 | # command: --http=0.0.0.0:8090 ## To change the host:port 8 | ports: 9 | - 8090:8090 10 | volumes: 11 | - ./data:/pb/pb_data 12 | environment: 13 | - PB_ADMIN_EMAIL=test@test.com 14 | - PB_ADMIN_PASSWORD=12345678 15 | -------------------------------------------------------------------------------- /front/static/icons/filter.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /front/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "checkJs": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "strict": true, 12 | "moduleResolution": "node", 13 | "module": "esnext" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /front/svelte.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('@sveltejs/kit').Config} */ 2 | import adapter from '@sveltejs/adapter-static' 3 | 4 | const config = { 5 | kit: { 6 | adapter: adapter({ 7 | pages: 'build', 8 | assets: 'build', 9 | fallback: '/404.html', 10 | precompress: false, 11 | strict: true, 12 | }), 13 | paths: { 14 | relative: false, 15 | }, 16 | }, 17 | } 18 | 19 | export default config 20 | -------------------------------------------------------------------------------- /front/vite.config.js: -------------------------------------------------------------------------------- 1 | import tailwindcss from '@tailwindcss/vite' 2 | import { sveltekit } from '@sveltejs/kit/vite' 3 | import { enhancedImages } from '@sveltejs/enhanced-img' 4 | import { defineConfig } from 'vite' 5 | 6 | const config = defineConfig({ 7 | plugins: [enhancedImages(), tailwindcss(), sveltekit()], 8 | build: { 9 | sourcemap: false, 10 | assetsInlineLimit: Number.MAX_SAFE_INTEGER, 11 | }, 12 | }) 13 | 14 | export default config 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | .git 3 | .cache 4 | .local 5 | .npm 6 | .env 7 | .ash_history 8 | /docker-compose.override.yml 9 | pbsk.code-workspace 10 | 11 | .DS_Store 12 | node_modules 13 | .svelte-kit 14 | /package 15 | /build 16 | /functions 17 | /static/tailwind.css 18 | /src/lib/sampledata.json 19 | 20 | alcodico.db-* 21 | # Local Netlify folder 22 | .netlify 23 | 24 | .cache 25 | pocketbase 26 | *.zip 27 | pb_data 28 | tmp 29 | 30 | .gitlab-ci.yml 31 | job.hcl 32 | -------------------------------------------------------------------------------- /front/src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | %sveltekit.head% 10 | 11 | 12 |
%sveltekit.body%
13 | 14 | 15 | -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | back: 5 | image: alpine 6 | command: "/app/pb/pocketbase serve --dev --http 0.0.0.0:8090" 7 | volumes: 8 | - ./back:/app/pb 9 | working_dir: /app/pb 10 | environment: 11 | - HOME=/app/pb 12 | - PB_ADMIN_EMAIL=test@test.com 13 | - PB_ADMIN_PASSWORD=12345678 14 | - TRUSTED_HEADER_EMAIL=X-Auth-Request-Email 15 | # - UNIQUE_ACCOUNT=test@test.com 16 | ports: 17 | - 8090:8090 18 | -------------------------------------------------------------------------------- /front/static/icons/sort.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /front/static/icons/category.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /front/static/icons/transportations/boat.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /front/static/icons/cost.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /front/static/icons/transportations/bus.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /front/static/icons/location.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /front/static/icons/transportations/flight.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | -------------------------------------------------------------------------------- /front/static/icons/transportations/car.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /front/static/icons/search.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | -------------------------------------------------------------------------------- /front/src/lib/components/map/MapMarker.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /front/static/icons/edit.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /front/static/icons/flag.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: Lint and Intl Check 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | lint-and-intl-check: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v4 18 | - name: Set up Node.js 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: '22' 22 | - name: Install dependencies 23 | run: npm install 24 | working-directory: ./front 25 | - name: Run lint 26 | run: npm run lint 27 | working-directory: ./front 28 | - name: Run intl check 29 | run: npm run intl:check 30 | working-directory: ./front -------------------------------------------------------------------------------- /front/static/icons/airbnb.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /front/biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", 3 | "vcs": { 4 | "enabled": false, 5 | "clientKind": "git", 6 | "useIgnoreFile": false 7 | }, 8 | "files": { 9 | "ignoreUnknown": false, 10 | "ignore": ["build", ".svelte-kit", "node_modules"] 11 | }, 12 | "formatter": { 13 | "enabled": true, 14 | "indentStyle": "space", 15 | "indentWidth": 2, 16 | "lineEnding": "lf" 17 | }, 18 | "organizeImports": { 19 | "enabled": true 20 | }, 21 | "linter": { 22 | "enabled": true, 23 | "rules": { 24 | "recommended": true 25 | } 26 | }, 27 | "javascript": { 28 | "formatter": { 29 | "quoteStyle": "single", 30 | "trailingCommas": "all", 31 | "semicolons": "asNeeded" 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /.github/workflows/page.yml: -------------------------------------------------------------------------------- 1 | name: Deploy pages 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | permissions: 7 | contents: read 8 | pages: write 9 | id-token: write 10 | 11 | concurrency: 12 | group: "pages" 13 | cancel-in-progress: false 14 | 15 | jobs: 16 | deploy: 17 | environment: 18 | name: github-pages 19 | url: ${{ steps.deployment.outputs.page_url }} 20 | runs-on: ubuntu-latest 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@v4 24 | - name: Setup Pages 25 | uses: actions/configure-pages@v5 26 | - name: Upload artifact 27 | uses: actions/upload-pages-artifact@v3 28 | with: 29 | path: './page' 30 | - name: Deploy to GitHub Pages 31 | id: deployment 32 | uses: actions/deploy-pages@v4 33 | -------------------------------------------------------------------------------- /front/static/logo.svg: -------------------------------------------------------------------------------- 1 | 3 | 5 | 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine AS builder 2 | 3 | ARG PB_VERSION=0.28.2 4 | ARG TARGETARCH 5 | 6 | RUN apk add --no-cache \ 7 | unzip \ 8 | ca-certificates 9 | 10 | ADD https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/pocketbase_${PB_VERSION}_linux_${TARGETARCH}.zip /tmp/pb.zip 11 | RUN unzip /tmp/pb.zip -d /pb/ 12 | 13 | 14 | FROM node:lts-alpine AS builder-front 15 | 16 | ENV PUBLIC_POCKETBASE_URL="/" 17 | COPY front /front 18 | WORKDIR /front 19 | RUN npm i 20 | RUN npm run build 21 | 22 | 23 | FROM alpine 24 | 25 | RUN mkdir /pb 26 | COPY --from=builder-front /front/build /pb/pb_public 27 | COPY --from=builder /pb/pocketbase /pb/pocketbase 28 | COPY ./back/pb_migrations /pb/pb_migrations 29 | COPY ./back/pb_hooks /pb/pb_hooks 30 | 31 | ENTRYPOINT ["/pb/pocketbase", "serve"] 32 | CMD ["--http=0.0.0.0:8090"] 33 | -------------------------------------------------------------------------------- /front/src/lib/components/form/Rating.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 |
16 | {#if value > 0 && !disabled} 17 | 24 | {/if} 25 |
26 | {#each [1, 2, 3, 4, 5] as rate} 27 | (value = rate)} 32 | checked={value === rate} 33 | /> 34 | {/each} 35 |
36 |
37 | -------------------------------------------------------------------------------- /front/src/hooks.client.ts: -------------------------------------------------------------------------------- 1 | import { pb } from '$lib/api' 2 | import { currentUserStore } from '$lib/store' 3 | import { Locale, localeStore } from '$lib/i18n' 4 | 5 | const lang = window.navigator.language as Locale 6 | const desiredLang = (document.cookie 7 | .split(';') 8 | .filter((c) => /lang=/.test(c))?.[0] 9 | ?.split('=')[1] 10 | ?.trim() ?? lang) as Locale 11 | if (Locale[desiredLang] && desiredLang !== Locale.en) 12 | localeStore.set(desiredLang) 13 | 14 | pb.authStore.loadFromCookie(document.cookie) 15 | pb.authStore.onChange(() => { 16 | currentUserStore.set( 17 | pb.authStore.record 18 | ? { 19 | ...pb.authStore.record, 20 | email: pb.authStore.record.email, 21 | username: pb.authStore.record.name, 22 | } 23 | : null, 24 | ) 25 | document.cookie = pb.authStore.exportToCookie({ httpOnly: false }) 26 | }, true) 27 | 28 | export const handleError = () => undefined 29 | export const init = () => undefined 30 | -------------------------------------------------------------------------------- /front/src/lib/components/form/Input.svelte: -------------------------------------------------------------------------------- 1 | 25 | 26 |
27 | 38 | {#if errors} 39 | {#each errors as error} 40 | 43 | {/each} 44 | {/if} 45 |
46 | -------------------------------------------------------------------------------- /front/src/routes/+layout.svelte: -------------------------------------------------------------------------------- 1 | 36 | 37 |
38 | 39 | {@render children?.()} 40 |
-------------------------------------------------------------------------------- /front/src/app.css: -------------------------------------------------------------------------------- 1 | @import url("https://fonts.googleapis.com/css2?family=Montez&display=swap"); 2 | @import "tailwindcss"; 3 | @plugin "daisyui"; 4 | 5 | @theme { 6 | --font-cookie: "Montez", sans-serif; 7 | } 8 | 9 | :root { 10 | --color-primary: oklch(87% 0.15 154.449); 11 | --color-primary-content: oklch(26% 0.065 152.934); 12 | --color-secondary: oklch(80.53% 0.1109 19.78); 13 | --color-secondary-content: oklch(26% 0.065 152.934); 14 | --radius-field: 1rem; 15 | --radius-box: 1rem; 16 | } 17 | 18 | html { 19 | @apply bg-base-200 text-base-content; 20 | } 21 | 22 | h1 { 23 | @apply font-cookie; 24 | } 25 | 26 | .marked h1 { 27 | @apply text-4xl font-sans; 28 | } 29 | .marked h2 { 30 | @apply text-3xl; 31 | } 32 | .marked h3 { 33 | @apply text-2xl; 34 | } 35 | .marked h4 { 36 | @apply text-xl; 37 | } 38 | .marked h5 { 39 | @apply text-lg; 40 | } 41 | 42 | .marked a { 43 | @apply text-accent underline; 44 | } 45 | 46 | .marked ul { 47 | @apply list-disc pl-6; 48 | } 49 | .marked ol { 50 | @apply list-decimal pl-6; 51 | } 52 | -------------------------------------------------------------------------------- /front/static/icons/calendar.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /front/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "outpin", 3 | "private": true, 4 | "version": "0.0.1", 5 | "type": "module", 6 | "engines": { 7 | "node": ">=18" 8 | }, 9 | "scripts": { 10 | "dev": "vite dev --port=5173", 11 | "build": "vite build", 12 | "serve": "vite preview --port=5173", 13 | "intl": "npx --yes tsx ./script/translate.ts --default-lang=en", 14 | "intl:check": "npm run intl && npx --yes tsx ./script/checkTranslate.ts --origin-lang=en", 15 | "lint": "npx --yes @biomejs/biome format .", 16 | "lint:fix": "npx --yes @biomejs/biome format --write ." 17 | }, 18 | "devDependencies": { 19 | "@sveltejs/adapter-static": "^3.0.8", 20 | "@sveltejs/enhanced-img": "^0.6.0", 21 | "@sveltejs/kit": "^2.21.1", 22 | "@tailwindcss/typography": "^0.5.16", 23 | "@tailwindcss/vite": "^4.1.8", 24 | "daisyui": "^5.0.43", 25 | "dompurify": "^3.2.6", 26 | "marked": "^15.0.12", 27 | "pocketbase": "^0.26.0", 28 | "svelte": "^5.33.11", 29 | "svelte-maplibre": "^1.0.0", 30 | "tailwindcss": "^4.1.8", 31 | "vite": "^6.3.5", 32 | "zod": "^3.25.46" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /front/src/lib/components/Icon.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | {#each Object.entries(imageModules) as [_path, module]} 14 | {#if _path.includes(`${icon}.svg`)} 15 | {#if onClick !== undefined} 16 | 29 | {:else} 30 | 39 | {/if} 40 | {/if} 41 | {/each} 42 | -------------------------------------------------------------------------------- /front/static/icons/locale.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /front/src/routes/profile/+layout.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 |
10 |
11 | 35 |
36 | {@render children?.()} 37 |
38 |
39 |
40 | -------------------------------------------------------------------------------- /front/static/icons/transportations/bike.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /front/src/lib/components/Toast.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | {#if show} 11 |
16 |
17 |
18 |
19 | {#if type === 'success'} 20 | 26 | 32 | 33 | {:else} 34 | icon 35 | {/if} 36 |
37 |
{message}
38 |
39 |
40 |
41 | {/if} 42 | -------------------------------------------------------------------------------- /front/src/lib/i18n.ts: -------------------------------------------------------------------------------- 1 | import { derived, writable } from 'svelte/store' 2 | 3 | export enum Locale { 4 | en = 'en', 5 | fr = 'fr', 6 | } 7 | 8 | export let localeStore = writable(Locale.en) 9 | let translations = writable>({}) 10 | 11 | localeStore.subscribe(async ($locale) => { 12 | translations.set( 13 | (await (await fetch(`/lang/${$locale}.json`)).json()) as Record< 14 | string, 15 | string 16 | >, 17 | ) 18 | document.documentElement.setAttribute('lang', $locale) 19 | document.cookie = `lang=${$locale};path=/` 20 | }) 21 | 22 | export const t = derived( 23 | [localeStore, translations], 24 | ([$locale, $translations]) => 25 | ( 26 | key: string, 27 | options: { 28 | values?: Record 29 | defaultValue: string 30 | }, 31 | ) => { 32 | let text = $translations[key] 33 | if (!text) { 34 | console.warn(`no translation found for ${$locale}.${key}`) 35 | if (options.defaultValue) return options.defaultValue 36 | throw new Error(`no translation found for ${$locale}.${key}`) 37 | } 38 | 39 | // Replace any passed in variables in the translation string. 40 | Object.keys(options.values ?? {}).map((k) => { 41 | const regex = new RegExp(`{${k}}`, 'g') 42 | text = text.replace(regex, (options.values ?? {})[k]) 43 | }) 44 | 45 | return text 46 | }, 47 | ) 48 | -------------------------------------------------------------------------------- /front/static/icons/drag.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /front/src/lib/components/Head.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | {title} 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 42 | 43 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: docker-ci 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*.*.*' 7 | 8 | env: 9 | REGISTRY: ghcr.io 10 | DOCKER_BUILDKIT: 1 11 | 12 | jobs: 13 | build-and-push-image: 14 | runs-on: ubuntu-latest 15 | 16 | permissions: 17 | contents: read 18 | packages: write 19 | 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@v4 23 | - name: Set up QEMU 24 | uses: docker/setup-qemu-action@v3 25 | - name: Set up Docker Buildx 26 | uses: docker/setup-buildx-action@v3 27 | - name: Login to registry 28 | uses: docker/login-action@v3 29 | with: 30 | registry: ${{ env.REGISTRY }} 31 | username: ${{ github.actor }} 32 | password: ${{ secrets.GITHUB_TOKEN }} 33 | - name: Docker meta 34 | id: docker_meta 35 | uses: docker/metadata-action@v5 36 | with: 37 | images: | 38 | ${{ env.REGISTRY }}/${{ github.repository }} 39 | tags: | 40 | type=semver,pattern=v{{version}} 41 | type=semver,pattern=v{{major}}.{{minor}} 42 | type=sha 43 | - name: Build and push image 44 | uses: docker/build-push-action@v5 45 | with: 46 | context: . 47 | platforms: linux/amd64, linux/arm64 48 | push: true 49 | provenance: false 50 | tags: ${{ steps.docker_meta.outputs.tags }} 51 | labels: ${{ steps.docker_meta.outputs.labels }} 52 | -------------------------------------------------------------------------------- /front/src/lib/components/map/Map.svelte: -------------------------------------------------------------------------------- 1 | 41 | 42 | 49 | 50 | 51 | {#if marker} 52 | 53 | onMarkerChange(marker.lngLat)} /> 54 | 55 | {/if} 56 | 57 | -------------------------------------------------------------------------------- /front/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { PUBLIC_POCKETBASE_URL } from '$env/static/public' 3 | 4 | export function getImageURL( 5 | collectionId: unknown, 6 | recordId: unknown, 7 | fileName: unknown, 8 | size = '0x0', 9 | ) { 10 | return `${PUBLIC_POCKETBASE_URL}/api/files/${collectionId}/${recordId}/${fileName}?thumb=${size}` 11 | } 12 | 13 | export function toSlug(str: string) { 14 | return str 15 | .toString() 16 | .normalize('NFD') // split an accented letter in the base letter and the acent 17 | .replace(/[\u0300-\u036f]/g, '') // remove all previously split accents 18 | .toLowerCase() 19 | .trim() 20 | .replace(/[^a-z0-9 ]/g, '') // remove all chars not letters, numbers and spaces (to be replaced) 21 | .replace(/\s+/g, '-') // separator 22 | } 23 | 24 | export function clickOutside(element: Element, callback: () => void) { 25 | function onClick(event: Event) { 26 | if (event?.target && !element.contains(event.target as Node)) { 27 | callback() 28 | } 29 | } 30 | document.body.addEventListener('click', onClick) 31 | return { 32 | update: (newCallback: () => void) => (callback = newCallback), 33 | destroy: () => document.body.removeEventListener('click', onClick), 34 | } 35 | } 36 | 37 | export function formatDate(str: string) { 38 | return new Date(str).toLocaleDateString(undefined, { 39 | year: '2-digit', 40 | month: 'numeric', 41 | day: 'numeric', 42 | }) 43 | } 44 | 45 | export function formatDatetime(str: string) { 46 | return new Date(str).toLocaleString(undefined, { 47 | year: '2-digit', 48 | month: 'numeric', 49 | day: 'numeric', 50 | hour: 'numeric', 51 | minute: '2-digit', 52 | }) 53 | } 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OutPin 2 | 3 | OutPin is a history of all your trips deployable as a Go program thanks to [PocketBase](https://pocketbase.io). 4 | OutPin offers a way to plan your future holidays by creating adventures and adding steps to them. 5 | 6 | 7 | ## Installation 8 | 9 | Run the Docker image provided `ghcr.io/maxlerebourg/outpin`. 10 | 11 | The default port is 8090, but it can be overridden using the Docker CMD argument with `--http=http://0.0.0.0:9999` 12 | 13 | All data is stored in the `/pb/pb_data` folder. 14 | 15 | Don’t forget to use PocketBase’s built-in backup system or implement your own backup solution. 16 | 17 | 18 | ## [Demo](https://outpin.lerebourg.eu) 19 | 20 | Email verification has been disabled, you can use any email address 21 | 22 | The search and the pin placement can be down if some individuals use all my free quotas from [Nominatim](https://nominatim.openstreetmap.org). 23 | 24 | The map from [Carto](https://carto.com) can be impossible to display for the same reason. 25 | 26 | 27 | ## Env 28 | 29 | ``` 30 | PB_ADMIN_EMAIL - Your superuser PocketBase email. 31 | PB_ADMIN_PASSWORD - Your superuser PocketBase password. 32 | TRUSTED_HEADER_EMAIL - The given header on `/api/self` to signin/signup account (Remove this header if it doesn't come from your setup). 33 | UNIQUE_ACCOUNT - Set an email to disable login/register and use the email's account for everyone. 34 | ``` 35 | 36 | 37 | ## Thanks 38 | 39 | Thanks to all the wonderful project that save me a lot of time: 40 | - [SQLite](https://www.sqlite.org) 41 | - [PocketBase](https://pocketbase.io) 42 | - [Tailwind](https://tailwindcss.com) 43 | - [DaisyUI](https://daisyui.com) 44 | - [SvelteKit](https://svelte.dev) 45 | - [MapLibre](https://maplibre.org) 46 | 47 | 48 | ### About 49 | 50 | I wanted to create a Go alternative to [AdventureLog](https://adventurelog.app) because I dislike Python for no particular reason and I needed a goal to use [PocketBase](https://pocketbase.io). 51 | At first, I wanted to use the same interface/API as AdventureLog, but as my needs evolved, I decided to take a different approach. 52 | As a traveler, I find it hard to remember all the trips I’ve taken, so I created an app to help me keep track of them. 53 | -------------------------------------------------------------------------------- /front/src/routes/profile/security/+page.svelte: -------------------------------------------------------------------------------- 1 | 34 | 35 |
36 |
{$t('profile.security.title', { defaultValue: 'Change Password' })}
37 |
41 | 49 | 57 | 64 |
65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /front/src/routes/auth/register/+page.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 |
{ 15 | evt.preventDefault() 16 | loading = true 17 | try { 18 | await Api.register(auth) 19 | await Api.login(auth) 20 | goto('/') 21 | } catch (err) { 22 | if (err instanceof FormError) errors = err.errors as Auth 23 | } 24 | loading = false 25 | }} 26 | > 27 |
28 |

{$t('register.title', { defaultValue: "Register"})}

29 |

30 | {$t('register.subtitle-1', { defaultValue: "Or"})} 31 | 32 | {$t('register.subtitle-2', { defaultValue: "login"})} 33 | 34 | {$t('register.subtitle-3', { defaultValue: "if you have an account."})} 35 |

36 |
37 |
38 | 45 | 52 |
53 |
54 | {$t('register.alert', { defaultValue: 'Your password will be encrypted for your safety, it will not be readable by anyone.'})} 55 |
56 |
57 | 58 | 65 |
66 |
67 | -------------------------------------------------------------------------------- /front/src/routes/auth/login/+page.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 |
{ 15 | evt.preventDefault() 16 | loading = true 17 | try { 18 | await Api.login(auth) 19 | goto('/') 20 | } catch (err) { 21 | if (err instanceof FormError) errors = err.errors as Auth 22 | } 23 | loading = false 24 | }} 25 | > 26 |
27 |

{$t('login.title', { defaultValue: 'Login' })}

28 |

29 | {$t('login.subtitle-1', { defaultValue: 'Or' })} 30 | 31 | {$t('login.subtitle-2', { defaultValue: 'register' })} 32 | 33 | {$t('login.subtitle-3', { defaultValue: 'to create an account.' })} 34 |

35 |
36 | 37 |
38 | 46 | 54 | 62 | 69 |
70 |
71 | -------------------------------------------------------------------------------- /front/src/routes/auth/reset-password/+page.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 |
{ 15 | evt.preventDefault() 16 | loading = true 17 | try { 18 | await Api.forgotPassword(auth) 19 | success = true 20 | } catch (err) { 21 | if (err instanceof FormError) errors = err.errors as Auth 22 | } 23 | loading = false 24 | }} 25 | > 26 |
27 |

{$t('reset-password.title', { defaultValue: "Reset password"})}

28 | 29 |

30 | {$t('reset-password.request-password-reset', { defaultValue: "Request a password reset link to be e-mailed to you."})} 31 |

32 |
33 |
34 | 41 |
42 | 49 | 50 | {#if success} 51 |
52 |
53 | 65 | {$t('reset-password.success-alert', { defaultValue: "An email has been sent to reset your password!"})} 66 |
67 |
68 | {/if} 69 |
70 | -------------------------------------------------------------------------------- /front/static/icons/transportations/train.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /front/script/translate.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs' 2 | import * as path from 'path' 3 | 4 | function readDirectoryRecursive(directory: string): string[] { 5 | let files: string[] = [] 6 | const items = fs.readdirSync(directory) 7 | 8 | items.forEach((item: string) => { 9 | const fullPath = path.join(directory, item) 10 | if (fs.statSync(fullPath).isDirectory()) { 11 | files = files.concat(readDirectoryRecursive(fullPath)) 12 | } else { 13 | files.push(fullPath) 14 | } 15 | }) 16 | 17 | return files 18 | } 19 | 20 | function extractDefaultValue(jsonString: string): string | null { 21 | const defaultValueMatch = jsonString.match( 22 | /['"`]?defaultValue['"`]?: *['"`](.*)['"`]/, 23 | ) 24 | return defaultValueMatch?.[1] ?? null 25 | } 26 | 27 | function findOccurrencesInFile( 28 | filePath: string, 29 | regex: RegExp, 30 | ): { key: string; defaultValue: any }[] { 31 | const fileContent = fs.readFileSync(filePath, 'utf-8') 32 | const matches = fileContent.matchAll(regex) 33 | const occurrences: { key: string; defaultValue: any }[] = [] 34 | 35 | for (const match of matches) { 36 | const key = match[1] 37 | const jsonString = match[2] 38 | const defaultValue = extractDefaultValue(jsonString) 39 | if (defaultValue !== null) { 40 | occurrences.push({ key, defaultValue }) 41 | } 42 | } 43 | 44 | return occurrences 45 | } 46 | 47 | export default function findTranslations( 48 | defaultLocale: string, 49 | directory: string, 50 | directoryOutput: string, 51 | ) { 52 | const regex = /\$t\(['"`](.*?)['"`], *(\{.*?\})\)/gs 53 | const files = readDirectoryRecursive(directory) 54 | const tsAndSvelteFiles = files.filter( 55 | (file) => file.endsWith('.ts') || file.endsWith('.svelte'), 56 | ) 57 | const result: { [key: string]: any } = {} 58 | 59 | tsAndSvelteFiles.forEach((file) => { 60 | const fileOccurrences = findOccurrencesInFile(file, regex) 61 | fileOccurrences.forEach(({ key, defaultValue }) => { 62 | if (result[key] && result[key] !== defaultValue) 63 | console.warn(`Duplicate key found in ${file}: ${key}`) 64 | result[key] = defaultValue 65 | }) 66 | }) 67 | fs.writeFileSync( 68 | `${directoryOutput}/${defaultLocale}.json`, 69 | JSON.stringify(result, null, 2) + '\n', 70 | ) 71 | } 72 | 73 | const defaultLocale = 74 | process.argv 75 | .find((a: string) => a.startsWith('--default-lang=')) 76 | ?.split('=')[1] ?? 'en' 77 | const directoryPath = `${process.cwd()}/src` 78 | const directoryOutputPath = `${process.cwd()}/static/lang` 79 | 80 | findTranslations(defaultLocale, directoryPath, directoryOutputPath) 81 | -------------------------------------------------------------------------------- /front/script/checkTranslate.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs' 2 | import * as path from 'path' 3 | 4 | function readJsonFile(filePath: string): any { 5 | const data = fs.readFileSync(filePath, 'utf-8') 6 | return JSON.parse(data) 7 | } 8 | 9 | function getJsonKeys(jsonObject: any): Set { 10 | const keys = new Set() 11 | function extractKeys(obj: any, parentKey: string = '') { 12 | if (obj !== null && typeof obj === 'object') { 13 | for (const key in obj) { 14 | if (obj.hasOwnProperty(key)) { 15 | const fullKey = parentKey ? `${parentKey}.${key}` : key 16 | keys.add(fullKey) 17 | extractKeys(obj[key], fullKey) 18 | } 19 | } 20 | } 21 | } 22 | extractKeys(jsonObject) 23 | return keys 24 | } 25 | 26 | function listJsonFiles(directory: string): string[] { 27 | const files = fs.readdirSync(directory) 28 | return files.filter((file: string) => path.extname(file) === '.json') 29 | } 30 | 31 | function checkJsonFiles(directory: string, originalFileName: string) { 32 | const originalFilePath = path.join(directory, originalFileName) 33 | if (!fs.existsSync(originalFilePath)) { 34 | throw new Error(`File ${directory}/${originalFileName} not found`) 35 | } 36 | 37 | const originalJson = readJsonFile(originalFilePath) 38 | const originalKeys = getJsonKeys(originalJson) 39 | 40 | const jsonFiles = listJsonFiles(directory) 41 | let filesNotOk: string[] = [] 42 | jsonFiles.forEach((file) => { 43 | if (file === originalFileName) return 44 | 45 | const filePath = path.join(directory, file) 46 | const json = readJsonFile(filePath) 47 | const keys = getJsonKeys(json) 48 | const missingKeys = [...originalKeys].filter((key) => !keys.has(key)) 49 | 50 | if (missingKeys.length > 0) { 51 | const max = 10 52 | console.log( 53 | `File: ${file} - Missing keys: '${missingKeys.slice(0, max).join("', '")}'${missingKeys.length > max ? ` and ${missingKeys.length - max} others` : ''}`, 54 | ) 55 | filesNotOk.push(file) 56 | } else { 57 | console.log(`File: ${file} - OK`) 58 | } 59 | }) 60 | if (filesNotOk.length !== 0) 61 | throw new Error( 62 | `Files '${filesNotOk.join("', '")}' do not contains same keys as original`, 63 | ) 64 | } 65 | 66 | const directory = `${process.cwd()}/static/lang` 67 | const originLocale = 68 | process.argv 69 | .find((a: string) => a.startsWith('--origin-lang=')) 70 | ?.split('=')[1] ?? 'en' 71 | const originFileName = `${originLocale}.json` 72 | try { 73 | checkJsonFiles(directory, originFileName) 74 | } catch (err) { 75 | console.log(err.message) 76 | process.exit(1) 77 | } 78 | -------------------------------------------------------------------------------- /back/pb_hooks/main.pb.js: -------------------------------------------------------------------------------- 1 | // Extending PocketBase with JS - @see https://pocketbase.io/docs/js-overview/ 2 | 3 | /// 4 | 5 | routerUse((c) => { 6 | try { 7 | c.next() 8 | } catch (err) { 9 | if (err.value?.status == 404) { 10 | c.fileFS($os.dirFS('/pb/pb_static'), '404.html') 11 | return 12 | } 13 | throw err // rethrow 14 | } 15 | }) 16 | 17 | routerAdd( 18 | "GET", 19 | "/api/self", 20 | (c) => { 21 | let email = $os.getenv('UNIQUE_ACCOUNT') 22 | if (!email) { 23 | const trustedHeaderEmail = $os.getenv('TRUSTED_HEADER_EMAIL') 24 | email = c.request.header.get(trustedHeaderEmail ?? '') 25 | } 26 | if (!email) return c.json(200, {}) 27 | 28 | let user 29 | try { 30 | user = $app.findAuthRecordByEmail('users', email) 31 | } catch(err) { 32 | console.log('err', err) 33 | } 34 | 35 | if (!user) { 36 | try { 37 | const users = $app.findCollectionByNameOrId('users') 38 | const record = new Record(users) 39 | 40 | record.set('email', email) 41 | record.set('password', $security.randomStringByRegex('[A-Za-z0-9]{15}')) 42 | 43 | $app.save(record) 44 | user = $app.findAuthRecordByEmail('users', email) 45 | } catch (err) { 46 | console.log('err', err) 47 | } 48 | } 49 | if (!user) return c.json(200, {}) 50 | return $apis.recordAuthResponse(c, user) 51 | }, 52 | ) 53 | 54 | routerAdd( 55 | "GET", 56 | "/api/geocoding/reverse", 57 | (c) => { 58 | const lat = c.request.url.query().get('lat') 59 | const lng = c.request.url.query().get('lng') 60 | const url = `https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${lat}&lon=${lng}` 61 | const response = $http.send({ url, headers: { 'Accept-Language': 'en-US' } }) 62 | const data = response.json 63 | // console.log(JSON.stringify(data, null, 1)) 64 | c.json(200, { 65 | city: data.address?.town ?? data.address?.village ?? data.address?.municipality ?? data.address?.city, 66 | state: data.address?.county ?? data.address?.state ?? data.address?.suburb ?? data.address?.province, 67 | postCode: data.address?.postcode, 68 | country: data.address?.country, 69 | latitude: Number(lat), 70 | longitude: Number(lng), 71 | }) 72 | }, 73 | ) 74 | 75 | routerAdd( 76 | "GET", 77 | "/api/geocoding/search", 78 | (c) => { 79 | const q = c.request.url.query().get('q') 80 | const url = `https://nominatim.openstreetmap.org/search?format=jsonv2&addressdetails=1&q=${q}}` 81 | const response = $http.send({ url, headers: { 'Accept-Language': 'en-US' } }) 82 | const data = response.json?.[0] ?? {} 83 | // console.log(JSON.stringify(data, null, 1)) 84 | c.json(200, { 85 | city: data.address?.town ?? data.address?.village ?? data.address?.municipality ?? data.address?.city, 86 | state: data.address?.county ?? data.address?.state ?? data.address?.suburb ?? data.address?.province, 87 | postCode: data.address?.postcode, 88 | country: data.address?.country, 89 | latitude: Number(data.lat), 90 | longitude: Number(data.lon), 91 | }) 92 | }, 93 | ) 94 | -------------------------------------------------------------------------------- /front/src/lib/components/form/EmojiPicker.svelte: -------------------------------------------------------------------------------- 1 | 132 | 133 |
134 | 141 | {#if displayed} 142 |
displayed ? openEmojiPicker() : undefined} 150 | > 151 | 154 |
155 | {#each emojis as emoji} 156 | 162 | {/each} 163 |
164 |
165 | {/if} 166 |
167 | -------------------------------------------------------------------------------- /front/src/app.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace App { 2 | interface Locals { 3 | pb: import('pocketbase').default 4 | user: import('pocketbase').default['authStore']['record'] 5 | } 6 | } 7 | 8 | declare global { 9 | interface User { 10 | id: string 11 | username: string 12 | email: string 13 | } 14 | 15 | interface Adventure { 16 | id: string 17 | user_id: string | null 18 | name: string 19 | day_duration: number | null 20 | start_date: string | null 21 | end_date: string | null 22 | activity_types?: string[] | null 23 | description?: string | null 24 | rating: number | null 25 | category_id: string | null 26 | category?: Category | null 27 | visits?: Visit[] 28 | activities?: Activity[] 29 | lodgings?: Lodging[] 30 | transportations?: Transportation[] 31 | // link?: string | null 32 | // images: { 33 | // id: string 34 | // image: string 35 | // is_primary: boolean 36 | // }[] 37 | // collection?: string | null 38 | // created_at?: string | null 39 | // updated_at?: string | null 40 | // attachments: Array<{ 41 | // id: string 42 | // file: string 43 | // adventure: string 44 | // extension: string 45 | // user_id: string 46 | // name: string 47 | // }> 48 | } 49 | 50 | interface Category { 51 | id: string 52 | name: string | null 53 | display_name: string 54 | icon: string 55 | user_id: string 56 | num_adventures?: number | null 57 | } 58 | 59 | interface Visit { 60 | id: string 61 | adventure_id: string 62 | day_duration: number | null 63 | start_date: string | null 64 | end_date: string | null 65 | notes: string | null 66 | location: string 67 | latitude: number 68 | longitude: number 69 | rating: number | null 70 | order: number 71 | status?: 'past' | 'current' | 'future' 72 | category_id: string | null 73 | category?: Category | null 74 | } 75 | 76 | interface Activity { 77 | id: string 78 | adventure_id: string 79 | name: string 80 | location: string | null 81 | cost: number | null 82 | at: string | null 83 | } 84 | 85 | interface Lodging { 86 | id: string 87 | adventure_id: string 88 | company: string 89 | location: string | null 90 | reservation: string | null 91 | cost: number | null 92 | from_at: string | null 93 | to_at: string | null 94 | } 95 | 96 | interface Transportation { 97 | id: string 98 | adventure_id: string 99 | type: 'car' | 'boat' | 'bike' | 'bus' | 'flight' | 'train' 100 | company: string | null 101 | reservation: string | null 102 | cost: number | null 103 | from: string | null 104 | from_at: string | null 105 | to: string | null 106 | to_at: string | null 107 | } 108 | 109 | interface Address { 110 | city: string 111 | state: string 112 | postCode: string 113 | country: string 114 | latitude: number 115 | longitude: number 116 | } 117 | 118 | interface LngLat { 119 | lat: number 120 | lng: number 121 | } 122 | 123 | interface Auth { 124 | email: string 125 | password: string 126 | } 127 | } 128 | 129 | export {} 130 | -------------------------------------------------------------------------------- /front/src/lib/components/organization/ActivityForm.svelte: -------------------------------------------------------------------------------- 1 | 40 | 41 |
42 |
43 |

44 | {newActivity.id 45 | ? $t('organization.activity-form.title-modify', { defaultValue: 'Modify' }) 46 | : $t('organization.activity-form.title-create', { defaultValue: 'Create' })} 47 | {$t('organization.activity-form.title', { defaultValue: 'activity' })} 48 |

49 | 50 |
51 | 55 | {#if errors?.location}{errors.location}{/if} 56 |
57 | 61 | 65 |
66 | {#if errors?.name}{errors.name}{/if} 67 | {#if errors?.cost}{errors.cost}{/if} 68 | 72 | {#if errors?.at}{errors.at}{/if} 73 | 74 | 83 |
84 | -------------------------------------------------------------------------------- /front/src/routes/profile/account/+page.svelte: -------------------------------------------------------------------------------- 1 | 65 | 66 |
67 |

68 | {$t('profile.account.title-email', { defaultValue: 'Change Email' })} 69 |

70 |
71 | 78 | 85 |
86 |
87 |
88 |
89 |

90 | {$t('profile.account.title-username', { defaultValue: 'Change Username' })} 91 |

92 |
93 | 100 | 107 |
108 |
109 | 110 | 111 | -------------------------------------------------------------------------------- /front/src/lib/components/form/CategoryPicker.svelte: -------------------------------------------------------------------------------- 1 | 50 | 51 | 69 | {#if value === '__add'} 70 |
71 | 81 | 88 |
89 | {/if} 90 | {#if errors?.display_name}{errors.display_name}{/if} 91 | 92 | 117 | -------------------------------------------------------------------------------- /front/src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 | 65 | 66 |
67 |
onAddressSearch(search)} 70 | > 71 | 78 | 90 |
91 |
95 |
96 | (isFormDisplayed = false)} onSearch={onAddressSearch} /> 97 |
98 |
99 | {#await loadMap() then map} 100 | 101 | {/await} 102 |
103 | -------------------------------------------------------------------------------- /front/src/lib/components/organization/LodgingForm.svelte: -------------------------------------------------------------------------------- 1 | 42 | 43 |
44 |
45 |

46 | {newLodging.id 47 | ? $t('organization.lodging-form.title-modify', { defaultValue: 'Modify' }) 48 | : $t('organization.lodging-form.title-create', { defaultValue: 'Create' })} 49 | {$t('organization.lodging-form.title', { defaultValue: 'lodging' })} 50 |

51 | 52 |
53 | 57 | {#if errors?.location}{errors.location}{/if} 58 | 62 | {#if errors?.company}{errors.company}{/if} 63 |
64 | 68 | 72 |
73 | {#if errors?.reservation}{errors.reservation}{/if} 74 | {#if errors?.cost}{errors.cost}{/if} 75 |
76 | 80 | 84 |
85 | {#if errors?.from_at}{errors.from_at}{/if} 86 | {#if errors?.to_at}{errors.to_at}{/if} 87 | 88 | 97 |
98 | -------------------------------------------------------------------------------- /front/src/lib/components/NavBar.svelte: -------------------------------------------------------------------------------- 1 | 40 | 41 |
42 | 99 |
100 | -------------------------------------------------------------------------------- /front/src/lib/components/visit/VisitForm.svelte: -------------------------------------------------------------------------------- 1 | 60 | 61 |
62 |
63 |

64 | {visit.id 65 | ? $t('visit-form.title-modify', { defaultValue: 'Modify' }) 66 | : $t('visit-form.title-create', { defaultValue: 'Add' })} 67 | {$t('visit-form.title', { defaultValue: 'a step' })} 68 |

69 | 70 |
71 | 85 | {#if errors?.location}{errors.location}{/if} 86 | 87 | 111 | {#if errors?.day_duration}{errors.day_duration}{/if} 112 | 117 | {#if errors?.notes}{errors.notes}{/if} 118 |
119 | 131 | 140 |
141 | 142 | -------------------------------------------------------------------------------- /front/src/routes/adventures/[id]/+page.svelte: -------------------------------------------------------------------------------- 1 | 26 | 27 | {#if adventure} 28 |
29 |

30 | {adventure.name} 31 |

32 | {#if adventure.rating} 33 | 39 | {/if} 40 |
41 |
42 | {#if adventure.category} 43 | 44 | {adventure.category.display_name} {adventure.category.icon} 45 | 46 | {/if} 47 | 57 | 61 | {$t('adventures.id.modify', { defaultValue: 'Modify' })} 62 | 63 | 69 |
70 | { await Api.patchAdventure({ id: page.params.id, description }) }} 74 | /> 75 |
76 | {#each adventure?.visits ?? [] as visit} 77 |
78 |
79 |
80 |

{visit.location}

81 | {#if visit.rating} 82 | 83 | {/if} 84 |
85 | {#if visit.category} 86 | 87 | {visit.category.display_name} {visit.category.icon} 88 | 89 | {/if} 90 |

91 | 92 | 93 | {visit.day_duration ?? 0} 94 | {(visit.day_duration ?? 0) < 2 95 | ? $t('adventures.id.day', { defaultValue: 'day' }) 96 | : $t('adventures.id.days', { defaultValue: 'days' })} 97 | 98 | {#if visit.start_date || visit.end_date} 99 | 100 | {visit.start_date 101 | ? new Date(visit.start_date).toLocaleDateString(undefined, { timeZone: 'UTC' }) 102 | : ''} 103 | {visit.end_date && visit.end_date !== visit.start_date 104 | ? `- ${new Date(visit.end_date).toLocaleDateString(undefined, { timeZone: 'UTC' })}` 105 | : ''} 106 | 107 | {/if} 108 |

109 | { await Api.patchVisit({ id: visit.id, notes }) }} 113 | /> 114 |
115 |
116 |
117 |

118 | {$t('adventures.id.latitude', { defaultValue: 'Latitude' })} 119 |
120 | {visit.latitude?.toFixed(6)}° N 121 |

122 |

123 | {$t('adventures.id.longitude', { defaultValue: 'Longitude' })} 124 |
125 | {visit.longitude?.toFixed(6)}° W 126 |

127 |
128 |
129 | {#await loadMapMarker() then mapMarker} 130 | 131 | {/await} 132 |
133 |
134 |
135 | {/each} 136 |
137 | 138 | 139 | {/if} 140 | -------------------------------------------------------------------------------- /front/src/lib/components/map/ClusterMarker.svelte: -------------------------------------------------------------------------------- 1 | 85 | 86 | 87 | 88 | 93 | 94 | 99 | 100 | {#snippet children({ feature })} 101 |
102 | {feature.properties?.point_count ?? 0} 103 | 104 | 105 | 106 |
107 | {/snippet} 108 |
109 | 110 | 111 | {#snippet children({ feature })} 112 | {@const props = feature.properties} 113 | {@const adventure = JSON.parse(props?.adventure) ?? {}} 114 | {@const visit = JSON.parse(props?.visit) ?? {}} 115 |
116 | {visit.order + 1} 117 | {visit.category?.icon || adventure.category?.icon || ''} 118 |
119 | 120 |

121 | {`${visit.location?.slice(0,20)}${(visit.location?.length ?? 0) > 20 ? '...' : ''}`} 122 |

123 | {#if visit.rating} 124 | 125 | {/if} 126 |

127 | {#if visit.status === 'past'} 128 | {$t('cluster-marker.past', { defaultValue: 'visited' })} 129 | {:else if visit.status === 'current'} 130 | {$t('cluster-marker.current', { defaultValue: 'currently' })} 131 | {:else} 132 | {$t('cluster-marker.future', { defaultValue: 'planned' })} 133 | {/if} 134 |

135 | {#if visit.start_date || visit.end_date} 136 |

137 | {visit.start_date 138 | ? formatDate(visit.start_date) 139 | : ''} 140 | {visit.end_date && visit.end_date !== visit.start_date 141 | ? ` - ${formatDate(visit.end_date)}` 142 | : ''} 143 |

144 | {/if} 145 | 146 | {$t('cluster-marker.submit-details', { defaultValue: 'details' })} 147 | 148 |
149 | {/snippet} 150 |
151 |
152 | -------------------------------------------------------------------------------- /front/src/lib/components/organization/TransportationForm.svelte: -------------------------------------------------------------------------------- 1 | 49 | 50 |
51 |
52 |

53 | {newTransportation.id 54 | ? $t('organization.transportation-form.title-modify', { defaultValue: 'Modify' }) 55 | : $t('organization.transportation-form.title-create', { defaultValue: 'Create' })} 56 | {$t('organization.transportation-form.title', { defaultValue: 'transportation' })} 57 |

58 | 59 |
60 | 83 | 87 |
88 | 92 | 96 |
97 | {#if errors?.company}{errors.company}{/if} 98 | {#if errors?.cost}{errors.cost}{/if} 99 |
100 | 108 | 111 |
112 | {#if errors?.from}{errors.from}{/if} 113 | {#if errors?.from_at}{errors.from_at}{/if} 114 |
115 | 123 | 126 |
127 | {#if errors?.to}{errors.to}{/if} 128 | {#if errors?.to_at}{errors.to_at}{/if} 129 | 130 | 139 |
140 | -------------------------------------------------------------------------------- /front/src/lib/components/AdventureForm.svelte: -------------------------------------------------------------------------------- 1 | 111 | 112 |
113 |
114 |

{$t('adventure-form.title', { defaultValue: 'Adventure' })}

115 | 116 |
117 | {#if errors?.error}{errors.error}{/if} 118 |
119 | 130 | {#if errors?.name}{errors.name}{/if} 131 | 140 | 141 | 146 |
147 | 156 |
157 | 158 | {#if newAdventure.id} 159 | 165 |
166 | 174 |
175 | {/if} 176 |
177 | -------------------------------------------------------------------------------- /front/src/lib/components/visit/VisitsDragger.svelte: -------------------------------------------------------------------------------- 1 | 104 | 105 |

{$t('visits-dragger.title', { defaultValue: 'Visits' })}

106 |
    107 | {#each newAdventure.visits as visit, i (visit.id)} 108 |
  • dragStart(i)} 112 | ondrop={() => dragDrop(i)} 113 | ondragover={(evt: Event) => dragOver(evt, i)} 114 | style="transition: background 100ms" 115 | class="w-full rounded" 116 | > 117 | {#if i !== 0}
    {/if} 118 |
    119 | 120 |
    121 |
    122 |
    123 |
    124 |

    125 | + {visit.day_duration ?? 0} 126 | {(visit.day_duration ?? 0) < 2 127 | ? $t('visits-dragger.night', { defaultValue: 'night' }) 128 | : $t('visits-dragger.nights', { defaultValue: 'nights' })} 129 |

    130 |

    {visit.location}

    131 |
    132 |
    133 | 145 | 157 |
    158 |
    159 | {#if modifiedVisit && modifiedVisit.id === visit.id} 160 |
    161 | 162 |
    163 | {/if} 164 |
    165 |
    166 |
  • 167 | {/each} 168 | {#if !modifiedVisit || modifiedVisit.id === ''} 169 |
  • 170 |
    171 |
    172 |
    173 | {#if modifiedVisit?.id === ''} 174 | 175 | {:else} 176 | 184 | {/if} 185 |
    186 |
  • 187 | {/if} 188 |
189 | -------------------------------------------------------------------------------- /front/src/routes/adventures/+page.svelte: -------------------------------------------------------------------------------- 1 | 25 | 26 |

{$t('adventures.title', { defaultValue: 'My adventures' })}

27 |
28 |
29 | 46 |
47 |
48 | 62 |
63 |
64 | 65 |
66 |
67 | {#if adventures.length > 0} 68 |
69 | {#each adventures 70 | .filter((a) => 71 | (!filters.category_id || filters.category_id === a.category_id) && ( 72 | (filters.is_visited === 'ALL') 73 | || (filters.is_visited === 'VISITED' && (!a.start_date || a.start_date < now)) 74 | || (filters.is_visited === 'PLANED' && (a.start_date && a.start_date > now)) 75 | ) 76 | ) 77 | .sort((a, b) => { 78 | switch(order) { 79 | case 'DATE_DESC': 80 | return (a.start_date || '1900') < (b.start_date || '1900') ? 1 : -1 81 | case 'DATE_ASC': 82 | return (a.start_date || '2999') > (b.start_date || '2999') ? 1 : -1 83 | case 'RATE_DESC': 84 | return (b.rating || 0) - (a.rating || 0) 85 | case 'RATE_ASC': 86 | return (a.rating || 6) - (b.rating || 6) 87 | } 88 | }) as adventure(adventure.id)} 89 |
90 |
91 | 95 | {adventure.name} 96 | 97 | {#if adventure.rating} 98 | 99 | {/if} 100 |
101 |
102 | {#if adventure.category} 103 |
104 | {adventure.category.display_name} {adventure.category.icon} 105 |
106 | {/if} 107 | 114 | 118 | {$t('adventures.modify', { defaultValue: 'Modify' })} 119 | 120 |
121 |

122 | 123 | 124 | {adventure.day_duration ?? 0} 125 | {(adventure.day_duration ?? 0) < 2 126 | ? $t('adventures.day', { defaultValue: 'day' }) 127 | : $t('adventures.days', { defaultValue: 'days' })} 128 | 129 | {#if adventure.start_date} 130 | 131 | {adventure.start_date 132 | ? new Date(adventure.start_date).toLocaleDateString(undefined, { 133 | timeZone: 'UTC', 134 | }) 135 | : ''} 136 | {adventure.end_date && adventure.end_date !== adventure.start_date 137 | ? `- ${new Date(adventure.end_date).toLocaleDateString(undefined, { timeZone: 'UTC' })}` 138 | : ''} 139 | 140 | {/if} 141 |

142 |
143 | {#each adventure.visits ?? [] as visit} 144 |

• {visit.location} {visit.category?.icon}

145 | {/each} 146 |
147 |
148 | {/each} 149 |
150 | {:else} 151 |

152 | {$t('adventures.no-adventure-1', { defaultValue: 'You have not adventure, create one' })} 153 | {$t('adventures.no-adventure-2', { defaultValue: 'here' })} 154 |

155 | {/if} 156 | 157 | {$t('adventures.airbnb-import', { defaultValue: 'Import from Airbnb (RGPD export)' })} 158 | 159 | -------------------------------------------------------------------------------- /front/src/lib/schemas.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | const zDate = () => 4 | z 5 | .string() 6 | .transform((str: string) => 7 | str !== '' ? new Date(str).toISOString().replace('T', ' ') : null, 8 | ) 9 | 10 | const zCategoryId = () => 11 | z 12 | .string() 13 | .min(1) 14 | .max(200) 15 | .transform((str) => (str === '__add' ? undefined : str)) 16 | 17 | export const loginUserSchema = z.object({ 18 | email: z 19 | .string({ required_error: 'Email is required' }) 20 | .email({ message: 'Email must be a valid email.' }), 21 | password: z.string({ required_error: 'Password is required' }), 22 | }) 23 | 24 | export const registerUserSchema = z.object({ 25 | email: z 26 | .string({ required_error: 'Email is required' }) 27 | .email({ message: 'Email must be a valid email' }), 28 | password: z 29 | .string({ required_error: 'Password is required' }) 30 | .regex(/^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*#?&])[A-Za-z\d@$!%*#?&]{8,}$/, { 31 | message: 32 | 'Password must be a minimum of 8 characters & contain at least one letter, one number, and one special character', 33 | }), 34 | }) 35 | 36 | export const patchEmailSchema = z.object({ 37 | email: z 38 | .string({ required_error: 'Email is required' }) 39 | .email({ message: 'Email must be a valid email' }), 40 | }) 41 | 42 | export const patchUsernameSchema = z.object({ 43 | username: z 44 | .string({ required_error: 'Username is required' }) 45 | .min(3, { message: 'Username must be at least 3 characters' }) 46 | .max(24, { message: 'Username must be 24 characters or less' }) 47 | .regex(/^[a-zA-Z0-9]*$/, { 48 | message: 'Username can only contain letters or numbers', 49 | }), 50 | }) 51 | 52 | export const patchPasswordSchema = z.object({ 53 | oldPassword: z.string({ required_error: 'Old password is required' }), 54 | password: z 55 | .string({ required_error: 'Password is required' }) 56 | .regex(/^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*#?&])[A-Za-z\d@$!%*#?&]{8,}$/, { 57 | message: 58 | 'Password must be a minimum of 8 characters & contain at least one letter, one number, and one special character', 59 | }), 60 | }) 61 | 62 | export const postCategorySchema = z.object({ 63 | display_name: z 64 | .string({ required_error: 'Name is required' }) 65 | .min(3) 66 | .max(200), 67 | icon: z.string().min(1).max(4).nullable(), 68 | }) 69 | 70 | export const postAdventureSchema = z.object({ 71 | name: z.string({ required_error: 'Name is required' }).min(3).max(200), 72 | description: z.string().nullable(), 73 | start_date: zDate().nullable(), 74 | rating: z.number().min(0).max(5).nullable(), 75 | category_id: zCategoryId().nullable(), 76 | }) 77 | 78 | export const patchAdventureSchema = z.object({ 79 | id: z.string().min(1).max(200), 80 | name: z 81 | .string({ required_error: 'Name is required' }) 82 | .min(3) 83 | .max(200) 84 | .optional(), 85 | start_date: zDate().nullable().optional(), 86 | description: z.string().nullable().optional(), 87 | rating: z.number().min(0).max(5).nullable().optional(), 88 | category_id: zCategoryId().nullable().optional(), 89 | }) 90 | 91 | export const postVisitSchema = z.object({ 92 | adventure_id: z.string().min(1).max(200), 93 | category_id: zCategoryId().nullable(), 94 | day_duration: z.number().min(0).nullable(), 95 | notes: z.string().min(3).nullable(), 96 | latitude: z.number(), 97 | longitude: z.number(), 98 | location: z 99 | .string({ required_error: 'Location is required' }) 100 | .min(3) 101 | .max(200), 102 | rating: z.number().min(0).max(5).nullable(), 103 | order: z.number().min(0).max(100), 104 | }) 105 | 106 | export const patchVisitSchema = z.object({ 107 | id: z.string().min(1).max(200), 108 | adventure_id: z.string().min(1).max(200).optional(), 109 | category_id: zCategoryId().nullable().optional(), 110 | day_duration: z.number().min(0).nullable().optional(), 111 | notes: z.string().min(3).nullable().optional(), 112 | latitude: z.number().optional(), 113 | longitude: z.number().optional(), 114 | location: z 115 | .string({ required_error: 'Location is required' }) 116 | .min(3) 117 | .max(200) 118 | .optional(), 119 | rating: z.number().min(0).max(5).nullable().optional(), 120 | order: z.number().min(0).max(100).optional(), 121 | }) 122 | 123 | export const postActivitySchema = z.object({ 124 | name: z.string().min(1).max(200), 125 | adventure_id: z.string().min(1).max(200), 126 | location: z.string().nullable(), 127 | cost: z.number().min(0).nullable(), 128 | at: zDate().nullable(), 129 | }) 130 | 131 | export const patchActivitySchema = z.object({ 132 | id: z.string().min(1).max(200), 133 | adventure_id: z.string().min(1).max(200), 134 | name: z.string().max(200).nullable().optional(), 135 | location: z.string().nullable().optional(), 136 | cost: z.number().min(0).nullable().optional(), 137 | at: zDate().nullable().optional(), 138 | }) 139 | 140 | export const postLodgingSchema = z.object({ 141 | adventure_id: z.string().min(1).max(200), 142 | location: z.string().min(1).max(200), 143 | company: z.string().min(1).max(200), 144 | reservation: z.string().max(200).nullable(), 145 | cost: z.number().min(0).nullable(), 146 | from_at: zDate().nullable(), 147 | to_at: zDate().nullable(), 148 | }) 149 | 150 | export const patchLodgingSchema = z.object({ 151 | id: z.string().min(1).max(200), 152 | adventure_id: z.string().min(1).max(200), 153 | location: z.string().max(200).nullable(), 154 | company: z.string().max(200).nullable().optional(), 155 | reservation: z.string().max(200).nullable().optional(), 156 | cost: z.number().min(0).nullable().optional(), 157 | from_at: zDate().nullable().optional(), 158 | to_at: zDate().nullable().optional(), 159 | }) 160 | 161 | export const postTransportationSchema = z.object({ 162 | adventure_id: z.string().min(1).max(200), 163 | company: z.string().max(200).nullable(), 164 | reservation: z.string().max(200).nullable(), 165 | type: z.enum(['car', 'boat', 'bike', 'bus', 'flight', 'train']), 166 | cost: z.number().min(0).nullable(), 167 | from: z.string().max(200).nullable(), 168 | from_at: zDate().nullable(), 169 | to: z.string().max(200).nullable(), 170 | to_at: zDate().nullable(), 171 | }) 172 | 173 | export const patchTransportationSchema = z.object({ 174 | id: z.string().min(1).max(200), 175 | adventure_id: z.string().min(1).max(200), 176 | company: z.string().max(200).nullable().optional(), 177 | reservation: z.string().max(200).nullable().optional(), 178 | type: z.enum(['car', 'boat', 'bike', 'bus', 'flight', 'train']).optional(), 179 | cost: z.number().min(0).nullable().optional(), 180 | from: z.string().max(200).nullable().optional(), 181 | from_at: zDate().nullable().optional(), 182 | to: z.string().max(200).nullable().optional(), 183 | to_at: zDate().nullable().optional(), 184 | }) 185 | -------------------------------------------------------------------------------- /front/static/lang/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "adventure-form.title": "Adventure", 3 | "adventure-form.input-name-label": "My trip in", 4 | "adventure-form.input-name-placeholder": "Name", 5 | "adventure-form.input-start-date-label": "Start at", 6 | "adventure-form.input-description-placeholder": "Add description", 7 | "adventure-form.submit-modify": "Modify", 8 | "adventure-form.submit-create": "Create", 9 | "adventure-form.finish": "Finish", 10 | "category-picker.select-category-null": "No category", 11 | "category-picker.select-category-add": "Add a category", 12 | "category-picker.input-category-placeholder": "Add a category", 13 | "category-picker.submit": "Add", 14 | "emoji-picker.submit-null": "No emoji", 15 | "markup-editor.default-value": "No description", 16 | "cluster-marker.past": "visited", 17 | "cluster-marker.current": "currently", 18 | "cluster-marker.future": "planned", 19 | "cluster-marker.submit-details": "details", 20 | "navbar.login": "login", 21 | "navbar.signup": "signup", 22 | "navbar.adventures": "Adventures", 23 | "navbar.account": "account", 24 | "organization.activity-form.title-modify": "Modify", 25 | "organization.activity-form.title-create": "Create", 26 | "organization.activity-form.title": "activity", 27 | "organization.activity-form.input-location-label": "Location", 28 | "organization.activity-form.input-name-label": "Name", 29 | "organization.activity-form.input-cost-label": "Cost", 30 | "organization.activity-form.input-at-label": "Date", 31 | "organization.activity-form.submit-modify": "Modify", 32 | "organization.activity-form.submit-create": "Create", 33 | "organization.lodging-form.title-modify": "Modify", 34 | "organization.lodging-form.title-create": "Create", 35 | "organization.lodging-form.title": "lodging", 36 | "organization.lodging-form.input-location-label": "Location", 37 | "organization.lodging-form.input-company-label": "Company", 38 | "organization.lodging-form.input-reservation-label": "Reservation", 39 | "organization.lodging-form.input-cost-label": "Cost", 40 | "organization.lodging-form.input-from-at-label": "From", 41 | "organization.lodging-form.input-to-at-label": "To", 42 | "organization.lodging-form.submit-modify": "Modify", 43 | "organization.lodging-form.submit-create": "Create", 44 | "organization.organization.title": "Organization", 45 | "organization.organization.activities": "Activities", 46 | "organization.organization.transportations": "Transportations", 47 | "organization.organization.lodgings": "Lodgings", 48 | "organization.transportation-form.title-modify": "Modify", 49 | "organization.transportation-form.title-create": "Create", 50 | "organization.transportation-form.title": "transportation", 51 | "organization.transportation-form.input-type-label": "Type", 52 | "organization.transportation-form.input-type-car-option": "Car", 53 | "organization.transportation-form.input-type-boat-option": "Boat", 54 | "organization.transportation-form.input-type-bike-option": "Bike", 55 | "organization.transportation-form.input-type-bus-option": "Bus", 56 | "organization.transportation-form.input-type-flight-option": "Flight", 57 | "organization.transportation-form.input-type-train-option": "Train", 58 | "organization.transportation-form.input-company-label": "Company", 59 | "organization.transportation-form.input-reservation-label": "Reservation", 60 | "organization.transportation-form.input-cost-label": "Cost", 61 | "organization.transportation-form.input-from-label": "From", 62 | "organization.transportation-form.input-to-placeholder": "City", 63 | "organization.transportation-form.input-to-label": "To", 64 | "organization.transportation-form.submit-modify": "Modify", 65 | "organization.transportation-form.submit-create": "Create", 66 | "visit-form.title-modify": "Modify", 67 | "visit-form.title-create": "Add", 68 | "visit-form.title": "a step", 69 | "visit-form.input-location-label": "Step at", 70 | "visit-form.input-location-placeholder": "Click on the map", 71 | "visit-form.input-day-duration-label": "Nights", 72 | "visit-form.input-notes-placeholder": "Add notes", 73 | "visit-form.submit-close": "Close", 74 | "visit-form.submit-modify": "Modify", 75 | "visit-form.submit-create": "Create", 76 | "visits-dragger.title": "Visits", 77 | "visits-dragger.night": "night", 78 | "visits-dragger.nights": "nights", 79 | "visits-dragger.submit-new": "New step", 80 | "index.input-search-placeholder": "Search a location", 81 | "adventures.title": "My adventures", 82 | "adventures.input-order-label-1": "Newest first", 83 | "adventures.input-order-label-2": "Oldest first", 84 | "adventures.input-order-label-3": "Best rate first", 85 | "adventures.input-order-label-4": "Lowest rate first", 86 | "adventures.input-is-visited-label-1": "All", 87 | "adventures.input-is-visited-label-2": "Visited", 88 | "adventures.input-is-visited-label-3": "Planed", 89 | "adventures.delete": "Delete", 90 | "adventures.modify": "Modify", 91 | "adventures.day": "day", 92 | "adventures.days": "days", 93 | "adventures.no-adventure-1": "You have not adventure, create one", 94 | "adventures.no-adventure-2": "here", 95 | "adventures.airbnb-import": "Import from Airbnb (RGPD export)", 96 | "adventures.aibnb-import.title": "Airbnb import", 97 | "adventures.aibnb-import.input-json-label": "Search history from Airbnb (JSON: json/search_history.json)", 98 | "adventures.aibnb-import.data-disclaimer": "No data will be sent to any server, the compute will happen in this browser.", 99 | "adventures.aibnb-import.submit": "Compute", 100 | "adventures.aibnb-import.subtitle": "Add an adventure", 101 | "adventures.aibnb-import.input-name-placeholder": "Name", 102 | "adventures.id.delete": "Delete", 103 | "adventures.id.modify": "Modify", 104 | "adventures.id.edit": "Edit", 105 | "adventures.id.day": "day", 106 | "adventures.id.days": "days", 107 | "adventures.id.latitude": "Latitude", 108 | "adventures.id.longitude": "Longitude", 109 | "login.title": "Login", 110 | "login.subtitle-1": "Or", 111 | "login.subtitle-2": "register", 112 | "login.subtitle-3": "to create an account.", 113 | "login.input-email-placeholder": "Email", 114 | "login.input-password-placeholder": "Password", 115 | "login.forgot-password": "Forgot Password?", 116 | "login.submit": "login", 117 | "register.title": "Register", 118 | "register.subtitle-1": "Or", 119 | "register.subtitle-2": "login", 120 | "register.subtitle-3": "if you have an account.", 121 | "register.input-email": "Email", 122 | "register.input-password": "Password", 123 | "register.alert": "Your password will be encrypted for your safety, it will not be readable by anyone.", 124 | "register.submit": "register", 125 | "reset-password.title": "Reset password", 126 | "reset-password.request-password-reset": "Request a password reset link to be e-mailed to you.", 127 | "reset-password.input-email-placeholder": "Email", 128 | "reset-password.submit": "send", 129 | "reset-password.success-alert": "An email has been sent to reset your password!", 130 | "profile.layout.account": "Account", 131 | "profile.layout.security": "Security", 132 | "profile.layout.submit-logout": "Logout", 133 | "profile.account.toast": "Profile updated successfully", 134 | "profile.account.title-email": "Change Email", 135 | "profile.account.submit": "save", 136 | "profile.account.title-username": "Change Username", 137 | "profile.security.toast": "Password updated successfully", 138 | "profile.security.title": "Change Password", 139 | "profile.security.input-old-password-placeholder": "Old Password", 140 | "profile.security.input-password-placeholder": "Password", 141 | "profile.security.submit": "save" 142 | } 143 | -------------------------------------------------------------------------------- /front/static/lang/fr.json: -------------------------------------------------------------------------------- 1 | { 2 | "adventure-form.title": "Aventure", 3 | "adventure-form.input-name-label": "Mon voyage à", 4 | "adventure-form.input-name-placeholder": "Nom", 5 | "adventure-form.input-start-date-label": "Commence le", 6 | "adventure-form.input-description-placeholder": "Ajouter une description", 7 | "adventure-form.submit-modify": "Modifier", 8 | "adventure-form.submit-create": "Créer", 9 | "adventure-form.finish": "Terminer", 10 | "category-picker.select-category-null": "Pas de catégorie", 11 | "category-picker.select-category-add": "Ajouter une catégorie", 12 | "category-picker.input-category-placeholder": "Ajouter une catégorie", 13 | "category-picker.submit": "Ajouter", 14 | "emoji-picker.submit-null": "Pas d'emoji", 15 | "markup-editor.default-value": "Pas de description", 16 | "cluster-marker.past": "visité", 17 | "cluster-marker.current": "actuellement", 18 | "cluster-marker.future": "planifié", 19 | "cluster-marker.submit-details": "détails", 20 | "navbar.login": "connexion", 21 | "navbar.signup": "inscription", 22 | "navbar.adventures": "Aventures", 23 | "navbar.account": "compte", 24 | "visit-form.title-modify": "Modifier", 25 | "visit-form.title-create": "Ajouter", 26 | "visit-form.title": "une étape", 27 | "visit-form.input-location-label": "Étape à", 28 | "visit-form.input-location-placeholder": "Cliquez sur la carte", 29 | "visit-form.input-day-duration-label": "Nuits", 30 | "visit-form.input-notes-placeholder": "Ajouter des notes", 31 | "visit-form.submit-close": "Fermer", 32 | "visit-form.submit-modify": "Modifier", 33 | "visit-form.submit-create": "Créer", 34 | "visits-dragger.title": "Visites", 35 | "visits-dragger.night": "nuit", 36 | "visits-dragger.nights": "nuits", 37 | "visits-dragger.submit-new": "Nouvelle étape", 38 | "index.input-search-placeholder": "Rechercher un lieu", 39 | "adventures.title": "Mes aventures", 40 | "adventures.input-order-label-1": "Les plus récentes d'abord", 41 | "adventures.input-order-label-2": "Les plus anciennes d'abord", 42 | "adventures.input-order-label-3": "Les mieux notées d'abord", 43 | "adventures.input-order-label-4": "Les moins bien notées d'abord", 44 | "adventures.input-is-visited-label-1": "Toutes", 45 | "adventures.input-is-visited-label-2": "Visitées", 46 | "adventures.input-is-visited-label-3": "Planifiées", 47 | "adventures.delete": "Supprimer", 48 | "adventures.modify": "Modifier", 49 | "adventures.day": "jour", 50 | "adventures.days": "jours", 51 | "adventures.no-adventure-1": "Vous n'avez pas d'aventure, créez-en une", 52 | "adventures.no-adventure-2": "ici", 53 | "adventures.airbnb-import": "Importer depuis Airbnb (export RGPD)", 54 | "adventures.aibnb-import.title": "Importation Airbnb", 55 | "adventures.aibnb-import.input-json-label": "Historique de recherche depuis Airbnb (JSON: json/search_history.json)", 56 | "adventures.aibnb-import.data-disclaimer": "Aucune donnée ne sera envoyée à un serveur, le calcul se fera dans ce navigateur.", 57 | "adventures.aibnb-import.submit": "Calculer", 58 | "adventures.aibnb-import.subtitle": "Ajouter une aventure", 59 | "adventures.aibnb-import.input-name-placeholder": "Nom", 60 | "adventures.id.delete": "Supprimer", 61 | "adventures.id.modify": "Modifier", 62 | "adventures.id.day": "jour", 63 | "adventures.id.days": "jours", 64 | "adventures.id.latitude": "Latitude", 65 | "adventures.id.longitude": "Longitude", 66 | "login.title": "Connexion", 67 | "login.subtitle-1": "Ou", 68 | "login.subtitle-2": "inscrivez-vous", 69 | "login.subtitle-3": "pour créer un compte.", 70 | "login.input-email-placeholder": "Email", 71 | "login.input-password-placeholder": "Mot de passe", 72 | "login.forgot-password": "Mot de passe oublié ?", 73 | "login.submit": "connexion", 74 | "register.title": "Inscription", 75 | "register.subtitle-1": "Ou", 76 | "register.subtitle-2": "connectez-vous", 77 | "register.subtitle-3": "si vous avez un compte.", 78 | "register.input-email": "Email", 79 | "register.input-password": "Mot de passe", 80 | "register.alert": "Votre mot de passe sera chiffré pour votre sécurité, il ne sera lisible par personne.", 81 | "register.submit": "inscription", 82 | "reset-password.title": "Réinitialiser le mot de passe", 83 | "reset-password.request-password-reset": "Demandez un lien de réinitialisation de mot de passe à vous envoyer par e-mail.", 84 | "reset-password.input-email-placeholder": "Email", 85 | "reset-password.submit": "envoyer", 86 | "reset-password.success-alert": "Un e-mail a été envoyé pour réinitialiser votre mot de passe !", 87 | "profile.layout.account": "Compte", 88 | "profile.layout.security": "Sécurité", 89 | "profile.layout.submit-logout": "Déconnexion", 90 | "profile.account.toast": "Profil mis à jour avec succès", 91 | "profile.account.title-email": "Changer l'Email", 92 | "profile.account.submit": "sauvegarder", 93 | "profile.account.title-username": "Changer le Nom d'utilisateur", 94 | "profile.security.toast": "Mot de passe mis à jour avec succès", 95 | "profile.security.title": "Changer le Mot de passe", 96 | "profile.security.input-old-password-placeholder": "Ancien Mot de passe", 97 | "profile.security.input-password-placeholder": "Mot de passe", 98 | "profile.security.submit": "sauvegarder", 99 | "organization.activity-form.title-modify": "Modifier", 100 | "organization.activity-form.title-create": "Créer", 101 | "organization.activity-form.title": "activité", 102 | "organization.activity-form.input-location-label": "Lieu", 103 | "organization.activity-form.input-name-label": "Nom", 104 | "organization.activity-form.input-cost-label": "Coût", 105 | "organization.activity-form.input-at-label": "Date", 106 | "organization.activity-form.submit-modify": "Modifier", 107 | "organization.activity-form.submit-create": "Créer", 108 | "organization.lodging-form.title-modify": "Modifier", 109 | "organization.lodging-form.title-create": "Créer", 110 | "organization.lodging-form.title": "hébergement", 111 | "organization.lodging-form.input-location-label": "Lieu", 112 | "organization.lodging-form.input-company-label": "Entreprise", 113 | "organization.lodging-form.input-reservation-label": "Réservation", 114 | "organization.lodging-form.input-cost-label": "Coût", 115 | "organization.lodging-form.input-from-at-label": "Du", 116 | "organization.lodging-form.input-to-at-label": "Au", 117 | "organization.lodging-form.submit-modify": "Modifier", 118 | "organization.lodging-form.submit-create": "Créer", 119 | "organization.organization.title": "Organisation", 120 | "organization.organization.activities": "Activités", 121 | "organization.organization.transportations": "Transports", 122 | "organization.organization.lodgings": "Hébergements", 123 | "organization.transportation-form.title-modify": "Modifier", 124 | "organization.transportation-form.title-create": "Créer", 125 | "organization.transportation-form.title": "transport", 126 | "organization.transportation-form.input-type-label": "Type", 127 | "organization.transportation-form.input-type-car-option": "Voiture", 128 | "organization.transportation-form.input-type-boat-option": "Bateau", 129 | "organization.transportation-form.input-type-bike-option": "Vélo", 130 | "organization.transportation-form.input-type-bus-option": "Bus", 131 | "organization.transportation-form.input-type-flight-option": "Vol", 132 | "organization.transportation-form.input-type-train-option": "Train", 133 | "organization.transportation-form.input-company-label": "Entreprise", 134 | "organization.transportation-form.input-reservation-label": "Réservation", 135 | "organization.transportation-form.input-cost-label": "Coût", 136 | "organization.transportation-form.input-from-label": "De", 137 | "organization.transportation-form.input-to-placeholder": "Ville", 138 | "organization.transportation-form.input-to-label": "À", 139 | "organization.transportation-form.submit-modify": "Modifier", 140 | "organization.transportation-form.submit-create": "Créer", 141 | "adventures.id.edit": "Editer" 142 | } 143 | -------------------------------------------------------------------------------- /front/src/routes/adventures/airbnb-import/+page.svelte: -------------------------------------------------------------------------------- 1 | 114 | 115 |

116 | {$t('adventures.aibnb-import.title', { defaultValue: 'Airbnb import' })} 117 |

118 | {#if Object.keys(visits).length === 0} 119 | 122 | 127 | {#if errors?.error}{errors.error}{/if} 128 |
129 |
130 | {$t('adventures.aibnb-import.data-disclaimer', { defaultValue: 'No data will be sent to any server, the compute will happen in this browser.' })} 131 |
132 |
133 | 136 | {:else} 137 |
138 |
139 |
140 |

{$t('adventures.aibnb-import.subtitle', { defaultValue: 'Add an adventure' })}

141 | {#if errors?.error}{errors.error}{/if} 142 | 152 | {#if errors?.name}{errors.name}{/if} 153 | {#if newAdventure.visitIds.length > 0} 154 |
    155 | {#each newAdventure?.visitIds ?? [] as visitId, i} 156 |
  • 157 | {#if i !== 0}
    {/if} 158 |
    159 | 160 |
    161 |
    162 |
    163 |
    164 | {#if visits[visitId].start_date || visits[visitId].end_date} 165 | 173 | {/if} 174 |

    {visits[visitId].location}

    175 |
    176 |
    177 |
    178 | {#if i !== newAdventure.visitIds.length - 1}
    {/if} 179 |
  • 180 | {/each} 181 |
182 | {/if} 183 |
184 | 191 |
192 |
193 |
194 |
195 | {#each Object.entries(visits) as [key, visit]} 196 | 212 | {/each} 213 |
214 |
215 | {/if} 216 | -------------------------------------------------------------------------------- /front/src/lib/components/organization/Organization.svelte: -------------------------------------------------------------------------------- 1 | 30 | 31 | {#if adventure} 32 |

33 | {$t('organization.organization.title', { defaultValue: 'Organization' })} 34 |

35 |
36 |
37 | 38 |
39 | {$t('organization.organization.activities', { defaultValue: 'Activities' })} 40 |
41 |
42 | {#if isEditable} 43 | {#if newActivity !== null} 44 | (newActivity = null)} /> 45 | {:else} 46 | 47 | {/if} 48 | {/if} 49 |
50 | {#each adventure.activities ?? [] as activity} 51 |
52 | {#if isEditable} 53 | 61 | 69 | {/if} 70 |

71 | {#if activity.at}{/if} 72 | {#if activity.location} 73 | 74 | 75 | {activity.location} 76 | 77 | {/if} 78 | {#if activity.name}{activity.name}{/if} 79 | {#if activity.cost} 80 | 81 | {activity.cost} 82 | {/if} 83 |

84 |
85 | {/each} 86 |
87 |
88 |
89 |
90 | 91 |
92 | {$t('organization.organization.transportations', { defaultValue: 'Transportations' })} 93 |
94 |
95 | {#if isEditable} 96 | {#if newTransportation !== null} 97 | (newTransportation = null)} /> 98 | {:else} 99 | 100 | {/if} 101 | {/if} 102 | 103 |
104 | {#each adventure.transportations ?? [] as transportation} 105 |
106 | {#if isEditable} 107 | 115 | 123 | {/if} 124 | 125 | 126 |

127 | {#if transportation.from}{transportation.from}{/if} 128 | {#if transportation.from_at}{/if} 129 | {#if transportation.to_at}- {/if} 130 | {#if transportation.to}{transportation.to}{/if} 131 | {#if transportation.from || transportation.from_at || transportation.to || transportation.to_at} 132 | | 133 | {/if} 134 | {#if transportation.company}{transportation.company}{/if} 135 | {#if transportation.reservation}({transportation.reservation}){/if} 136 | {#if transportation.cost} 137 | 138 | {transportation.cost} 139 | {/if} 140 |

141 |
142 | {/each} 143 |
144 |
145 |
146 |
147 | 148 |
149 | {$t('organization.organization.lodgings', { defaultValue: 'Lodgings' })} 150 |
151 |
152 | {#if isEditable} 153 | {#if newLodging !== null} 154 | (newLodging = null)} /> 155 | {:else} 156 | 157 | {/if} 158 | {/if} 159 |
160 | {#each adventure.lodgings ?? [] as lodging} 161 |
162 | {#if isEditable} 163 | 171 | 179 | {/if} 180 | 181 | {#if lodging.company?.toLowerCase()?.includes('airbnb')} 182 | 183 | {:else} 184 | 185 | {/if} 186 |

187 | {#if lodging.from_at}{/if} 188 | {#if lodging.to_at}- {/if} 189 | {#if lodging.location} 190 | 191 | 192 | {lodging.location} 193 | 194 | {/if} 195 | {#if lodging.company}{lodging.company}{/if} 196 | {#if lodging.reservation}({lodging.reservation}){/if} 197 | {#if lodging.cost} 198 | 199 | {lodging.cost} 200 | {/if} 201 |

202 |
203 | {/each} 204 |
205 |
206 |
207 |
208 | {/if} 209 | -------------------------------------------------------------------------------- /front/src/lib/components/form/MarkupEditor.svelte: -------------------------------------------------------------------------------- 1 | 193 | 194 |
195 | {#if isEditable} 196 | 273 | 274 | {#if !showPreview} 275 | 287 | {:else if contentValue === DEFAULT_VALUE} 288 |
289 | {DEFAULT_VALUE} 290 |
291 | {:else} 292 |
293 | {@html markup} 294 |
295 | {/if} 296 | {:else} 297 |
298 | {@html markup} 299 |
300 | {/if} 301 |
302 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /back/pb_migrations/1716411453_collections_snapshot.js: -------------------------------------------------------------------------------- 1 | migrate((app) => { 2 | const collections = [ 3 | { 4 | "fields": [ 5 | { 6 | "autogeneratePattern": "[a-z0-9]{15}", 7 | "hidden": false, 8 | "id": "text3208210256", 9 | "max": 15, 10 | "min": 15, 11 | "name": "id", 12 | "pattern": "^[a-z0-9]+$", 13 | "presentable": false, 14 | "primaryKey": true, 15 | "required": true, 16 | "system": true, 17 | "type": "text" 18 | }, 19 | { 20 | "autogeneratePattern": "", 21 | "hidden": false, 22 | "id": "text1579384326", 23 | "max": 250, 24 | "min": 0, 25 | "name": "name", 26 | "pattern": "", 27 | "presentable": false, 28 | "primaryKey": false, 29 | "required": true, 30 | "system": false, 31 | "type": "text" 32 | }, 33 | { 34 | "autogeneratePattern": "", 35 | "hidden": false, 36 | "id": "text3578368839", 37 | "max": 250, 38 | "min": 0, 39 | "name": "display_name", 40 | "pattern": "", 41 | "presentable": false, 42 | "primaryKey": false, 43 | "required": false, 44 | "system": false, 45 | "type": "text" 46 | }, 47 | { 48 | "autogeneratePattern": "", 49 | "hidden": false, 50 | "id": "text1704208859", 51 | "max": 4, 52 | "min": 0, 53 | "name": "icon", 54 | "pattern": "", 55 | "presentable": false, 56 | "primaryKey": false, 57 | "required": false, 58 | "system": false, 59 | "type": "text" 60 | }, 61 | { 62 | "cascadeDelete": true, 63 | "collectionId": "_pb_users_auth_", 64 | "hidden": false, 65 | "id": "relation2809058197", 66 | "maxSelect": 1, 67 | "minSelect": 0, 68 | "name": "user_id", 69 | "presentable": false, 70 | "required": true, 71 | "system": false, 72 | "type": "relation" 73 | }, 74 | { 75 | "hidden": false, 76 | "id": "autodate2990389176", 77 | "name": "created", 78 | "onCreate": true, 79 | "onUpdate": false, 80 | "presentable": false, 81 | "system": false, 82 | "type": "autodate" 83 | }, 84 | { 85 | "hidden": false, 86 | "id": "autodate3332085495", 87 | "name": "updated", 88 | "onCreate": true, 89 | "onUpdate": true, 90 | "presentable": false, 91 | "system": false, 92 | "type": "autodate" 93 | } 94 | ], 95 | "id": "pbc_3292755704", 96 | "indexes": [], 97 | "name": "categories", 98 | "system": false, 99 | "type": "base", 100 | "createRule": "null != @request.auth.id", 101 | "deleteRule": "user_id = @request.auth.id", 102 | "listRule": "user_id = @request.auth.id", 103 | "updateRule": "user_id = @request.auth.id", 104 | "viewRule": "user_id = @request.auth.id" 105 | }, 106 | { 107 | "fields": [ 108 | { 109 | "autogeneratePattern": "[a-z0-9]{15}", 110 | "hidden": false, 111 | "id": "text3208210256", 112 | "max": 15, 113 | "min": 15, 114 | "name": "id", 115 | "pattern": "^[a-z0-9]+$", 116 | "presentable": false, 117 | "primaryKey": true, 118 | "required": true, 119 | "system": true, 120 | "type": "text" 121 | }, 122 | { 123 | "autogeneratePattern": "", 124 | "hidden": false, 125 | "id": "text1579384326", 126 | "max": 250, 127 | "min": 0, 128 | "name": "name", 129 | "pattern": "", 130 | "presentable": false, 131 | "primaryKey": false, 132 | "required": true, 133 | "system": false, 134 | "type": "text" 135 | }, 136 | { 137 | "autogeneratePattern": "", 138 | "hidden": false, 139 | "id": "text1843675174", 140 | "max": 0, 141 | "min": 0, 142 | "name": "description", 143 | "pattern": "", 144 | "presentable": false, 145 | "primaryKey": false, 146 | "required": false, 147 | "system": false, 148 | "type": "text" 149 | }, 150 | { 151 | "hidden": false, 152 | "id": "number3632866850", 153 | "max": 5, 154 | "min": 0, 155 | "name": "rating", 156 | "onlyInt": true, 157 | "presentable": false, 158 | "required": false, 159 | "system": false, 160 | "type": "number" 161 | }, 162 | { 163 | "hidden": false, 164 | "id": "date2502384312", 165 | "max": "", 166 | "min": "", 167 | "name": "start_date", 168 | "presentable": false, 169 | "required": false, 170 | "system": false, 171 | "type": "date" 172 | }, 173 | { 174 | "cascadeDelete": true, 175 | "collectionId": "_pb_users_auth_", 176 | "hidden": false, 177 | "id": "relation2809058197", 178 | "maxSelect": 999, 179 | "minSelect": 0, 180 | "name": "user_id", 181 | "presentable": false, 182 | "required": true, 183 | "system": false, 184 | "type": "relation" 185 | }, 186 | { 187 | "cascadeDelete": false, 188 | "collectionId": "pbc_3292755704", 189 | "hidden": false, 190 | "id": "relation306617826", 191 | "maxSelect": 1, 192 | "minSelect": 0, 193 | "name": "category_id", 194 | "presentable": false, 195 | "required": false, 196 | "system": false, 197 | "type": "relation" 198 | }, 199 | { 200 | "hidden": false, 201 | "id": "autodate2990389176", 202 | "name": "created", 203 | "onCreate": true, 204 | "onUpdate": false, 205 | "presentable": false, 206 | "system": false, 207 | "type": "autodate" 208 | }, 209 | { 210 | "hidden": false, 211 | "id": "autodate3332085495", 212 | "name": "updated", 213 | "onCreate": true, 214 | "onUpdate": true, 215 | "presentable": false, 216 | "system": false, 217 | "type": "autodate" 218 | } 219 | ], 220 | "id": "pbc_1973380996", 221 | "indexes": [], 222 | "name": "adventures", 223 | "system": false, 224 | "type": "base", 225 | "createRule": "null != @request.auth.id", 226 | "deleteRule": "user_id.id = @request.auth.id", 227 | "listRule": "user_id.id = @request.auth.id", 228 | "updateRule": "user_id.id = @request.auth.id", 229 | "viewRule": "user_id.id = @request.auth.id" 230 | }, 231 | { 232 | "fields": [ 233 | { 234 | "autogeneratePattern": "[a-z0-9]{15}", 235 | "hidden": false, 236 | "id": "text3208210256", 237 | "max": 15, 238 | "min": 15, 239 | "name": "id", 240 | "pattern": "^[a-z0-9]+$", 241 | "presentable": false, 242 | "primaryKey": true, 243 | "required": true, 244 | "system": true, 245 | "type": "text" 246 | }, 247 | { 248 | "autogeneratePattern": "", 249 | "hidden": false, 250 | "id": "text18589324", 251 | "max": 0, 252 | "min": 0, 253 | "name": "notes", 254 | "pattern": "", 255 | "presentable": false, 256 | "primaryKey": false, 257 | "required": false, 258 | "system": false, 259 | "type": "text" 260 | }, 261 | { 262 | "autogeneratePattern": "", 263 | "hidden": false, 264 | "id": "text1587448267", 265 | "max": 250, 266 | "min": 0, 267 | "name": "location", 268 | "pattern": "", 269 | "presentable": false, 270 | "primaryKey": false, 271 | "required": true, 272 | "system": false, 273 | "type": "text" 274 | }, 275 | { 276 | "hidden": false, 277 | "id": "number1092145443", 278 | "max": null, 279 | "min": null, 280 | "name": "latitude", 281 | "onlyInt": false, 282 | "presentable": false, 283 | "required": false, 284 | "system": false, 285 | "type": "number" 286 | }, 287 | { 288 | "hidden": false, 289 | "id": "number1092145448", 290 | "max": null, 291 | "min": 0, 292 | "name": "day_duration", 293 | "onlyInt": true, 294 | "presentable": false, 295 | "required": false, 296 | "system": false, 297 | "type": "number" 298 | }, 299 | { 300 | "hidden": false, 301 | "id": "number2246143851", 302 | "max": null, 303 | "min": null, 304 | "name": "longitude", 305 | "onlyInt": false, 306 | "presentable": false, 307 | "required": false, 308 | "system": false, 309 | "type": "number" 310 | }, 311 | { 312 | "hidden": false, 313 | "id": "number3632866855", 314 | "max": 5, 315 | "min": 0, 316 | "name": "rating", 317 | "onlyInt": true, 318 | "presentable": false, 319 | "required": false, 320 | "system": false, 321 | "type": "number" 322 | }, 323 | { 324 | "hidden": false, 325 | "id": "number3632866856", 326 | "max": 5, 327 | "min": 0, 328 | "name": "order", 329 | "onlyInt": true, 330 | "presentable": false, 331 | "required": false, 332 | "system": false, 333 | "type": "number" 334 | }, 335 | { 336 | "cascadeDelete": true, 337 | "collectionId": "pbc_1973380996", 338 | "hidden": false, 339 | "id": "relation1439645945", 340 | "maxSelect": 1, 341 | "minSelect": 0, 342 | "name": "adventure_id", 343 | "presentable": false, 344 | "required": false, 345 | "system": false, 346 | "type": "relation" 347 | }, 348 | { 349 | "cascadeDelete": false, 350 | "collectionId": "pbc_3292755704", 351 | "hidden": false, 352 | "id": "relation306617827", 353 | "maxSelect": 1, 354 | "minSelect": 0, 355 | "name": "category_id", 356 | "presentable": false, 357 | "required": false, 358 | "system": false, 359 | "type": "relation" 360 | }, 361 | { 362 | "hidden": false, 363 | "id": "autodate2990389176", 364 | "name": "created", 365 | "onCreate": true, 366 | "onUpdate": false, 367 | "presentable": false, 368 | "system": false, 369 | "type": "autodate" 370 | }, 371 | { 372 | "hidden": false, 373 | "id": "autodate3332085495", 374 | "name": "updated", 375 | "onCreate": true, 376 | "onUpdate": true, 377 | "presentable": false, 378 | "system": false, 379 | "type": "autodate" 380 | } 381 | ], 382 | "id": "pbc_1935361188", 383 | "indexes": [], 384 | "name": "visits", 385 | "system": false, 386 | "type": "base", 387 | "createRule": "adventure_id.user_id.id = @request.auth.id", 388 | "deleteRule": "adventure_id.user_id.id = @request.auth.id", 389 | "listRule": "adventure_id.user_id.id = @request.auth.id", 390 | "updateRule": "adventure_id.user_id.id = @request.auth.id", 391 | "viewRule": "adventure_id.user_id.id = @request.auth.id" 392 | }, 393 | ] 394 | collections.map((item) => app.save(new Collection(item))) 395 | 396 | const superusers = app.findCollectionByNameOrId("_superusers") 397 | const record = new Record(superusers) 398 | 399 | const email = $os.getenv('PB_ADMIN_EMAIL') 400 | const randomEmail = 'admin@admin.com' 401 | const password = $os.getenv('PB_ADMIN_PASSWORD') 402 | const randomPassword = $security.randomStringByRegex('[A-Za-z0-9]{8}') 403 | if (!email) 404 | console.log(`Generated superuser email is ${randomEmail}`) 405 | if (!password) 406 | console.log(`Generated superuser password is: ${randomPassword}`) 407 | 408 | record.set("email", email || randomEmail) 409 | record.set("password", password || randomPassword) 410 | app.save(record) 411 | }, (_) => { }); --------------------------------------------------------------------------------