├── .env.development.local ├── .eslintrc.json ├── .github └── dependabot.yml ├── .gitignore ├── .nvmrc ├── .prettierrc.js ├── .vscode └── launch.json ├── LICENSE ├── README.md ├── app ├── [board_name] │ ├── [layout_id] │ │ ├── [size_id] │ │ │ ├── [set_ids] │ │ │ │ ├── [angle] │ │ │ │ │ ├── layout.tsx │ │ │ │ │ ├── list │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ └── view │ │ │ │ │ │ └── [climb_uuid] │ │ │ │ │ │ └── page.tsx │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ └── page.tsx │ ├── layout.tsx │ └── page.tsx ├── api │ ├── internal │ │ └── shared-sync │ │ │ └── [board_name] │ │ │ └── route.ts │ └── v1 │ │ ├── [board_name] │ │ ├── [layout_id] │ │ │ ├── [size_id] │ │ │ │ ├── [set_ids] │ │ │ │ │ ├── [angle] │ │ │ │ │ │ ├── [climb_uuid] │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── heatmap │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── search │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── details │ │ │ │ │ │ └── route.ts │ │ │ │ └── sets │ │ │ │ │ └── route.ts │ │ │ └── sizes │ │ │ │ └── route.ts │ │ ├── grades │ │ │ └── route.ts │ │ ├── layouts │ │ │ └── route.ts │ │ └── proxy │ │ │ ├── getLogbook │ │ │ └── route.ts │ │ │ ├── login │ │ │ └── route.ts │ │ │ ├── saveAscent │ │ │ └── route.ts │ │ │ └── user-sync │ │ │ └── route.ts │ │ ├── angles │ │ └── [board_name] │ │ │ └── [layout_id] │ │ │ └── route.ts │ │ └── grades │ │ └── [board_name] │ │ └── route.ts ├── components │ ├── App.tsx │ ├── board-bluetooth-control │ │ ├── bluetooth.ts │ │ ├── send-climb-to-board-button.css │ │ ├── send-climb-to-board-button.tsx │ │ └── web-bluetooth-warning.tsx │ ├── board-page │ │ ├── back-to-climb-list-button.tsx │ │ ├── climbs-list.tsx │ │ ├── constants.ts │ │ ├── header.tsx │ │ └── share-button.tsx │ ├── board-provider │ │ ├── board-provider-context.tsx │ │ └── storage.ts │ ├── board-renderer │ │ ├── board-heatmap.tsx │ │ ├── board-litup-holds.tsx │ │ ├── board-renderer.tsx │ │ ├── types.ts │ │ └── util.ts │ ├── climb-card │ │ ├── climb-card-actions.tsx │ │ ├── climb-card-cover.tsx │ │ ├── climb-card-modal.tsx │ │ ├── climb-card.tsx │ │ └── climb-thumbnail.tsx │ ├── climb-info │ │ ├── climb-info-button.tsx │ │ ├── climb-info-drawer.tsx │ │ └── climb-info.tsx │ ├── connection-manager │ │ ├── constants.ts │ │ ├── peer-context.tsx │ │ ├── peer-queue-controller-context.tsx │ │ ├── reducer.tsx │ │ └── types.ts │ ├── index.css │ ├── logbook │ │ ├── logascent-form.tsx │ │ ├── logbook-drawer.tsx │ │ ├── logbook-stats.tsx │ │ ├── logbook-view.tsx │ │ └── tick-button.tsx │ ├── party-manager │ │ └── party-context.tsx │ ├── queue-control │ │ ├── hooks │ │ │ └── use-queue-data-fetching.tsx │ │ ├── idb-queue-context-manager.tsx │ │ ├── next-climb-button.tsx │ │ ├── previous-climb-button.tsx │ │ ├── queue-context.tsx │ │ ├── queue-control-bar.tsx │ │ ├── queue-list-item.tsx │ │ ├── queue-list.tsx │ │ ├── reducer.ts │ │ ├── types.ts │ │ └── ui-searchparams-provider.tsx │ ├── rest-api │ │ ├── api.ts │ │ └── types.ts │ ├── search-drawer │ │ ├── basic-search-form.tsx │ │ ├── clear-button.tsx │ │ ├── climb-hold-search-form.tsx │ │ ├── constants.ts │ │ ├── search-button.tsx │ │ ├── search-climb-name-input.tsx │ │ ├── search-drawer.tsx │ │ ├── search-form.tsx │ │ └── use-heatmap.tsx │ └── setup-wizard │ │ ├── angle-selection.tsx │ │ ├── board-selection.tsx │ │ ├── layout-selection.tsx │ │ ├── sets-selection.tsx │ │ └── size-selection.tsx ├── favicon.ico ├── fonts │ ├── GeistMonoVF.woff │ └── GeistVF.woff ├── layout.tsx ├── lib │ ├── api-wrappers │ │ ├── aurora-rest-client │ │ │ ├── README.md │ │ │ ├── aurora-rest-client.ts │ │ │ └── types.ts │ │ ├── aurora │ │ │ ├── explore.ts │ │ │ ├── getBidsLogbook.ts │ │ │ ├── getClimbName.ts │ │ │ ├── getClimbStats.ts │ │ │ ├── getGrades.ts │ │ │ ├── getGyms.ts │ │ │ ├── saveAscent.ts │ │ │ ├── saveAttempt.ts │ │ │ ├── saveClimb.ts │ │ │ ├── sharedSync.ts │ │ │ ├── types.ts │ │ │ ├── user │ │ │ │ ├── follows-save.ts │ │ │ │ ├── getFollowees.ts │ │ │ │ ├── getLogbook.ts │ │ │ │ └── getUser.ts │ │ │ ├── userSync.ts │ │ │ └── util.ts │ │ └── sync-api-types.ts │ ├── board-data.ts │ ├── data-sync │ │ └── aurora │ │ │ ├── getTableName.ts │ │ │ ├── shared-sync.ts │ │ │ └── user-sync.ts │ ├── data │ │ ├── get-logbook.ts │ │ └── queries.ts │ ├── db │ │ ├── db.ts │ │ ├── queries │ │ │ ├── climbs │ │ │ │ ├── Untitled │ │ │ │ ├── create-climb-filters.ts │ │ │ │ ├── holds-heatmap.ts │ │ │ │ └── search-climbs.ts │ │ │ └── util │ │ │ │ └── table-select.ts │ │ ├── relations.ts │ │ └── schema.ts │ ├── session.ts │ ├── types.ts │ └── url-utils.ts ├── page.tsx ├── robots.ts └── sesh │ └── newstartpage.tsx ├── db ├── README.md ├── cleanup_sqlite_db_problems.sh ├── docker-compose.yml ├── kilter_db.load ├── kilter_table_rename.sql ├── setup-development-db.sh ├── tension_db.load └── tension_table_rename.sql ├── drizzle.config.ts ├── drizzle ├── 0000_cloudy_carlie_cooper.sql ├── 0001_unique_climbstats.sql ├── 0002_unique_climbstats.sql ├── 0003_add_climbs_indexes.sql ├── 0004_fix_circuits.sql ├── 0005_add_pk_to_user_syncs.sql ├── 0006_remove_unnecessary_unique.sql ├── 0007_remove_climbstats_id_column.sql ├── 0008_stats_foreign_keys.sql ├── 0009_nullable_stats.sql ├── 0010_remove_stats_foreignkeys.sql ├── 0011_add_holds_map_table.sql ├── 0012_drop_climb_holds_fk.sql ├── 0013_add_heatmap_indexes.sql ├── README.md └── meta │ ├── 0000_snapshot.json │ ├── 0003_snapshot.json │ ├── 0004_snapshot.json │ ├── 0005_snapshot.json │ ├── 0006_snapshot.json │ ├── 0007_snapshot.json │ ├── 0008_snapshot.json │ ├── 0009_snapshot.json │ ├── 0010_snapshot.json │ ├── 0011_snapshot.json │ ├── 0012_snapshot.json │ ├── 0013_snapshot.json │ └── _journal.json ├── esphome_bluetooth_proxy ├── README.md ├── kilter_board_component.h └── kilterboard.yaml ├── middleware.ts ├── next.config.mjs ├── package-lock.json ├── package.json ├── public ├── images │ ├── kilter │ │ └── product_sizes_layouts_sets │ │ │ ├── 15_5_24.png │ │ │ ├── 36-1.png │ │ │ ├── 38-1.png │ │ │ ├── 39-1.png │ │ │ ├── 41-1.png │ │ │ ├── 45-1.png │ │ │ ├── 46-1.png │ │ │ ├── 47.png │ │ │ ├── 48.png │ │ │ ├── 49.png │ │ │ ├── 50-1.png │ │ │ ├── 51-1.png │ │ │ ├── 53.png │ │ │ ├── 54.png │ │ │ ├── 55-v2.png │ │ │ ├── 56-v3.png │ │ │ ├── 59.png │ │ │ ├── 60-v3.png │ │ │ ├── 61-v3.png │ │ │ ├── 63-v3.png │ │ │ ├── 64-v3.png │ │ │ ├── 65-v2.png │ │ │ ├── 66-v2.png │ │ │ ├── 70-v2.png │ │ │ ├── 71-v3.png │ │ │ ├── 72.png │ │ │ ├── 73.png │ │ │ ├── 77-1.png │ │ │ ├── 78-1.png │ │ │ ├── original-16x12-bolt-ons-v2.png │ │ │ └── original-16x12-screw-ons-v2.png │ └── tension │ │ └── product_sizes_layouts_sets │ │ ├── 1.png │ │ ├── 10.png │ │ ├── 11.png │ │ ├── 12.png │ │ ├── 12x10-tb2-plastic.png │ │ ├── 12x10-tb2-wood.png │ │ ├── 12x12-tb2-plastic.png │ │ ├── 12x12-tb2-wood.png │ │ ├── 13.png │ │ ├── 14.png │ │ ├── 15.png │ │ ├── 16.png │ │ ├── 17.png │ │ ├── 18.png │ │ ├── 19.png │ │ ├── 2.png │ │ ├── 20.png │ │ ├── 21-2.png │ │ ├── 22-2.png │ │ ├── 23.png │ │ ├── 24-2.png │ │ ├── 25.png │ │ ├── 26.png │ │ ├── 27.png │ │ ├── 28.png │ │ ├── 3.png │ │ ├── 4.png │ │ ├── 5.png │ │ ├── 6.png │ │ ├── 7.png │ │ ├── 8.png │ │ ├── 8x10-tb2-plastic.png │ │ ├── 8x10-tb2-wood.png │ │ ├── 8x12-tb2-plastic.png │ │ ├── 8x12-tb2-wood.png │ │ └── 9.png └── index.html ├── scripts ├── download.sh └── get-image-size-json.sh ├── tsconfig.json └── vercel.json /.env.development.local: -------------------------------------------------------------------------------- 1 | # Warning: This file is tracked in git!!! 2 | # So use another .env file if you need to use secrets 3 | # The env variables in here are the default for the checked in 4 | # docker container 5 | VERCEL_ENV=development 6 | POSTGRES_URL=postgresql://postgres:password@localhost:54320/verceldb 7 | BASE_URL=http://localhost:3000 8 | POSTGRES_HOST=localhost 9 | POSTGRES_PORT=54320 10 | POSTGRES_USER=postgres 11 | POSTGRES_PASSWORD=password 12 | POSTGRES_DATABASE=verceldb 13 | IRON_SESSION_PASSWORD={ "1": "68cJgCDE39gaXwi8LTVW4WioyhGxwcAd" } -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "next", 4 | "next/core-web-vitals", 5 | "next/typescript", 6 | "plugin:react/recommended", 7 | "plugin:@typescript-eslint/recommended", 8 | "prettier" 9 | ], 10 | "plugins": ["react", "@typescript-eslint"], 11 | "parserOptions": { 12 | "ecmaVersion": 2021, 13 | "sourceType": "module" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: 'npm' # See documentation for possible values 9 | directory: '/' # Location of package manifests 10 | schedule: 11 | interval: 'weekly' 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | !.env.development.local 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | kilter.db 39 | tension.db 40 | db/tmp 41 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v22.12.0 2 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: true, 3 | trailingComma: 'all', 4 | singleQuote: true, 5 | printWidth: 120, 6 | tabWidth: 2, 7 | plugins: [], 8 | }; 9 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Next.js: debug server-side", 6 | "type": "node-terminal", 7 | "request": "launch", 8 | "command": "npm run dev" 9 | }, 10 | { 11 | "name": "Next.js: debug client-side", 12 | "type": "chrome", 13 | "request": "launch", 14 | "url": "http://localhost:3000" 15 | }, 16 | { 17 | "name": "Next.js: debug client-side (Firefox)", 18 | "type": "firefox", 19 | "request": "launch", 20 | "url": "http://localhost:3000", 21 | "reAttach": true, 22 | "pathMappings": [ 23 | { 24 | "url": "webpack://_N_E", 25 | "path": "${workspaceFolder}" 26 | } 27 | ] 28 | }, 29 | { 30 | "name": "Next.js: debug full stack", 31 | "type": "node", 32 | "request": "launch", 33 | "program": "${workspaceFolder}/node_modules/.bin/next", 34 | "runtimeArgs": ["--inspect"], 35 | "skipFiles": ["/**"], 36 | "serverReadyAction": { 37 | "action": "debugWithEdge", 38 | "killOnServerStop": true, 39 | "pattern": "- Local:.+(https?://.+)", 40 | "uriFormat": "%s", 41 | "webRoot": "${workspaceFolder}" 42 | } 43 | } 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Boardsesh 2 | 3 | [Join us on discord](https://discord.gg/YXA8GsXfQK) 4 | 5 | Boardsesh is an app for controlling a ["standardized interactive climbing training boards" (SICTBs)](https://gearjunkie.com/climbing/kilter-moon-grasshopper-more-interactive-climbing-training-boards-explained) and intends to add missing functionality to boards that utilize Aurora Climbing's software, such as [Kilter](https://settercloset.com/pages/the-kilter-board), 6 | [Tension](https://tensionclimbing.com/product/tension-board-sets/), and [Decoy](https://decoy-holds.com/pages/decoy-board). 7 | 8 | Try it out [here](https://www.boardsesh.com/) 9 | 10 | This app was originally started as a fork of https://github.com/lemeryfertitta/Climbdex. 11 | We also use https://github.com/lemeryfertitta/BoardLib for creating the database. 12 | Many thanks to @lemeryfertitta for making this project possible!! 13 | 14 | ## IOS support 15 | 16 | Unfortunately mobile safari doesn't support web bluetooth. So to use this website on your phone you could install a ios browser that does have web ble support, for example: https://apps.apple.com/us/app/bluefy-web-ble-browser/id1492822055 17 | 18 | Bluefy is what I tested boardsesh in on my iphone and it worked like expected. 19 | 20 | ## Current status 21 | 22 | Basic board use works, and the app already has queue controls, open to feedback and contributions! 23 | Using the share button in the top right corner, users can connect to each other and control the board and queue together. 24 | Similar to Spotify Jams, no more "What climb was that?", "what climb was the last one?", "Mind if I change it?", questions during a sesh 25 | 26 | ## Future features: 27 | 28 | - Faster beta video uploads. Current process for beta videos is manual, and as a result new beta videos are almost never added. We'll implement our own Instagram integration to get beta videos faster. 29 | 30 | # Getting Started 31 | 32 | ## Database setup 33 | 34 | Before we can start developing, we need to setup a database. Start the docker container to startup the development database: 35 | 36 | ``` 37 | cd db/ && docker-compose up 38 | ``` 39 | 40 | This starts up a docker container that uses Boardlib to download the databases and then loads them into postgres with an db update script and pgloader. When the postgres docker container is up, 41 | you can connect to the database on localhost:54320 using `default:password` as the login details. 42 | 43 | ## Setup ENV variables 44 | 45 | Create the following `.env.development.local`: 46 | 47 | ``` 48 | VERCEL_ENV=development 49 | POSTGRES_URL=postgresql://default:password@localhost:54320/verceldb 50 | BASE_URL=http://localhost:3000 51 | ``` 52 | 53 | ## Running webapp 54 | 55 | In root of the repo, npm install the dependencies 56 | 57 | ``` 58 | npm install 59 | ``` 60 | 61 | Now we can run the development server: 62 | 63 | ```bash 64 | npm run dev 65 | ``` 66 | 67 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 68 | -------------------------------------------------------------------------------- /app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/layout.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { PropsWithChildren } from 'react'; 3 | import { Affix, Layout } from 'antd'; 4 | import { ParsedBoardRouteParameters, BoardRouteParametersWithUuid } from '@/app/lib/types'; 5 | import { parseBoardRouteParams } from '@/app/lib/url-utils'; // Assume this utility helps with parsing 6 | import '@/c/index.css'; 7 | 8 | import { Content } from 'antd/es/layout/layout'; 9 | import QueueControlBar from '@/app/components/queue-control/queue-control-bar'; 10 | import { fetchBoardDetails } from '@/app/components/rest-api/api'; 11 | import BoardSeshHeader from '@/app/components/board-page/header'; 12 | import { QueueProvider } from '@/app/components/queue-control/queue-context'; 13 | import { PeerProvider } from '@/app/components/connection-manager/peer-context'; 14 | import { PartyProvider } from '@/app/components/party-manager/party-context'; 15 | 16 | interface BoardLayoutProps { 17 | params: Promise; 18 | searchParams: { 19 | query?: string; 20 | page?: string; 21 | gradeAccuracy?: string; 22 | maxGrade?: string; 23 | minAscents?: string; 24 | minGrade?: string; 25 | minRating?: string; 26 | sortBy?: string; 27 | sortOrder?: string; 28 | name?: string; 29 | onlyClassics?: string; 30 | settername?: string; 31 | setternameSuggestion?: string; 32 | holds?: string; 33 | mirroredHolds?: string; 34 | pageSize?: string; 35 | }; 36 | } 37 | 38 | export default async function BoardLayout(props: PropsWithChildren) { 39 | const params = await props.params; 40 | 41 | const { 42 | children 43 | } = props; 44 | 45 | // Parse the route parameters 46 | const parsedParams: ParsedBoardRouteParameters = parseBoardRouteParams(params); 47 | 48 | const { board_name, layout_id, angle } = parsedParams; 49 | 50 | // Fetch the climbs and board details server-side 51 | const [boardDetails] = await Promise.all([ 52 | fetchBoardDetails(parsedParams.board_name, parsedParams.layout_id, parsedParams.size_id, parsedParams.set_ids), 53 | ]); 54 | 55 | return ( 56 | <> 57 | {`Boardsesh on ${board_name} - Layout ${layout_id}`} 58 | 59 | 60 | 61 | 62 | 63 | 64 | 78 | {children} 79 | 80 | 81 | 82 |
83 | 84 |
85 |
86 |
87 |
88 |
89 |
90 | 91 | ); 92 | } 93 | -------------------------------------------------------------------------------- /app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/list/layout.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { PropsWithChildren } from 'react'; 4 | 5 | import SearchColumn from '@/app/components/search-drawer/search-drawer'; 6 | import Col from 'antd/es/col'; 7 | import { Content } from 'antd/es/layout/layout'; 8 | import Row from 'antd/es/row'; 9 | import { BoardRouteParametersWithUuid, ParsedBoardRouteParameters } from '@/app/lib/types'; 10 | import { parseBoardRouteParams } from '@/app/lib/url-utils'; 11 | import { fetchBoardDetails } from '@/app/components/rest-api/api'; 12 | 13 | interface LayoutProps { 14 | params: Promise; 15 | } 16 | 17 | export default async function ListLayout(props: PropsWithChildren) { 18 | const params = await props.params; 19 | 20 | const { 21 | children 22 | } = props; 23 | 24 | const parsedParams: ParsedBoardRouteParameters = parseBoardRouteParams(params); 25 | 26 | const { board_name, layout_id, set_ids, size_id } = parsedParams; 27 | 28 | // Fetch the climbs and board details server-side 29 | const [boardDetails] = await Promise.all([fetchBoardDetails(board_name, layout_id, size_id, set_ids)]); 30 | 31 | return ( 32 | 33 | 34 | {children} 35 | 36 | 37 | 38 | 39 | 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/list/page.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { notFound } from 'next/navigation'; 4 | import { BoardRouteParametersWithUuid, SearchRequestPagination } from '@/app/lib/types'; 5 | import { parseBoardRouteParams, parsedRouteSearchParamsToSearchParams } from '@/app/lib/url-utils'; 6 | import ClimbsList from '@/app/components/board-page/climbs-list'; 7 | import { fetchBoardDetails, fetchClimbs } from '@/app/components/rest-api/api'; 8 | 9 | export default async function DynamicResultsPage( 10 | props: { 11 | params: Promise; 12 | searchParams: Promise; 13 | } 14 | ) { 15 | const searchParams = await props.searchParams; 16 | const params = await props.params; 17 | const parsedParams = parseBoardRouteParams(params); 18 | 19 | try { 20 | const searchParamsObject: SearchRequestPagination = parsedRouteSearchParamsToSearchParams(searchParams); 21 | 22 | // For the SSR version we increase the pageSize so it also gets whatever page number 23 | // is in the search params. Without this, it would load the SSR version of the page on page 2 24 | // which would then flicker once SWR runs on the client. 25 | searchParamsObject.pageSize = (searchParamsObject.page + 1) * searchParamsObject.pageSize; 26 | searchParamsObject.page = 0; 27 | 28 | const [fetchedResults, boardDetails] = await Promise.all([ 29 | fetchClimbs(searchParamsObject, parsedParams), 30 | fetchBoardDetails(parsedParams.board_name, parsedParams.layout_id, parsedParams.size_id, parsedParams.set_ids), 31 | ]); 32 | 33 | if (!fetchedResults || fetchedResults.climbs.length === 0) { 34 | notFound(); 35 | } 36 | 37 | return ( 38 | <> 39 | 40 | 41 | ); 42 | } catch (error) { 43 | console.error('Error fetching results or climb:', error); 44 | notFound(); // or show a 500 error page 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/view/[climb_uuid]/page.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { notFound } from 'next/navigation'; 3 | import { BoardRouteParametersWithUuid } from '@/app/lib/types'; 4 | import { parseBoardRouteParams } from '@/app/lib/url-utils'; 5 | import { fetchBoardDetails, fetchCurrentClimb } from '@/app/components/rest-api/api'; 6 | import ClimbCard from '@/app/components/climb-card/climb-card'; 7 | import { Col, Row } from 'antd'; 8 | import ClimbInfoColumn from '@/app/components/climb-info/climb-info-drawer'; 9 | 10 | export default async function DynamicResultsPage(props: { params: Promise }) { 11 | const params = await props.params; 12 | const parsedParams = parseBoardRouteParams(params); 13 | 14 | try { 15 | // Fetch the search results using searchCLimbs 16 | const [boardDetails, currentClimb] = await Promise.all([ 17 | fetchBoardDetails(parsedParams.board_name, parsedParams.layout_id, parsedParams.size_id, parsedParams.set_ids), 18 | fetchCurrentClimb(parsedParams), 19 | ]); 20 | 21 | return ( 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | ); 31 | } catch (error) { 32 | console.error('Error fetching results or climb:', error); 33 | notFound(); // or show a 500 error page 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /app/[board_name]/[layout_id]/[size_id]/[set_ids]/page.tsx: -------------------------------------------------------------------------------- 1 | import AngleSelection from '@/app/components/setup-wizard/angle-selection'; 2 | import { BoardName } from '@/app/lib/types'; 3 | import React from 'react'; 4 | 5 | export default async function LayoutsPage(props: { params: Promise<{ board_name: BoardName }> }) { 6 | const params = await props.params; 7 | 8 | const { 9 | board_name 10 | } = params; 11 | 12 | return ; 13 | } 14 | -------------------------------------------------------------------------------- /app/[board_name]/[layout_id]/[size_id]/page.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { fetchSets } from '@/app/components/rest-api/api'; 3 | import SetsSelection from '@/app/components/setup-wizard/sets-selection'; 4 | import { BoardName, LayoutId, Size } from '@/app/lib/types'; 5 | 6 | export default async function LayoutsPage( 7 | props: { 8 | params: Promise<{ board_name: BoardName; layout_id: LayoutId; size_id: Size }>; 9 | } 10 | ) { 11 | const params = await props.params; 12 | const sets = await fetchSets(params.board_name, params.layout_id, params.size_id); 13 | return ; 14 | } 15 | -------------------------------------------------------------------------------- /app/[board_name]/[layout_id]/page.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import SizeSelection from '@/app/components/setup-wizard/size-selection'; 3 | import { fetchSizes } from '@/app/components/rest-api/api'; 4 | import { BoardName, LayoutId } from '@/app/lib/types'; 5 | 6 | export default async function LayoutsPage(props: { params: Promise<{ board_name: BoardName; layout_id: LayoutId }> }) { 7 | const params = await props.params; 8 | const sizes = await fetchSizes(params.board_name, params.layout_id); 9 | return ; 10 | } 11 | -------------------------------------------------------------------------------- /app/[board_name]/layout.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { PropsWithChildren } from 'react'; 3 | import { ParsedBoardRouteParameters, BoardRouteParametersWithUuid } from '@/app/lib/types'; 4 | import { parseBoardRouteParams } from '@/app/lib/url-utils'; // Assume this utility helps with parsing 5 | 6 | import { BoardProvider } from '../components/board-provider/board-provider-context'; 7 | 8 | interface BoardLayoutProps { 9 | params: Promise; 10 | } 11 | 12 | export default async function BoardLayout(props: PropsWithChildren) { 13 | const params = await props.params; 14 | 15 | const { 16 | children 17 | } = props; 18 | 19 | // Parse the route parameters 20 | const parsedParams: ParsedBoardRouteParameters = parseBoardRouteParams(params); 21 | 22 | const { board_name } = parsedParams; 23 | return {children}; 24 | } 25 | -------------------------------------------------------------------------------- /app/[board_name]/page.tsx: -------------------------------------------------------------------------------- 1 | // app/page.tsx 2 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 3 | import React from 'react'; 4 | import LayoutSelection from '../components/setup-wizard/layout-selection'; 5 | import { fetchLayouts } from '../components/rest-api/api'; 6 | import { BoardName } from '../lib/types'; 7 | 8 | export default async function LayoutsPage(props: { params: Promise<{ board_name: BoardName }> }) { 9 | const params = await props.params; 10 | 11 | const { 12 | board_name 13 | } = params; 14 | 15 | const layouts = await fetchLayouts(board_name); 16 | return ; 17 | } 18 | -------------------------------------------------------------------------------- /app/api/internal/shared-sync/[board_name]/route.ts: -------------------------------------------------------------------------------- 1 | // app/api/cron/sync-shared-data/route.ts 2 | import { NextResponse } from 'next/server'; 3 | import { syncSharedData } from '@/lib/data-sync/aurora/shared-sync'; 4 | import { BoardRouteParameters, ParsedBoardRouteParameters } from '@/app/lib/types'; 5 | import { parseBoardRouteParams } from '@/app/lib/url-utils'; 6 | 7 | export const dynamic = 'force-dynamic'; 8 | export const maxDuration = 300; 9 | // This is a simple way to secure the endpoint, should be replaced with a better solution 10 | const CRON_SECRET = process.env.CRON_SECRET; 11 | 12 | export async function GET(request: Request, props: { params: Promise }) { 13 | const params = await props.params; 14 | try { 15 | const { board_name }: ParsedBoardRouteParameters = parseBoardRouteParams(params); 16 | console.log(`Starting shared sync for ${board_name}`); 17 | // Basic auth check 18 | const authHeader = request.headers.get('authorization'); 19 | if (process.env.VERCEL_ENV !== 'development' && authHeader !== `Bearer ${CRON_SECRET}`) { 20 | return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); 21 | } 22 | console.log(`Passed auth for ${board_name}`); 23 | 24 | const result = await syncSharedData(board_name); 25 | 26 | return NextResponse.json({ 27 | success: true, 28 | results: result, 29 | }); 30 | } catch (error) { 31 | console.error('Cron job failed:', error); 32 | return NextResponse.json({ success: false, error: 'Sync failed' }, { status: 500 }); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /app/api/v1/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/[climb_uuid]/route.ts: -------------------------------------------------------------------------------- 1 | // api/v1/[board_name]/[layout_id]/[size_id]/[set_ids]/[climb_uuid] 2 | import { convertLitUpHoldsStringToMap } from '@/app/components/board-renderer/util'; 3 | import { getClimb } from '@/app/lib/data/queries'; 4 | import { BoardRouteParametersWithUuid, ErrorResponse, FetchCurrentProblemResponse } from '@/app/lib/types'; 5 | import { parseBoardRouteParams } from '@/app/lib/url-utils'; 6 | import { NextResponse } from 'next/server'; 7 | 8 | export async function GET(req: Request, props: { params: Promise }): Promise> { 9 | const params = await props.params; 10 | try { 11 | const parsedParams = parseBoardRouteParams(params); 12 | const result = await getClimb(parsedParams); 13 | 14 | // TODO: Multiframe support should remove the hardcoded [0] 15 | const litUpHoldsMap = convertLitUpHoldsStringToMap(result.frames, parsedParams.board_name)[0]; 16 | 17 | if (!result) { 18 | return NextResponse.json({ error: `Failed to find problem ${params.climb_uuid}` }, { status: 404 }); 19 | } 20 | // Include both the rows and the total count in the response 21 | return NextResponse.json({ ...result, litUpHoldsMap }); 22 | } catch (error) { 23 | console.error('Error fetching data:', error); 24 | return NextResponse.json({ error: 'Failed to fetch board details' }, { status: 500 }); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/api/v1/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/heatmap/route.ts: -------------------------------------------------------------------------------- 1 | 2 | import { getHoldHeatmapData } from '@/app/lib/db/queries/climbs/holds-heatmap'; 3 | import { getSession } from '@/app/lib/session'; 4 | import { BoardRouteParameters, ErrorResponse, SearchRequestPagination } from '@/app/lib/types'; 5 | import { parseBoardRouteParams, urlParamsToSearchParams } from '@/app/lib/url-utils'; 6 | import { cookies } from 'next/headers'; 7 | import { NextResponse } from 'next/server'; 8 | 9 | export interface HoldHeatmapResponse { 10 | holdStats: Array<{ 11 | holdId: number; 12 | totalUses: number; 13 | startingUses: number; 14 | totalAscents: number; 15 | handUses: number; 16 | footUses: number; 17 | finishUses: number; 18 | averageDifficulty: number | null; 19 | userAscents?: number; // Added for user-specific ascent data 20 | userAttempts?: number; // Added for user-specific attempt data 21 | }>; 22 | } 23 | 24 | export async function GET(req: Request, props: { params: Promise }): Promise> { 25 | const params = await props.params; 26 | // Extract search parameters from query string 27 | const query = new URL(req.url).searchParams; 28 | const parsedParams = parseBoardRouteParams(params); 29 | const searchParams: SearchRequestPagination = urlParamsToSearchParams(query); 30 | 31 | const cookieStore = await cookies(); 32 | const session = await getSession(cookieStore, parsedParams.board_name); 33 | console.log(parsedParams); 34 | console.log(params); 35 | try { 36 | // Get the heatmap data using the query function 37 | const holdStats = await getHoldHeatmapData(parsedParams, searchParams, session.userId); 38 | 39 | // Return response 40 | return NextResponse.json({ 41 | holdStats, 42 | }); 43 | } catch (error) { 44 | console.error('Error generating heatmap data:', error); 45 | return NextResponse.json({ error: 'Failed to generate hold heatmap data' }, { status: 500 }); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /app/api/v1/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/search/route.ts: -------------------------------------------------------------------------------- 1 | import { searchClimbs } from '@/app/lib/db/queries/climbs/search-climbs'; 2 | import { BoardRouteParameters, ErrorResponse, SearchClimbsResult, SearchRequestPagination } from '@/app/lib/types'; 3 | import { parseBoardRouteParams, urlParamsToSearchParams } from '@/app/lib/url-utils'; 4 | import { NextResponse } from 'next/server'; 5 | 6 | // Refactor: Keep BoardRouteParameters and SearchRequest fields in separate objects 7 | export async function GET(req: Request, props: { params: Promise }): Promise> { 8 | const params = await props.params; 9 | // Extract search parameters from query string 10 | const query = new URL(req.url).searchParams; 11 | const parsedParams = parseBoardRouteParams(params); 12 | 13 | const searchParams: SearchRequestPagination = urlParamsToSearchParams(query); 14 | 15 | try { 16 | // Call the separate function to perform the search 17 | const result = await searchClimbs(parsedParams, searchParams); 18 | 19 | // Return response 20 | return NextResponse.json({ 21 | totalCount: result.totalCount, 22 | climbs: result.climbs, 23 | }); 24 | } catch (error) { 25 | console.error('Error fetching data:', error); 26 | return NextResponse.json({ error: 'Failed to fetch board details' }, { status: 500 }); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/api/v1/[board_name]/[layout_id]/[size_id]/[set_ids]/details/route.ts: -------------------------------------------------------------------------------- 1 | import { getBoardDetails } from '@/app/lib/data/queries'; 2 | import { BoardRouteParameters } from '@/app/lib/types'; 3 | import { NextResponse } from 'next/server'; 4 | import { parseBoardRouteParams } from '@/app/lib/url-utils'; 5 | 6 | export async function GET(req: Request, props: { params: Promise }) { 7 | const params = await props.params; 8 | try { 9 | const parsedParams = parseBoardRouteParams(params); 10 | const boardDetails = await getBoardDetails(parsedParams); 11 | 12 | // Return the combined result 13 | return NextResponse.json(boardDetails); 14 | } catch (error) { 15 | return NextResponse.json({ error: 'Failed to fetch board details' }, { status: 500 }); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /app/api/v1/[board_name]/[layout_id]/[size_id]/sets/route.ts: -------------------------------------------------------------------------------- 1 | import { getSets } from '@/app/lib/data/queries'; 2 | import { BoardName, LayoutId, Size } from '@/app/lib/types'; 3 | import { NextResponse } from 'next/server'; 4 | 5 | export async function GET( 6 | request: Request, 7 | props: { params: Promise<{ board_name: BoardName; layout_id: LayoutId; size_id: Size }> } 8 | ) { 9 | const params = await props.params; 10 | const { board_name, layout_id, size_id } = params; 11 | 12 | try { 13 | const sets = await getSets(board_name, layout_id, size_id); 14 | 15 | return NextResponse.json(sets); 16 | } catch (error) { 17 | console.error(error); 18 | return NextResponse.json({ error: 'Failed to fetch sets' }, { status: 500 }); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/api/v1/[board_name]/[layout_id]/sizes/route.ts: -------------------------------------------------------------------------------- 1 | import { getSizes } from '@/app/lib/data/queries'; 2 | import { BoardName, LayoutId } from '@/app/lib/types'; 3 | import { NextResponse } from 'next/server'; 4 | 5 | // Dynamic handler for fetching sizes related to a specific layout 6 | export async function GET( 7 | req: Request, 8 | props: { params: Promise<{ board_name: BoardName; layout_id: LayoutId }> } 9 | ) { 10 | const params = await props.params; 11 | const { board_name, layout_id } = params; 12 | 13 | try { 14 | // Fetch sizes based on layout_id 15 | const result = await getSizes(board_name, layout_id); 16 | 17 | // Return the sizes as JSON response 18 | return NextResponse.json(result); 19 | } catch (error) { 20 | console.error('Error fetching sizes:', error); 21 | return NextResponse.json({ error: 'Failed to fetch sizes' }, { status: 500 }); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/api/v1/[board_name]/grades/route.ts: -------------------------------------------------------------------------------- 1 | import { sql } from '@vercel/postgres'; 2 | import { NextResponse } from 'next/server'; 3 | 4 | export async function GET() { 5 | try { 6 | const { rows: grades } = await sql` 7 | SELECT difficulty as difficulty_id, boulder_name as difficulty_name 8 | FROM difficulty_grades 9 | WHERE is_listed = true 10 | ORDER BY difficulty ASC 11 | `; 12 | return NextResponse.json(grades); 13 | } catch (error) { 14 | return NextResponse.json({ error: 'Failed to fetch grades' }, { status: 500 }); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /app/api/v1/[board_name]/layouts/route.ts: -------------------------------------------------------------------------------- 1 | import { getLayouts } from '@/app/lib/data/queries'; 2 | import { BoardRouteParameters } from '@/app/lib/types'; 3 | import { parseBoardRouteParams } from '@/app/lib/url-utils'; 4 | import { NextResponse } from 'next/server'; 5 | 6 | // Correct typing for the parameters 7 | export async function GET(req: Request, props: { params: Promise }) { 8 | const params = await props.params; 9 | const { board_name } = parseBoardRouteParams(params); 10 | 11 | try { 12 | const layouts = await getLayouts(board_name); 13 | 14 | return NextResponse.json(layouts); 15 | } catch (error) { 16 | console.error('Error fetching layouts:', error); // Log the error for debugging 17 | return NextResponse.json({ error: 'Failed to fetch layouts' }, { status: 500 }); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /app/api/v1/[board_name]/proxy/getLogbook/route.ts: -------------------------------------------------------------------------------- 1 | // app/api/login/route.ts 2 | import { getLogbook } from '@/app/lib/data/get-logbook'; 3 | import { getSession } from '@/app/lib/session'; 4 | import { BoardRouteParameters, ParsedBoardRouteParameters } from '@/app/lib/types'; 5 | import { parseBoardRouteParams } from '@/app/lib/url-utils'; 6 | import { cookies } from 'next/headers'; 7 | import { NextResponse } from 'next/server'; 8 | import { z } from 'zod'; 9 | 10 | export async function POST(request: Request, props: { params: Promise }) { 11 | const params = await props.params; 12 | const { board_name }: ParsedBoardRouteParameters = parseBoardRouteParams(params); 13 | try { 14 | // Parse and validate request body 15 | const validatedData = await request.json(); 16 | // Call the board API 17 | const cookieStore = await cookies(); 18 | const session = await getSession(cookieStore, board_name); 19 | 20 | const { token, userId } = session; 21 | 22 | if (!token || !userId) { 23 | throw new Error('401: Unauthorized'); 24 | } 25 | 26 | const response = await getLogbook(board_name, validatedData.userId, validatedData.climbUuids); 27 | 28 | return NextResponse.json(response); 29 | } catch (error) { 30 | if (error instanceof z.ZodError) { 31 | return NextResponse.json({ error: 'Invalid request data', details: error.errors }, { status: 400 }); 32 | } 33 | 34 | // Handle fetch errors 35 | if (error instanceof Error) { 36 | if (error.message.includes('401')) { 37 | return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 }); 38 | } 39 | 40 | if (error.message.includes('403')) { 41 | return NextResponse.json({ error: 'Access forbidden' }, { status: 403 }); 42 | } 43 | 44 | if (error.message.startsWith('HTTP error!')) { 45 | return NextResponse.json({ error: 'Service unavailable' }, { status: 503 }); 46 | } 47 | } 48 | 49 | // Generic error 50 | console.error('Login error:', error); 51 | return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /app/api/v1/[board_name]/proxy/login/route.ts: -------------------------------------------------------------------------------- 1 | import { sql } from '@/app/lib/db/db'; 2 | import { NextResponse } from 'next/server'; 3 | import { z } from 'zod'; 4 | import AuroraClimbingClient from '@/app/lib/api-wrappers/aurora-rest-client/aurora-rest-client'; 5 | import { BoardName, BoardRouteParameters, ParsedBoardRouteParameters } from '@/app/lib/types'; 6 | import { parseBoardRouteParams } from '@/app/lib/url-utils'; 7 | import { syncUserData } from '@/app/lib/data-sync/aurora/user-sync'; 8 | import { Session } from '@/app/lib/api-wrappers/aurora-rest-client/types'; 9 | import { cookies } from 'next/headers'; 10 | import { getSession } from '@/app/lib/session'; 11 | 12 | 13 | // Input validation schema 14 | const loginSchema = z.object({ 15 | username: z.string().min(1), 16 | password: z.string().min(1), 17 | }); 18 | 19 | /** 20 | * Performs login for a specific climbing board 21 | * @param board - The name of the climbing board 22 | * @param username - User's username 23 | * @param password - User's password 24 | * @returns Login response from the board's API 25 | */ 26 | async function login(boardName: BoardName, username: string, password: string): Promise { 27 | const auroraClient = new AuroraClimbingClient({ boardName: boardName }); 28 | const loginResponse = await auroraClient.signIn(username, password); 29 | 30 | if (loginResponse.user_id) { 31 | const tableName = boardName === 'tension' || boardName === 'kilter' ? `${boardName}_users` : 'users'; 32 | 33 | // Insert/update user in our database 34 | await sql.query( 35 | ` 36 | INSERT INTO ${tableName} (id, username, created_at) 37 | VALUES ($1, $2, $3) 38 | ON CONFLICT (id) DO UPDATE SET 39 | username = EXCLUDED.username 40 | `, 41 | [loginResponse.user_id, loginResponse.username, new Date(loginResponse.user.created_at)], 42 | ); 43 | 44 | // If it's a new user, perform full sync 45 | try { 46 | await syncUserData( 47 | boardName, 48 | loginResponse.token, 49 | loginResponse.user_id, 50 | ); 51 | } catch (error) { 52 | console.error('Initial sync error:', error); 53 | // We don't throw here as login was successful 54 | } 55 | } 56 | 57 | return loginResponse; 58 | } 59 | 60 | 61 | /** 62 | * Route handler for login POST requests 63 | * @param request - Incoming HTTP request 64 | * @param props - Route parameters 65 | * @returns NextResponse with login results or error 66 | */ 67 | export async function POST(request: Request, props: { params: Promise }) { 68 | const params = await props.params; 69 | const { board_name }: ParsedBoardRouteParameters = parseBoardRouteParams(params); 70 | 71 | try { 72 | // Parse and validate request body 73 | const body = await request.json(); 74 | const validatedData = loginSchema.parse(body); 75 | 76 | // Call the board API 77 | const loginResponse = await login(board_name, validatedData.username, validatedData.password); 78 | 79 | const response = NextResponse.json(loginResponse); 80 | 81 | const session = await getSession(response.cookies, board_name); 82 | session.token = loginResponse.token; 83 | session.username = validatedData.username; 84 | session.password = validatedData.password; 85 | session.userId = loginResponse.user_id; 86 | await session.save(); 87 | 88 | return response; 89 | } catch (error) { 90 | if (error instanceof z.ZodError) { 91 | return NextResponse.json({ error: 'Invalid request data', details: error.errors }, { status: 400 }); 92 | } 93 | 94 | // Handle fetch errors 95 | if (error instanceof Error) { 96 | if (error.message.includes('401')) { 97 | return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 }); 98 | } 99 | if (error.message.includes('403')) { 100 | return NextResponse.json({ error: 'Access forbidden' }, { status: 403 }); 101 | } 102 | if (error.message.startsWith('HTTP error!')) { 103 | return NextResponse.json({ error: 'Service unavailable' }, { status: 503 }); 104 | } 105 | } 106 | 107 | // Generic error 108 | console.error('Login error:', error); 109 | return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /app/api/v1/[board_name]/proxy/saveAscent/route.ts: -------------------------------------------------------------------------------- 1 | // app/api/v1/[board_name]/proxy/saveAscent/route.ts 2 | import { saveAscent } from '@/app/lib/api-wrappers/aurora/saveAscent'; 3 | import { BoardRouteParameters, ParsedBoardRouteParameters } from '@/app/lib/types'; 4 | import { parseBoardRouteParams } from '@/app/lib/url-utils'; 5 | import { NextResponse } from 'next/server'; 6 | import { z } from 'zod'; 7 | 8 | const saveAscentSchema = z.object({ 9 | token: z.string().min(1), 10 | options: z 11 | .object({ 12 | uuid: z.string(), 13 | user_id: z.number(), // Changed from z.string() to z.number() 14 | climb_uuid: z.string(), 15 | angle: z.number(), 16 | is_mirror: z.boolean(), 17 | attempt_id: z.number(), 18 | bid_count: z.number(), 19 | quality: z.number(), 20 | difficulty: z.number(), 21 | is_benchmark: z.boolean(), 22 | comment: z.string(), 23 | climbed_at: z.string(), 24 | }) 25 | .strict(), 26 | }); 27 | 28 | export async function POST(request: Request, props: { params: Promise }) { 29 | const params = await props.params; 30 | const { board_name }: ParsedBoardRouteParameters = parseBoardRouteParams(params); 31 | 32 | try { 33 | const body = await request.json(); 34 | const validatedData = saveAscentSchema.parse(body); 35 | 36 | const response = await saveAscent(board_name, validatedData.token, validatedData.options); 37 | return NextResponse.json(response); 38 | } catch (error) { 39 | if (error instanceof z.ZodError) { 40 | return NextResponse.json({ error: 'Invalid request data', details: error.errors }, { status: 400 }); 41 | } 42 | 43 | if (error instanceof Error) { 44 | if (error.message.includes('401')) { 45 | return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 }); 46 | } 47 | 48 | if (error.message.includes('403')) { 49 | return NextResponse.json({ error: 'Access forbidden' }, { status: 403 }); 50 | } 51 | 52 | if (error.message.startsWith('HTTP error!')) { 53 | return NextResponse.json({ error: 'Service unavailable' }, { status: 503 }); 54 | } 55 | } 56 | 57 | return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /app/api/v1/[board_name]/proxy/user-sync/route.ts: -------------------------------------------------------------------------------- 1 | // app/api/[board]/sync/route.ts 2 | import { syncUserData } from '@/app/lib/data-sync/aurora/user-sync'; 3 | import { getSession } from '@/app/lib/session'; 4 | import { cookies } from 'next/headers'; 5 | 6 | export async function POST(request: Request) { 7 | const { board_name } = await request.json(); 8 | 9 | try { 10 | const cookieStore = await cookies(); 11 | const session = await getSession(cookieStore, board_name); 12 | if (!session) { 13 | throw new Error('401: Unauthorized'); 14 | } 15 | const { token, userId, username } = session; 16 | await syncUserData(board_name, token, userId); 17 | return new Response(JSON.stringify({ success: true, message: 'All tables synced' }), { status: 200 }); 18 | } catch (err) { 19 | console.error('Failed to sync with Aurora:', err); 20 | //@ts-expect-error Eh cant be bothered fixing this now 21 | return new Response(JSON.stringify({ error: 'Sync failed', details: err.message }), { status: 500 }); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/api/v1/angles/[board_name]/[layout_id]/route.ts: -------------------------------------------------------------------------------- 1 | import { sql } from '@vercel/postgres'; 2 | import { NextResponse } from 'next/server'; 3 | 4 | export async function GET( 5 | req: Request, 6 | props: { params: Promise<{ board_name: string; layout_id: string }> } 7 | ) { 8 | const params = await props.params; 9 | const { /*board_name,*/ layout_id } = params; 10 | 11 | try { 12 | const { rows: angles } = await sql` 13 | SELECT angle 14 | FROM kilter_products_angles 15 | JOIN layouts ON layouts.product_id = products_angles.product_id 16 | WHERE layouts.id = ${layout_id} 17 | ORDER BY angle ASC 18 | `; 19 | return NextResponse.json(angles); 20 | } catch (error) { 21 | return NextResponse.json({ error: 'Failed to fetch angles' }, { status: 500 }); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/api/v1/grades/[board_name]/route.ts: -------------------------------------------------------------------------------- 1 | import { sql } from '@vercel/postgres'; 2 | import { NextResponse } from 'next/server'; 3 | 4 | export async function GET() { 5 | try { 6 | const { rows: grades } = await sql` 7 | SELECT difficulty as difficulty_id, boulder_name as difficulty_name 8 | FROM kilter_difficulty_grades 9 | WHERE is_listed = true 10 | ORDER BY difficulty ASC 11 | `; 12 | return NextResponse.json(grades); 13 | } catch (error) { 14 | return NextResponse.json({ error: 'Failed to fetch grades' }, { status: 500 }); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /app/components/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | // import BoardForm from "./BoardForm"; 3 | 4 | const App = () => { 5 | // const handleLoginButtonClick = () => { 6 | // setShowLoginModal(true); 7 | // }; 8 | 9 | return ( 10 |
11 |
12 |
{/* */}
13 |
14 |
15 |
{/* Include your footer component here */}
16 |
17 |
18 | ); 19 | }; 20 | 21 | export default App; 22 | -------------------------------------------------------------------------------- /app/components/board-bluetooth-control/bluetooth.ts: -------------------------------------------------------------------------------- 1 | import { BoardName, LedPlacements } from '@/app/lib/types'; 2 | import { HOLD_STATE_MAP } from '../board-renderer/types'; 3 | 4 | // Bluetooth constants 5 | const MAX_BLUETOOTH_MESSAGE_SIZE = 20; 6 | const MESSAGE_BODY_MAX_LENGTH = 255; 7 | const PACKET_MIDDLE = 81; 8 | const PACKET_FIRST = 82; 9 | const PACKET_LAST = 83; 10 | const PACKET_ONLY = 84; 11 | const SERVICE_UUID = '6e400001-b5a3-f393-e0a9-e50e24dcca9e'; 12 | const CHARACTERISTIC_UUID = '6e400002-b5a3-f393-e0a9-e50e24dcca9e'; 13 | 14 | // Helper functions (same as before) 15 | const checksum = (data: number[]) => data.reduce((acc, value) => (acc + value) & 255, 0) ^ 255; 16 | 17 | const wrapBytes = (data: number[]) => 18 | data.length > MESSAGE_BODY_MAX_LENGTH ? [] : [1, data.length, checksum(data), 2, ...data, 3]; 19 | 20 | const encodePosition = (position: number) => [position & 255, (position >> 8) & 255]; 21 | 22 | const encodeColor = (color: string) => { 23 | const parsedColor = [ 24 | Math.floor(parseInt(color.substring(0, 2), 16) / 32) << 5, 25 | Math.floor(parseInt(color.substring(2, 4), 16) / 32) << 2, 26 | Math.floor(parseInt(color.substring(4, 6), 16) / 64), 27 | ].reduce((acc, val) => acc | val); 28 | return parsedColor; 29 | }; 30 | 31 | const encodePositionAndColor = (position: number, ledColor: string) => [ 32 | ...encodePosition(position), 33 | encodeColor(ledColor), 34 | ]; 35 | 36 | export const getBluetoothPacket = (frames: string, placementPositions: LedPlacements, board_name: BoardName) => { 37 | const resultArray: number[][] = []; 38 | let tempArray = [PACKET_MIDDLE]; 39 | 40 | frames.split('p').forEach((frame) => { 41 | if (!frame) return; 42 | 43 | const [placement, role] = frame.split('r'); 44 | const encodedFrame = encodePositionAndColor( 45 | Number(placementPositions[Number(placement)]), 46 | HOLD_STATE_MAP[board_name][Number(role)].color.replace('#', ''), 47 | ); 48 | 49 | if (tempArray.length + encodedFrame.length > MESSAGE_BODY_MAX_LENGTH) { 50 | resultArray.push(tempArray); 51 | tempArray = [PACKET_MIDDLE]; 52 | } 53 | tempArray.push(...encodedFrame); 54 | }); 55 | 56 | resultArray.push(tempArray); 57 | if (resultArray.length === 1) resultArray[0][0] = PACKET_ONLY; 58 | else { 59 | resultArray[0][0] = PACKET_FIRST; 60 | resultArray[resultArray.length - 1][0] = PACKET_LAST; 61 | } 62 | 63 | return Uint8Array.from(resultArray.flatMap(wrapBytes)); 64 | }; 65 | 66 | export const splitMessages = (buffer: Uint8Array) => 67 | Array.from({ length: Math.ceil(buffer.length / MAX_BLUETOOTH_MESSAGE_SIZE) }, (_, i) => 68 | buffer.slice(i * MAX_BLUETOOTH_MESSAGE_SIZE, (i + 1) * MAX_BLUETOOTH_MESSAGE_SIZE), 69 | ); 70 | 71 | export const writeCharacteristicSeries = async ( 72 | characteristic: BluetoothRemoteGATTCharacteristic, 73 | messages: Uint8Array[], 74 | ) => { 75 | for (const message of messages) { 76 | await characteristic.writeValue(message); 77 | } 78 | }; 79 | 80 | export const requestDevice = async (namePrefix: string) => 81 | navigator.bluetooth.requestDevice({ 82 | filters: [{ namePrefix }], 83 | optionalServices: [SERVICE_UUID], 84 | }); 85 | 86 | export const getCharacteristic = async (device: BluetoothDevice) => { 87 | const server = await device.gatt?.connect(); 88 | const service = await server?.getPrimaryService(SERVICE_UUID); 89 | return await service?.getCharacteristic(CHARACTERISTIC_UUID); 90 | }; 91 | -------------------------------------------------------------------------------- /app/components/board-bluetooth-control/send-climb-to-board-button.css: -------------------------------------------------------------------------------- 1 | .connect-button-glow { 2 | animation: glow 1.5s infinite alternate; 3 | color: rgba(255, 215, 0, 0.4) !important; 4 | } 5 | 6 | @keyframes glow { 7 | from { 8 | box-shadow: 9 | 0 0 5px rgba(255, 215, 0, 0.4), 10 | 0 0 10px rgba(255, 215, 0, 0.2), 11 | 0 0 15px rgba(255, 215, 0, 0.1); 12 | } 13 | to { 14 | box-shadow: 15 | 0 0 10px rgba(255, 215, 0, 0.8), 16 | 0 0 20px rgba(255, 215, 0, 0.6), 17 | 0 0 30px rgba(255, 215, 0, 0.4); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /app/components/board-page/back-to-climb-list-button.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { ParsedBoardRouteParametersWithUuid } from '@/lib/types'; 4 | import { Button } from 'antd'; 5 | import { LeftOutlined } from '@ant-design/icons'; 6 | import Link from 'next/link'; 7 | import { useQueueContext } from '../queue-control/queue-context'; 8 | import { searchParamsToUrlParams } from '@/app/lib/url-utils'; 9 | 10 | const BackToClimbList = ({ 11 | board_name, 12 | layout_id, 13 | size_id, 14 | set_ids, 15 | angle, 16 | climb_uuid, 17 | }: ParsedBoardRouteParametersWithUuid) => { 18 | const { climbSearchParams } = useQueueContext(); 19 | 20 | return ( 21 | 24 | 93 | 101 | 109 | 110 | ) : ( 111 | <> 112 | {showLogbookView && currentClimb && } 113 | {showLogAscentForm && currentClimb && ( 114 | 115 | )} 116 | {showLogBookStats && } 117 | 118 | )} 119 | 120 | ); 121 | }; 122 | -------------------------------------------------------------------------------- /app/components/logbook/logbook-view.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { List, Card, Rate, Tag, Typography, Space } from 'antd'; 3 | import { CheckOutlined, CloseOutlined } from '@ant-design/icons'; 4 | import { Climb } from '@/app/lib/types'; 5 | import { useBoardProvider } from '../board-provider/board-provider-context'; 6 | import dayjs from 'dayjs'; 7 | 8 | const { Text } = Typography; 9 | 10 | interface LogbookViewProps { 11 | currentClimb: Climb; 12 | } 13 | 14 | export const LogbookView: React.FC = ({ currentClimb }) => { 15 | const { logbook, boardName } = useBoardProvider(); 16 | 17 | // Filter ascents for current climb and sort by climbed_at 18 | const climbAscents = logbook 19 | .filter((ascent) => ascent.climb_uuid === currentClimb.uuid) 20 | .sort((a, b) => { 21 | // Parse dates using dayjs and compare them 22 | const dateA = dayjs(a.climbed_at); 23 | const dateB = dayjs(b.climbed_at); 24 | return dateB.valueOf() - dateA.valueOf(); // Descending order (newest first) 25 | }); 26 | 27 | const showMirrorTag = boardName === 'tension'; 28 | 29 | return ( 30 | ( 33 | 34 | 35 | 36 | 37 | {dayjs(ascent.climbed_at).format('MMM D, YYYY h:mm A')} 38 | {ascent.angle !== currentClimb.angle && ( 39 | <> 40 | {ascent.angle}° 41 | {ascent.is_ascent ? ( 42 | 43 | ) : ( 44 | 45 | )} 46 | 47 | )} 48 | {showMirrorTag && ascent.is_mirror && Mirrored} 49 | 50 | {ascent.is_ascent && ( 51 | <> 52 | 53 | 54 | 55 | 56 | )} 57 | 58 | Attempts: {ascent.tries} 59 | 60 | 61 | {ascent.comment && ( 62 | 63 | {ascent.comment} 64 | 65 | )} 66 | 67 | 68 | 69 | )} 70 | locale={{ emptyText: 'No ascents logged for this climb' }} 71 | /> 72 | ); 73 | }; 74 | -------------------------------------------------------------------------------- /app/components/logbook/tick-button.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Angle, Climb, BoardDetails } from '@/app/lib/types'; 3 | import { useBoardProvider } from '../board-provider/board-provider-context'; 4 | import { Button, Badge, Form, Input, Drawer } from 'antd'; 5 | import { CheckOutlined } from '@ant-design/icons'; 6 | import { LogbookDrawer } from './logbook-drawer'; 7 | 8 | interface TickButtonProps { 9 | angle: Angle; 10 | currentClimb: Climb | null; 11 | boardDetails: BoardDetails; 12 | } 13 | 14 | const LoginForm = ({ 15 | onLogin, 16 | isLoggingIn, 17 | }: { 18 | onLogin: (username: string, password: string) => Promise; 19 | isLoggingIn: boolean; 20 | }) => { 21 | const [username, setUsername] = useState(''); 22 | const [password, setPassword] = useState(''); 23 | 24 | const handleSubmit = () => { 25 | if (username && password) { 26 | onLogin(username, password); 27 | } 28 | }; 29 | 30 | return ( 31 |
32 | 33 | setUsername(e.target.value)} /> 34 | 35 | 36 | 37 | setPassword(e.target.value)} /> 38 | 39 | 40 | 43 |
44 | ); 45 | }; 46 | 47 | export const TickButton: React.FC = ({ currentClimb, angle, boardDetails }) => { 48 | const { logbook, login, isAuthenticated, user } = useBoardProvider(); // Assuming 'user' is available in the context 49 | const [drawerVisible, setDrawerVisible] = useState(false); 50 | const [isLoggingIn, setIsLoggingIn] = useState(false); 51 | 52 | const showDrawer = () => setDrawerVisible(true); 53 | const closeDrawer = () => setDrawerVisible(false); 54 | 55 | const handleLogin = async (username: string, password: string) => { 56 | setIsLoggingIn(true); 57 | try { 58 | await login(boardDetails.board_name, username, password); 59 | } catch (error) { 60 | console.error('Login failed:', error); 61 | } finally { 62 | setIsLoggingIn(false); 63 | } 64 | }; 65 | 66 | const filteredLogbook = logbook.filter((asc) => asc.climb_uuid === currentClimb?.uuid && Number(asc.angle) === angle); 67 | const hasSuccessfulAscent = filteredLogbook.some((asc) => asc.is_ascent); 68 | const badgeCount = filteredLogbook.length; 69 | 70 | const boardName = boardDetails.board_name; 71 | const userId = String(user?.id || ''); 72 | 73 | return ( 74 | <> 75 | 0 ? badgeCount : 0} 77 | overflowCount={100} 78 | showZero={false} 79 | color={hasSuccessfulAscent ? 'cyan' : 'red'} 80 | > 81 | ; 9 | }; 10 | 11 | export default ClearButton; 12 | -------------------------------------------------------------------------------- /app/components/search-drawer/climb-hold-search-form.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { BoardDetails, HoldState } from '@/app/lib/types'; 3 | import { useUISearchParams } from '@/app/components/queue-control/ui-searchparams-provider'; 4 | import { Select, Button, Form } from 'antd'; 5 | import BoardHeatmap from '../board-renderer/board-heatmap'; 6 | 7 | interface ClimbHoldSearchFormProps { 8 | boardDetails: BoardDetails; 9 | } 10 | 11 | const ClimbHoldSearchForm: React.FC = ({ boardDetails }) => { 12 | const { uiSearchParams, updateFilters } = useUISearchParams(); 13 | const [selectedState, setSelectedState] = React.useState('ANY'); 14 | 15 | const handleHoldClick = (holdId: number) => { 16 | const updatedHoldsFilter = { ...uiSearchParams.holdsFilter }; 17 | 18 | if (selectedState === 'ANY' || selectedState === 'NOT') { 19 | if (updatedHoldsFilter[holdId]?.state === selectedState) { 20 | delete updatedHoldsFilter[holdId]; 21 | } else { 22 | updatedHoldsFilter[holdId] = { 23 | state: selectedState, 24 | color: selectedState === 'ANY' ? '#00CCCC' : '#FF0000', 25 | displayColor: selectedState === 'ANY' ? '#00CCCC' : '#FF0000', 26 | }; 27 | } 28 | } 29 | 30 | updateFilters({ 31 | holdsFilter: updatedHoldsFilter 32 | }); 33 | }; 34 | 35 | const stateItems = [ 36 | { value: 'ANY', label: 'Any Hold' }, 37 | { value: 'NOT', label: 'Not This Hold' }, 38 | ]; 39 | 40 | return ( 41 |
42 |
43 | 44 | { 17 | updateFilters({ 18 | name: e.target.value, 19 | }); 20 | }} 21 | value={uiSearchParams.name} 22 | /> 23 | 24 | ); 25 | }; 26 | 27 | export default SearchClimbNameInput; 28 | -------------------------------------------------------------------------------- /app/components/search-drawer/search-drawer.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React from 'react'; 4 | import SearchForm from './search-form'; 5 | import { Grid } from 'antd'; 6 | import { UISearchParamsProvider } from '../queue-control/ui-searchparams-provider'; 7 | import { BoardDetails } from '@/app/lib/types'; 8 | 9 | const { useBreakpoint } = Grid; 10 | 11 | const FilterColumn = ({ boardDetails }: { boardDetails: BoardDetails }) => { 12 | const screens = useBreakpoint(); 13 | 14 | // Sidebar for desktop view 15 | const desktopSidebar = ( 16 | 17 | 18 | 19 | ); 20 | 21 | // Conditionally render based on screen size 22 | return screens.md ? desktopSidebar : null; 23 | }; 24 | 25 | export default FilterColumn; 26 | -------------------------------------------------------------------------------- /app/components/search-drawer/search-form.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Tabs } from 'antd'; 3 | import { BoardDetails } from '@/app/lib/types'; 4 | import BasicSearchForm from './basic-search-form'; 5 | import ClimbHoldSearchForm from './climb-hold-search-form'; 6 | 7 | interface SearchFormProps { 8 | boardDetails: BoardDetails; 9 | } 10 | 11 | const SearchForm: React.FC = ({ boardDetails }) => { 12 | const items = [ 13 | { 14 | key: 'filters', 15 | label: 'Filters', 16 | children: 17 | }, 18 | { 19 | key: 'holds', 20 | label: 'Search by Hold', 21 | children: 22 | } 23 | ]; 24 | 25 | return ; 26 | }; 27 | 28 | export default SearchForm; 29 | -------------------------------------------------------------------------------- /app/components/search-drawer/use-heatmap.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { BoardName, SearchRequestPagination } from '@/app/lib/types'; 3 | import { HeatmapData } from '../board-renderer/types'; 4 | import { searchParamsToUrlParams } from '@/app/lib/url-utils'; 5 | 6 | interface UseHeatmapDataProps { 7 | boardName: BoardName; 8 | layoutId: number; 9 | sizeId: number; 10 | setIds: string; 11 | angle: number; 12 | filters: SearchRequestPagination; 13 | } 14 | 15 | export default function useHeatmapData({ 16 | boardName, 17 | layoutId, 18 | sizeId, 19 | setIds, 20 | angle, 21 | filters 22 | }: UseHeatmapDataProps) { 23 | const [heatmapData, setHeatmapData] = useState([]); 24 | const [loading, setLoading] = useState(true); 25 | const [error, setError] = useState(null); 26 | 27 | useEffect(() => { 28 | const fetchHeatmapData = async () => { 29 | try { 30 | setLoading(true); 31 | const response = await fetch( 32 | `/api/v1/${boardName}/${layoutId}/${sizeId}/${setIds}/${angle}/heatmap?${searchParamsToUrlParams(filters).toString()}` 33 | ); 34 | 35 | if (!response.ok) { 36 | throw new Error('Failed to fetch heatmap data'); 37 | } 38 | 39 | const data = await response.json(); 40 | setHeatmapData(data.holdStats); 41 | setError(null); 42 | } catch (err) { 43 | setError(err instanceof Error ? err : new Error('Unknown error')); 44 | console.error('Error fetching heatmap data:', err); 45 | } finally { 46 | setLoading(false); 47 | } 48 | }; 49 | 50 | fetchHeatmapData(); 51 | }, [boardName, layoutId, sizeId, setIds, angle, filters]); 52 | 53 | return { data: heatmapData, loading, error }; 54 | } -------------------------------------------------------------------------------- /app/components/setup-wizard/angle-selection.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React from 'react'; 4 | import { useRouter } from 'next/navigation'; 5 | import { Select, Button, Typography, Form } from 'antd'; 6 | import { ANGLES } from '@/app/lib/board-data'; 7 | import { BoardName } from '@/app/lib/types'; 8 | 9 | const { Title } = Typography; 10 | const { Option } = Select; 11 | 12 | const AngleSelection = ({ board_name }: { board_name: BoardName }) => { 13 | const router = useRouter(); 14 | const [angle, setAngle] = React.useState(40); 15 | 16 | const handleAngleChange = (value: string) => { 17 | setAngle(Number(value)); 18 | }; 19 | 20 | const handleNext = () => { 21 | router.push(`${window.location.pathname}/${angle}/list`); 22 | }; 23 | 24 | return ( 25 |
26 | Select an angle 27 | 28 | 29 | 36 | 37 | 38 | 41 |
42 | ); 43 | }; 44 | 45 | export default AngleSelection; 46 | -------------------------------------------------------------------------------- /app/components/setup-wizard/board-selection.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React from 'react'; 4 | import { useRouter } from 'next/navigation'; 5 | import { Select, Button, Typography, Form } from 'antd'; 6 | import { SUPPORTED_BOARDS } from '@/app/lib/board-data'; 7 | 8 | const { Title } = Typography; 9 | const { Option } = Select; 10 | 11 | const BoardSelection = () => { 12 | const router = useRouter(); 13 | const [selectedBoard, setSelectedBoard] = React.useState('kilter'); 14 | 15 | const handleBoardChange = (value: string) => { 16 | setSelectedBoard(value); 17 | }; 18 | 19 | const handleNext = () => { 20 | router.push(`/${selectedBoard}`); 21 | }; 22 | 23 | return ( 24 |
25 | Select a board 26 |
27 | 28 | 35 | 36 |
37 | 40 |
41 | ); 42 | }; 43 | 44 | export default BoardSelection; 45 | -------------------------------------------------------------------------------- /app/components/setup-wizard/layout-selection.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import React, { useState } from 'react'; 3 | import { Button, Form, Select, Typography, Input, Divider } from 'antd'; 4 | import { useRouter } from 'next/navigation'; 5 | import { LayoutRow } from '@/app/lib/data/queries'; 6 | import { useBoardProvider } from '../board-provider/board-provider-context'; 7 | import { BoardName } from '@/app/lib/types'; 8 | 9 | const { Option } = Select; 10 | const { Title, Text } = Typography; 11 | 12 | const LayoutSelection = ({ layouts = [], boardName }: { layouts: LayoutRow[]; boardName: BoardName }) => { 13 | const router = useRouter(); 14 | const { login, isAuthenticated } = useBoardProvider(); 15 | const [selectedLayout, setSelectedLayout] = useState(); 16 | const [isLoggingIn, setIsLoggingIn] = useState(false); 17 | const [username, setUsername] = useState(''); 18 | const [password, setPassword] = useState(''); 19 | 20 | const onLayoutChange = (value: number) => { 21 | setSelectedLayout(value); 22 | }; 23 | 24 | const handleNext = () => { 25 | router.push(`${window.location.pathname}/${selectedLayout}`); 26 | }; 27 | 28 | const handleLogin = async () => { 29 | if (!username || !password) return; 30 | 31 | setIsLoggingIn(true); 32 | try { 33 | await login(boardName, username, password); 34 | } catch (error) { 35 | console.error('Login failed:', error); 36 | } finally { 37 | setIsLoggingIn(false); 38 | } 39 | }; 40 | 41 | return ( 42 |
43 | Select a layout 44 |
45 | 46 | 53 | 54 | 55 | 56 | Optional Login 57 | 58 | 59 | {isAuthenticated ? ( 60 |
61 | Logged in to {boardName} board 62 |
63 | ) : ( 64 |
65 | 66 | setUsername(e.target.value)} /> 67 | 68 | 69 | 70 | setPassword(e.target.value)} 74 | /> 75 | 76 | 77 | 80 |
81 | )} 82 | 83 | 86 |
87 |
88 | ); 89 | }; 90 | 91 | export default LayoutSelection; 92 | -------------------------------------------------------------------------------- /app/components/setup-wizard/sets-selection.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import React, { useState } from 'react'; 3 | import { Button, Form, Select, Typography } from 'antd'; 4 | import { useRouter } from 'next/navigation'; 5 | import { SetRow } from '@/app/lib/data/queries'; 6 | 7 | const { Option } = Select; 8 | const { Title } = Typography; 9 | 10 | const SetsSelection = ({ sets = [] }: { sets: SetRow[] }) => { 11 | const router = useRouter(); 12 | const [selectedSize, setSelectedSize] = useState(); 13 | 14 | const handleNext = () => { 15 | router.push(`${window.location.pathname}/${selectedSize}`); 16 | }; 17 | 18 | return ( 19 |
20 | Select Hold Sets 21 |
22 | 23 | 30 | 31 | 40 |
41 |
42 | ); 43 | }; 44 | 45 | export default SetsSelection; 46 | -------------------------------------------------------------------------------- /app/components/setup-wizard/size-selection.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import React, { useState } from 'react'; 3 | import { Button, Form, Select, Typography } from 'antd'; 4 | import { useRouter } from 'next/navigation'; 5 | import { SizeRow } from '@/app/lib/data/queries'; 6 | 7 | const { Option } = Select; 8 | const { Title } = Typography; 9 | 10 | const SizeSelection = ({ sizes = [] }: { sizes: SizeRow[] }) => { 11 | const router = useRouter(); 12 | const [selectedSize, setSelectedSize] = useState(); 13 | 14 | const handleNext = () => { 15 | if (selectedSize) { 16 | router.push(`${window.location.pathname}/${selectedSize}`); 17 | } 18 | }; 19 | 20 | return ( 21 |
22 | Select a size 23 |
24 | 25 | 32 | 33 | 42 |
43 |
44 | ); 45 | }; 46 | 47 | export default SizeSelection; 48 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/app/favicon.ico -------------------------------------------------------------------------------- /app/fonts/GeistMonoVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/app/fonts/GeistMonoVF.woff -------------------------------------------------------------------------------- /app/fonts/GeistVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/app/fonts/GeistVF.woff -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | // app/layout.tsx (or app/_app.tsx if you are using a global layout) 2 | import React from 'react'; 3 | import { AntdRegistry } from '@ant-design/nextjs-registry'; 4 | import { App } from 'antd'; 5 | import { Analytics } from '@vercel/analytics/react'; 6 | import WebBluetoothWarning from './components/board-bluetooth-control/web-bluetooth-warning'; 7 | 8 | export default function RootLayout({ children }: { children: React.ReactNode }) { 9 | return ( 10 | 11 | 12 | 13 | 14 | 15 | 16 | {children} 17 | 18 | 19 | 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /app/lib/api-wrappers/aurora/explore.ts: -------------------------------------------------------------------------------- 1 | import { BoardName } from '../../types'; 2 | import { API_HOSTS } from './types'; 3 | 4 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 5 | export async function explore(board: BoardName, token: string): Promise { 6 | const response = await fetch(`${API_HOSTS[board]}/explore`, { 7 | headers: { authorization: `Bearer ${token}` }, 8 | }); 9 | if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); 10 | return response.json(); 11 | } 12 | -------------------------------------------------------------------------------- /app/lib/api-wrappers/aurora/getBidsLogbook.ts: -------------------------------------------------------------------------------- 1 | import { BoardName } from '../../types'; 2 | import { userSync } from './userSync'; 3 | 4 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 5 | export async function getBidsLogbook(board: BoardName, token: string, userId: string): Promise { 6 | const syncResults = await userSync(board, Number(userId), { tables: ['bids'] }, token); 7 | return syncResults.PUT.bids || []; 8 | } 9 | -------------------------------------------------------------------------------- /app/lib/api-wrappers/aurora/getClimbName.ts: -------------------------------------------------------------------------------- 1 | import { BoardName } from '../../types'; 2 | import { WEB_HOSTS } from './types'; 3 | 4 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 5 | export async function getClimbName(board: BoardName, climbId: string): Promise { 6 | const response = await fetch(`${WEB_HOSTS[board]}/climbs/${climbId}`); 7 | if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); 8 | const text = await response.text(); 9 | const match = text.match(/]*>([^<]+)<\/h1>/); 10 | return match ? match[1].trim() : null; 11 | } 12 | -------------------------------------------------------------------------------- /app/lib/api-wrappers/aurora/getClimbStats.ts: -------------------------------------------------------------------------------- 1 | import { BoardName } from '../../types'; 2 | import { API_HOSTS, ClimbStats } from './types'; 3 | 4 | export async function getClimbStats( 5 | board: BoardName, 6 | token: string, 7 | climbId: string, 8 | angle: number, 9 | ): Promise { 10 | const response = await fetch(`${API_HOSTS[board]}/v1/climbs/${climbId}/info?angle=${angle}`, { 11 | headers: { authorization: `Bearer ${token}` }, 12 | }); 13 | if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); 14 | return response.json(); 15 | } 16 | -------------------------------------------------------------------------------- /app/lib/api-wrappers/aurora/getGrades.ts: -------------------------------------------------------------------------------- 1 | import { BoardName } from '../../types'; 2 | import { sharedSync } from './sharedSync'; 3 | 4 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 5 | export async function getGrades(board: BoardName): Promise { 6 | const syncResults = await sharedSync(board, { tables: ['difficulty_grades'] }); 7 | return syncResults.PUT.difficulty_grades; 8 | } 9 | -------------------------------------------------------------------------------- /app/lib/api-wrappers/aurora/getGyms.ts: -------------------------------------------------------------------------------- 1 | import { BoardName } from '../../types'; 2 | import { API_HOSTS, GymInfo } from './types'; 3 | 4 | export async function getGyms(board: BoardName): Promise<{ gyms: GymInfo[] }> { 5 | const response = await fetch(`${API_HOSTS[board]}/v1/pins?types=gym`); 6 | if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); 7 | return response.json(); 8 | } 9 | -------------------------------------------------------------------------------- /app/lib/api-wrappers/aurora/saveAscent.ts: -------------------------------------------------------------------------------- 1 | import { BoardName } from '../../types'; 2 | import { API_HOSTS, AscentSavedEvent, SaveAscentOptions, SaveAscentResponse } from './types'; 3 | import dayjs from 'dayjs'; 4 | import { sql } from '@/app/lib/db/db'; 5 | import { getTableName } from '../../data-sync/aurora/getTableName'; 6 | 7 | export async function saveAscent( 8 | board: BoardName, 9 | token: string, 10 | options: SaveAscentOptions, 11 | ): Promise { 12 | // Convert the ISO date to the required format "YYYY-MM-DD HH:mm:ss" 13 | const formattedDate = dayjs(options.climbed_at).format('YYYY-MM-DD HH:mm:ss'); 14 | 15 | const requestBody = { 16 | uuid: options.uuid, 17 | angle: options.angle, 18 | attempt_id: options.attempt_id, 19 | bid_count: options.bid_count, 20 | climb_uuid: options.climb_uuid, 21 | climbed_at: formattedDate, 22 | comment: options.comment, 23 | difficulty: options.difficulty, 24 | is_benchmark: options.is_benchmark, 25 | is_mirror: options.is_mirror, 26 | quality: options.quality, 27 | user_id: options.user_id, 28 | }; 29 | 30 | // Make the upstream API request 31 | const response = await fetch(`${API_HOSTS[board]}/v1/ascents/${options.uuid}`, { 32 | method: 'PUT', 33 | headers: { 34 | 'Content-Type': 'application/json', 35 | authorization: `Bearer ${token}`, 36 | }, 37 | body: JSON.stringify(requestBody), 38 | }); 39 | 40 | if (!response.ok) { 41 | const errorData = await response.json(); 42 | console.error('Error response:', { 43 | status: response.status, 44 | statusText: response.statusText, 45 | errors: errorData, 46 | }); 47 | throw new Error(`HTTP error! status: ${response.status}, details: ${JSON.stringify(errorData)}`); 48 | } 49 | 50 | const ascent: SaveAscentResponse = await response.json(); 51 | const savedAscentEvent = ascent.events.find((event): event is AscentSavedEvent => event._type === 'ascent_saved'); 52 | 53 | if (!savedAscentEvent) { 54 | throw new Error('Failed to save ascent'); 55 | } 56 | 57 | // Insert into the intermediate database 58 | const fullTableName = getTableName(board, 'ascents'); // Replace with your actual table name 59 | 60 | const params = [ 61 | requestBody.uuid, 62 | requestBody.climb_uuid, 63 | requestBody.angle, 64 | requestBody.is_mirror, 65 | requestBody.user_id, 66 | requestBody.attempt_id, 67 | requestBody.bid_count || 1, 68 | requestBody.quality, 69 | requestBody.difficulty, 70 | requestBody.is_benchmark || 0, 71 | requestBody.comment || '', 72 | requestBody.climbed_at, 73 | savedAscentEvent.ascent.created_at, // Assuming `created_at` is now 74 | ]; 75 | 76 | await sql.query( 77 | ` 78 | INSERT INTO ${fullTableName} ( 79 | uuid, climb_uuid, angle, is_mirror, user_id, attempt_id, 80 | bid_count, quality, difficulty, is_benchmark, comment, 81 | climbed_at, created_at 82 | ) 83 | VALUES ( 84 | $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13 85 | ) 86 | ON CONFLICT (uuid) DO UPDATE SET 87 | climb_uuid = EXCLUDED.climb_uuid, 88 | angle = EXCLUDED.angle, 89 | is_mirror = EXCLUDED.is_mirror, 90 | attempt_id = EXCLUDED.attempt_id, 91 | bid_count = EXCLUDED.bid_count, 92 | quality = EXCLUDED.quality, 93 | difficulty = EXCLUDED.difficulty, 94 | is_benchmark = EXCLUDED.is_benchmark, 95 | comment = EXCLUDED.comment, 96 | climbed_at = EXCLUDED.climbed_at; 97 | `, 98 | params, 99 | ); 100 | 101 | return ascent; 102 | } 103 | -------------------------------------------------------------------------------- /app/lib/api-wrappers/aurora/saveAttempt.ts: -------------------------------------------------------------------------------- 1 | import { BoardName } from '../../types'; 2 | import { API_HOSTS, SaveAttemptOptions } from './types'; 3 | import { generateUuid } from './util'; 4 | 5 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 6 | export async function saveAttempt(board: BoardName, token: string, options: SaveAttemptOptions): Promise { 7 | const uuid = generateUuid(); 8 | const response = await fetch(`${API_HOSTS[board]}/v1/bids/${uuid}`, { 9 | method: 'PUT', 10 | headers: { 11 | 'Content-Type': 'application/json', 12 | authorization: `Bearer ${token}`, 13 | }, 14 | body: JSON.stringify({ 15 | uuid, 16 | ...options, 17 | }), 18 | }); 19 | if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); 20 | return response.json(); 21 | } 22 | -------------------------------------------------------------------------------- /app/lib/api-wrappers/aurora/saveClimb.ts: -------------------------------------------------------------------------------- 1 | import { BoardName } from '../../types'; 2 | import { API_HOSTS, SaveClimbOptions } from './types'; 3 | 4 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 5 | export async function saveClimb(board: BoardName, token: string, options: SaveClimbOptions): Promise { 6 | const uuid = generateUuid(); 7 | const response = await fetch(`${API_HOSTS[board]}/v2/climbs/${uuid}`, { 8 | method: 'PUT', 9 | headers: { 10 | 'Content-Type': 'application/json', 11 | authorization: `Bearer ${token}`, 12 | }, 13 | body: JSON.stringify({ 14 | uuid, 15 | ...options, 16 | }), 17 | }); 18 | if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); 19 | return response.json(); 20 | } 21 | function generateUuid() { 22 | throw new Error('Function not implemented.'); 23 | } 24 | -------------------------------------------------------------------------------- /app/lib/api-wrappers/aurora/sharedSync.ts: -------------------------------------------------------------------------------- 1 | import { BoardName } from '../../types'; 2 | import { SyncData } from '../sync-api-types'; 3 | import { API_HOSTS, SyncOptions } from './types'; 4 | 5 | export async function sharedSync( 6 | board: BoardName, 7 | options: Omit = {}, 8 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 9 | ): Promise { 10 | const { tables = [], sharedSyncs = [] } = options; 11 | 12 | const response = await fetch(`${API_HOSTS[board]}/v1/sync`, { 13 | method: 'POST', 14 | headers: { 'Content-Type': 'application/json' }, 15 | cache: 'no-store', 16 | body: JSON.stringify({ 17 | client: { 18 | enforces_product_passwords: 1, 19 | enforces_layout_passwords: 1, 20 | manages_power_responsibly: 1, 21 | ufd: 1, 22 | }, 23 | GET: { 24 | query: { 25 | syncs: { 26 | shared_syncs: sharedSyncs, 27 | }, 28 | tables, 29 | include_multiframe_climbs: 1, 30 | include_all_beta_links: 1, 31 | include_null_climb_stats: 1, 32 | }, 33 | }, 34 | }), 35 | }); 36 | if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); 37 | return response.json(); 38 | } 39 | -------------------------------------------------------------------------------- /app/lib/api-wrappers/aurora/user/follows-save.ts: -------------------------------------------------------------------------------- 1 | // POST https://kilterboardapp.com/follows/save HTTP/2 2 | // host: kilterboardapp.com 3 | // accept: application/json 4 | // content-type: application/x-www-form-urlencoded 5 | // user-agent: Kilter%20Board/300 CFNetwork/1568.200.51 Darwin/24.1.0 6 | // accept-language: en-AU,en;q=0.9 7 | // content-length: 50 8 | // accept-encoding: gzip, deflate, br 9 | // cookie: token=XXXX 10 | 11 | // followee_id=44710&follower_id=118684&state=pending 12 | -------------------------------------------------------------------------------- /app/lib/api-wrappers/aurora/user/getFollowees.ts: -------------------------------------------------------------------------------- 1 | import { BoardName } from '../../../types'; 2 | import { API_HOSTS } from '../types'; 3 | import { auroraGetApi } from '../util'; 4 | 5 | export interface Followee { 6 | id: number; // Unique ID for the followee 7 | username: string; // Username of the followee 8 | name?: string; // Optional name of the followee 9 | avatar_image?: string; // Optional avatar image path 10 | followee_state: string; // State of the followee relationship (e.g., "accepted") 11 | } 12 | 13 | export interface FolloweesResponse { 14 | users: Followee[]; // Array of followees 15 | } 16 | 17 | export async function getFollowees(board: BoardName, userId: number, token: string): Promise { 18 | // Replace `any` with the specific type for followees if available 19 | const url = `${API_HOSTS[board]}/users/${userId}/followees`; // Adjust the endpoint as needed 20 | const data = await auroraGetApi(url, token); 21 | 22 | return data; 23 | } 24 | -------------------------------------------------------------------------------- /app/lib/api-wrappers/aurora/user/getLogbook.ts: -------------------------------------------------------------------------------- 1 | // GET https://kilterboardapp.com/users/44710/logbook?types=ascent,bid HTTP/2 2 | // host: kilterboardapp.com 3 | // accept: application/json 4 | // user-agent: Kilter%20Board/300 CFNetwork/1568.200.51 Darwin/24.1.0 5 | // accept-language: en-AU,en;q=0.9 6 | // accept-encoding: gzip, deflate, br 7 | // cookie: token=XXXX 8 | 9 | // Common fields for all logbook entries 10 | interface BaseLogbookEntry { 11 | _type: 'bid' | 'ascent'; // Discriminator type, e.g., "bid" or "ascent" 12 | uuid: string; // Unique identifier for the logbook entry 13 | user_id: number; // ID of the user who made the entry 14 | climb_uuid: string; // Unique identifier for the climb 15 | angle: number; // Angle of the climb 16 | is_mirror: boolean; // Indicates if the climb was mirrored 17 | bid_count: number; // Number of bids/attempts 18 | comment: string; // Comment for the entry (empty string if none) 19 | climbed_at: string; // ISO 8601 date string for when the climb occurred 20 | } 21 | 22 | // Logbook entry type for "bid" 23 | export interface BidLogbookEntry extends BaseLogbookEntry { 24 | _type: 'bid'; // Specific type for bid entries 25 | } 26 | 27 | // Logbook entry type for "ascent" 28 | export interface AscentLogbookEntry extends BaseLogbookEntry { 29 | _type: 'ascent'; // Specific type for ascent entries 30 | attempt_id: number; // ID of the attempt (specific to ascents) 31 | quality: number; // Quality rating of the climb (1-5) 32 | difficulty: number; // Difficulty rating of the climb 33 | is_benchmark: boolean; // Indicates if the climb is a benchmark climb 34 | } 35 | 36 | // Union type for all logbook entries 37 | export type LogbookEntry = BidLogbookEntry | AscentLogbookEntry; 38 | 39 | // Response type for the logbook endpoint 40 | export interface LogbookResponse { 41 | logbook: LogbookEntry[]; // Array of logbook entries (union type) 42 | } 43 | -------------------------------------------------------------------------------- /app/lib/api-wrappers/aurora/user/getUser.ts: -------------------------------------------------------------------------------- 1 | import { BoardName } from '../../../types'; 2 | import { API_HOSTS } from '../types'; 3 | import { auroraGetApi } from '../util'; 4 | 5 | export interface SocialStats { 6 | followees_accepted: number; 7 | followers_accepted: number; 8 | followers_pending: number; 9 | } 10 | 11 | export interface Logbook { 12 | count: number; // Number of logbook entries 13 | } 14 | 15 | export interface CircuitUser { 16 | id: number; 17 | username: string; 18 | is_verified: boolean; 19 | avatar_image: string | null; 20 | created_at: string; // ISO 8601 date string 21 | } 22 | 23 | export interface Circuit { 24 | uuid: string; 25 | name: string; 26 | description: string; 27 | color: string; 28 | user_id: number; 29 | is_public: boolean; 30 | is_listed: boolean; 31 | created_at: string; 32 | updated_at: string; 33 | user: CircuitUser; 34 | count: number; 35 | } 36 | 37 | export interface User { 38 | id: number; 39 | username: string; 40 | email_address: string; 41 | name: string; 42 | avatar_image: string | null; // Nullable avatar image 43 | instagram_username?: string; // Optional Instagram username 44 | is_public: boolean; // Indicates if the profile is public 45 | is_verified: boolean; // Indicates if the user is verified 46 | created_at: string; // ISO 8601 date string for creation 47 | updated_at: string; // ISO 8601 date string for last update 48 | social: SocialStats; // Social stats (followees, followers, etc.) 49 | logbook: Logbook; // Logbook stats 50 | circuits: Circuit[]; // Array of circuits created by the user 51 | } 52 | 53 | // Avatar url: https://api.kilterboardapp.com/img/avatars/74336-20220729204756.jpg 54 | 55 | export interface UsersResponse { 56 | users: User[]; // List of users 57 | } 58 | 59 | export async function getUser(board: BoardName, userId: number, token: string): Promise { 60 | const url = `${API_HOSTS[board]}/users/${userId}`; 61 | const data = await auroraGetApi(url, token); 62 | 63 | return data; 64 | } 65 | -------------------------------------------------------------------------------- /app/lib/api-wrappers/aurora/userSync.ts: -------------------------------------------------------------------------------- 1 | import { BoardName } from '../../types'; 2 | import { SyncData } from '../sync-api-types'; 3 | import { API_HOSTS, SyncOptions } from './types'; 4 | 5 | //TODO: Can probably be consolidated with sharedSync 6 | export async function userSync( 7 | board: BoardName, 8 | userId: number, 9 | options: SyncOptions = {}, 10 | token: string, 11 | ): Promise { 12 | const { tables = [], walls = [], wallExpungements = [], sharedSyncs = [], userSyncs = [] } = options; 13 | 14 | const response = await fetch(`${API_HOSTS[board]}/v1/sync`, { 15 | method: 'POST', 16 | cache: 'no-store', 17 | headers: { 18 | 'Content-Type': 'application/json', 19 | authorization: `Bearer ${token}`, 20 | }, 21 | body: JSON.stringify({ 22 | client: { 23 | enforces_product_passwords: 1, 24 | enforces_layout_passwords: 1, 25 | manages_power_responsibly: 1, 26 | ufd: 1, 27 | }, 28 | GET: { 29 | query: { 30 | syncs: { 31 | shared_syncs: sharedSyncs, 32 | user_syncs: userSyncs, 33 | }, 34 | tables, 35 | user_id: userId, 36 | include_multiframe_climbs: 1, 37 | include_all_beta_links: 1, 38 | include_null_climb_stats: 1, 39 | }, 40 | }, 41 | PUT: { 42 | walls, 43 | wall_expungements: wallExpungements, 44 | }, 45 | }), 46 | }); 47 | 48 | if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); 49 | 50 | return response.json(); 51 | } 52 | -------------------------------------------------------------------------------- /app/lib/api-wrappers/aurora/util.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from 'uuid'; 2 | import { promisify } from 'util'; 3 | import { unzip } from 'zlib'; 4 | 5 | const unzipAsync = promisify(unzip); 6 | 7 | export function generateUuid(): string { 8 | return uuidv4().replace(/-/g, '').toUpperCase(); 9 | } 10 | 11 | export async function auroraGetApi(url: string, token: string): Promise { 12 | // Default headers 13 | const headers: Record = { 14 | Accept: '*/*', // Accept any content type 15 | 'Accept-Encoding': 'gzip, deflate, br', 16 | Host: 'kilterboardapp.com', // Explicitly set the host 17 | 'User-Agent': 'Kilter%20Board/300 CFNetwork/1568.200.51 Darwin/24.1.0', // Simulate the specific user-agent 18 | 'Accept-Language': 'en-AU,en;q=0.9', // Accept preferred languages 19 | }; 20 | 21 | // Add Authorization header if token is provided 22 | if (token) { 23 | headers['Cookie'] = `token=${token}`; 24 | } 25 | 26 | const fetchOptions: RequestInit = { 27 | method: 'GET', 28 | headers, 29 | }; 30 | 31 | const response = await fetch(url, fetchOptions); 32 | 33 | if (!response.ok) { 34 | throw new Error(`HTTP error! status: ${response.status}`); 35 | } 36 | 37 | // Handle compressed responses 38 | const contentEncoding = response.headers.get('content-encoding'); 39 | if (contentEncoding === 'gzip' || contentEncoding === 'br' || contentEncoding === 'deflate') { 40 | const buffer = Buffer.from(await response.arrayBuffer()); 41 | const decompressed = await unzipAsync(buffer); // Decompress asynchronously 42 | return JSON.parse(decompressed.toString()) as T; // Parse JSON from decompressed data 43 | } 44 | 45 | // Handle plain JSON response 46 | return response.json() as Promise; 47 | } 48 | -------------------------------------------------------------------------------- /app/lib/data-sync/aurora/getTableName.ts: -------------------------------------------------------------------------------- 1 | import { BoardName } from '../../types'; 2 | 3 | export const getTableName = (boardName: BoardName, tableName: string) => { 4 | if (!boardName) { 5 | throw new Error('Boardname is required, but received falsey'); 6 | } 7 | switch (boardName) { 8 | case 'tension': 9 | case 'kilter': 10 | return `${boardName}_${tableName}`; 11 | default: 12 | return tableName; 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /app/lib/data/get-logbook.ts: -------------------------------------------------------------------------------- 1 | import { sql } from '@/app/lib/db/db'; 2 | import { BoardName, ClimbUuid } from '../types'; 3 | import { LogbookEntry } from '../api-wrappers/aurora/types'; 4 | import { getTableName } from '../data-sync/aurora/getTableName'; 5 | 6 | export async function getLogbook(board: BoardName, userId: string, climbUuids?: ClimbUuid[]): Promise { 7 | const ascentsTable = getTableName(board, 'ascents'); 8 | const bidsTable = getTableName(board, 'bids'); 9 | 10 | if (climbUuids && climbUuids.length > 0) { 11 | // If climbUuids are provided 12 | const combinedLogbook = await sql.query( 13 | ` 14 | SELECT 15 | uuid, 16 | climb_uuid, 17 | angle, 18 | is_mirror, 19 | user_id, 20 | attempt_id, 21 | bid_count AS tries, 22 | quality, 23 | difficulty, 24 | is_benchmark::boolean, 25 | comment, 26 | climbed_at, 27 | created_at, 28 | TRUE::boolean AS is_ascent 29 | FROM ${ascentsTable} 30 | WHERE user_id = $1 31 | AND climb_uuid = ANY($2) 32 | 33 | UNION ALL 34 | 35 | SELECT 36 | uuid, 37 | climb_uuid, 38 | angle, 39 | is_mirror, 40 | user_id, 41 | NULL AS attempt_id, 42 | bid_count AS tries, 43 | NULL AS quality, 44 | NULL AS difficulty, 45 | FALSE::boolean AS is_benchmark, 46 | comment, 47 | climbed_at, 48 | created_at, 49 | FALSE::boolean AS is_ascent 50 | FROM ${bidsTable} 51 | WHERE user_id = $1 52 | AND climb_uuid = ANY($2) 53 | 54 | ORDER BY climbed_at DESC 55 | `, 56 | [userId, climbUuids], 57 | ); 58 | 59 | return combinedLogbook.rows; 60 | } else { 61 | // If climbUuids are not provided 62 | const combinedLogbook = await sql.query( 63 | ` 64 | SELECT * FROM ( 65 | SELECT 66 | uuid, 67 | climb_uuid, 68 | angle, 69 | is_mirror, 70 | user_id, 71 | attempt_id, 72 | bid_count AS tries, 73 | quality, 74 | difficulty, 75 | is_benchmark::boolean, 76 | comment, 77 | climbed_at, 78 | created_at, 79 | TRUE AS is_ascent 80 | FROM ${ascentsTable} 81 | WHERE user_id = $1 82 | 83 | UNION ALL 84 | 85 | SELECT 86 | uuid, 87 | climb_uuid, 88 | angle, 89 | is_mirror, 90 | user_id, 91 | NULL AS attempt_id, 92 | bid_count AS tries, 93 | NULL AS quality, 94 | NULL AS difficulty, 95 | FALSE AS is_benchmark, 96 | comment, 97 | climbed_at, 98 | created_at, 99 | FALSE AS is_ascent 100 | FROM ${bidsTable} 101 | WHERE user_id = $1 102 | 103 | ORDER BY climbed_at DESC 104 | ) subquery 105 | WHERE difficulty IS NOT NULL; 106 | `, 107 | [userId], 108 | ); 109 | 110 | return combinedLogbook.rows; 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /app/lib/db/db.ts: -------------------------------------------------------------------------------- 1 | import { neonConfig } from '@neondatabase/serverless'; 2 | import { drizzle } from 'drizzle-orm/vercel-postgres'; 3 | 4 | if (process.env.VERCEL_ENV === 'development') { 5 | neonConfig.wsProxy = (host) => `${host}:54330/v1`; 6 | neonConfig.useSecureWebSocket = false; 7 | neonConfig.pipelineTLS = false; 8 | neonConfig.pipelineConnect = false; 9 | } 10 | 11 | export * from '@vercel/postgres'; 12 | 13 | export const dbz = drizzle({ 14 | logger: process.env.VERCEL_ENV === 'development', 15 | }); 16 | -------------------------------------------------------------------------------- /app/lib/db/queries/climbs/Untitled: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/app/lib/db/queries/climbs/Untitled -------------------------------------------------------------------------------- /app/lib/session.ts: -------------------------------------------------------------------------------- 1 | import { getIronSession } from 'iron-session'; 2 | import 'server-only'; 3 | import { SerializeOptions as CookieSerializeOptions } from 'cookie'; 4 | import { BoardName } from './types'; 5 | 6 | /** 7 | * {@link https://wicg.github.io/cookie-store/#dictdef-cookielistitem CookieListItem} 8 | * as specified by W3C. 9 | */ 10 | interface CookieListItem extends Pick { 11 | /** A string with the name of a cookie. */ 12 | name: string; 13 | /** A string containing the value of the cookie. */ 14 | value: string; 15 | /** A number of milliseconds or Date interface containing the expires of the cookie. */ 16 | expires?: CookieSerializeOptions["expires"] | number; 17 | } 18 | 19 | /** 20 | * Superset of {@link CookieListItem} extending it with 21 | * the `httpOnly`, `maxAge` and `priority` properties. 22 | */ 23 | type ResponseCookie = CookieListItem & Pick; 24 | /** 25 | * The high-level type definition of the .get() and .set() methods 26 | * of { cookies() } from "next/headers" 27 | */ 28 | interface CookieStore { 29 | get: (name: string) => { 30 | name: string; 31 | value: string; 32 | } | undefined; 33 | set: { 34 | (name: string, value: string, cookie?: Partial): void; 35 | (options: ResponseCookie): void; 36 | }; 37 | } 38 | 39 | interface BoardSessionData { 40 | token: string; 41 | username: string; 42 | password: string; 43 | userId: number; 44 | } 45 | 46 | export const getSession = async (cookies: CookieStore, boardName: BoardName) => { 47 | if (!process.env.IRON_SESSION_PASSWORD) { 48 | throw new Error("IRON_SESSION_PASSWORD is not set"); 49 | } 50 | const password = JSON.parse(process.env.IRON_SESSION_PASSWORD); 51 | return getIronSession(cookies, { 52 | password, 53 | cookieName: `${boardName}_session`, 54 | }); 55 | } -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import BoardSelection from './components/setup-wizard/board-selection'; 3 | 4 | export default function Home() { 5 | return ; 6 | } 7 | -------------------------------------------------------------------------------- /app/robots.ts: -------------------------------------------------------------------------------- 1 | import type { MetadataRoute } from 'next'; 2 | 3 | export default function robots(): MetadataRoute.Robots { 4 | return { 5 | rules: { 6 | userAgent: '*', 7 | allow: '/', 8 | }, 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /db/README.md: -------------------------------------------------------------------------------- 1 | # DB Setup 2 | 3 | Boardsesh uses [Boardlib](https://github.com/lemeryfertitta/BoardLib) for downloading the databases. 4 | They are then stored in postgres, but the tablenames are prefixed with the boardname, i.e: 5 | kilter_climbs 6 | kilter_climbstats 7 | tension_climbs 8 | tension_climbstats 9 | etc... 10 | 11 | At some point it would be good to add support to Boardlib for syncing the postgres database. 12 | But for now, we just drop everything and recreated it. 13 | 14 | ## Development setup 15 | 16 | Run docker to startup the development database: 17 | 18 | ``` 19 | docker-compose up 20 | ``` 21 | 22 | This starts up a docker container that uses Boardlib to download the databases and then loads them into postgres with an db update script and pgloader. 23 | -------------------------------------------------------------------------------- /db/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | postgres: 3 | image: postgres 4 | environment: 5 | POSTGRES_PASSWORD: password 6 | POSTGRES_USER: postgres 7 | POSTGRES_DB: verceldb 8 | ports: 9 | - '54320:5432' 10 | volumes: 11 | - ./db-scripts:/docker-entrypoint-initdb.d 12 | 13 | pg_proxy: 14 | image: ghcr.io/neondatabase/wsproxy:latest 15 | environment: 16 | APPEND_PORT: 'postgres:5432' 17 | ALLOW_ADDR_REGEX: '.*' 18 | LOG_TRAFFIC: 'true' 19 | ports: 20 | - '54330:80' 21 | depends_on: 22 | - postgres 23 | 24 | db_setup: 25 | image: python:3.9 26 | volumes: 27 | - ./:/db 28 | - ./../drizzle:/drizzle 29 | build: 30 | context: . 31 | environment: 32 | - POSTGRES_URL=postgres://postgres:password@postgres:5432/verceldb 33 | - POSTGRES_USER=postgres 34 | - POSTGRES_PASSWORD=password 35 | - POSTGRES_HOST=postgres 36 | - POSTGRES_DATABASE=verceldb 37 | - POSTGRES_URL_NON_POOLING=postgres://postgres:password@postgres:5432/verceldb 38 | depends_on: 39 | - postgres 40 | entrypoint: | 41 | /bin/sh -c " 42 | if [ ! -f /db/tmp/db-setup-complete.flag ]; then 43 | apt-get update && 44 | apt-get install -y pgloader postgresql-client sqlite3 ca-certificates && 45 | update-ca-certificates && 46 | /db/setup-development-db.sh && 47 | touch /db/tmp/db-setup-complete.flag 48 | else 49 | echo 'Database setup already completed. Skipping.' 50 | fi 51 | " 52 | -------------------------------------------------------------------------------- /db/kilter_db.load: -------------------------------------------------------------------------------- 1 | LOAD DATABASE 2 | FROM sqlite://{{DB_FILE}} 3 | INTO {{DB_URL}} 4 | 5 | WITH include drop, create tables, no truncate, reset sequences 6 | SET work_mem to '16MB', maintenance_work_mem to '512 MB' 7 | 8 | -- After the load, run the SQL file to rename tables 9 | AFTER LOAD 10 | EXECUTE ./kilter_table_rename.sql; 11 | -------------------------------------------------------------------------------- /db/kilter_table_rename.sql: -------------------------------------------------------------------------------- 1 | DO $$ 2 | DECLARE 3 | r RECORD; 4 | BEGIN 5 | FOR r IN 6 | SELECT tablename 7 | FROM pg_tables 8 | WHERE schemaname = 'public' 9 | and tablename not like 'kilter_%' 10 | AND tablename not like 'tension_%' 11 | AND tablename not like 'boardsesh_%' 12 | LOOP 13 | EXECUTE 'DROP TABLE IF EXISTS ' || quote_ident('kilter_' || r.tablename); 14 | EXECUTE 'ALTER TABLE ' || quote_ident(r.tablename) || 15 | ' RENAME TO ' || quote_ident('kilter_' || r.tablename); 16 | END LOOP; 17 | END $$; 18 | -------------------------------------------------------------------------------- /db/setup-development-db.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | # Set up environment variables for the Postgres connection 6 | export PGHOST="${POSTGRES_HOST:-postgres}" # Defaults to 'postgres' service name 7 | export PGPORT="${POSTGRES_PORT:-5432}" 8 | export PGUSER="${POSTGRES_USER:-postgres}" 9 | export PGPASSWORD="${POSTGRES_PASSWORD:-password}" 10 | export PGDBNAME="${POSTGRES_DATABASE:-verceldb}" 11 | 12 | export DB_URL="postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}:${PGPORT}/${PGDBNAME}" 13 | 14 | # Optional: Specify the database to connect to for admin commands like creating a new database 15 | export PGDATABASE="postgres" # Connect to the default `postgres` database 16 | 17 | echo "Creating database if it doesnt exist" 18 | psql postgres -tAc "SELECT 1 FROM pg_database WHERE datname='verceldb'" | grep -q 1 && psql postgres -c "DROP DATABASE verceldb" 19 | psql postgres -c "CREATE DATABASE verceldb" 20 | 21 | psql $DB_URL -f ./drizzle/0000_cloudy_carlie_cooper.sql 22 | 23 | # echo "Using boardlib to download database" 24 | if [ ! -f "/db/tmp/kilter.db" ]; then 25 | echo "Downloading Kilterboard APK..." 26 | curl -o kilterboard.apk -L -A "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36" "https://d.apkpure.net/b/APK/com.auroraclimbing.kilterboard?version=latest" 27 | unzip -j kilterboard.apk assets/db.sqlite3 -d /db/tmp/ -n kilter.db 28 | fi 29 | 30 | # Check for tension database 31 | if [ ! -f "/db/tmp/tension.db" ]; then 32 | echo "Downloading Tensionboard APK..." 33 | curl -o tensionboard.apk -L -A "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36" "https://d.apkpure.net/b/APK/com.auroraclimbing.tensionboard2?version=latest" 34 | unzip -j tensionboard.apk assets/db.sqlite3 -d /db/tmp/ -n tension.db 35 | fi 36 | 37 | export TENSION_DB_FILE="/db/tmp/tension.modified.db" 38 | export KILTER_DB_FILE="/db/tmp/kilter.modified.db" 39 | 40 | # echo "Deleting modified database copies if they exist already" 41 | rm -rf $TENSION_DB_FILE 42 | rm -rf $KILTER_DB_FILE 43 | 44 | # echo "Copying databases before modification" 45 | cp /db/tmp/tension.db $TENSION_DB_FILE 46 | cp /db/tmp/kilter.db $KILTER_DB_FILE 47 | 48 | # PG Loader fails to converst the floats in climb_stats & climb_cache_fields 49 | # so we convert those to regular floats before running pgloader 50 | echo "Fixing problems in kilter SQLite Database" 51 | DB_FILE=$KILTER_DB_FILE /db/cleanup_sqlite_db_problems.sh 52 | 53 | echo "Fixing problems in tension SQLite Database" 54 | DB_FILE=$TENSION_DB_FILE /db/cleanup_sqlite_db_problems.sh 55 | 56 | echo "Running pgloader" 57 | DB_FILE=$TENSION_DB_FILE pgloader /db/tension_db.load 58 | 59 | DB_FILE=$KILTER_DB_FILE pgloader /db/kilter_db.load 60 | -------------------------------------------------------------------------------- /db/tension_db.load: -------------------------------------------------------------------------------- 1 | LOAD DATABASE 2 | FROM sqlite://{{DB_FILE}} 3 | INTO {{DB_URL}} 4 | 5 | WITH include drop, create tables, no truncate, reset sequences 6 | SET work_mem to '16MB', maintenance_work_mem to '512 MB' 7 | 8 | -- After the load, run the SQL file to rename tables 9 | AFTER LOAD 10 | EXECUTE ./tension_table_rename.sql; -------------------------------------------------------------------------------- /db/tension_table_rename.sql: -------------------------------------------------------------------------------- 1 | DO $$ 2 | DECLARE 3 | r RECORD; 4 | BEGIN 5 | FOR r IN 6 | SELECT tablename 7 | FROM pg_tables 8 | WHERE schemaname = 'public' 9 | and tablename not like 'kilter_%' 10 | AND tablename not like 'tension_%' 11 | AND tablename not like 'boardsesh_%' 12 | LOOP 13 | EXECUTE 'DROP TABLE IF EXISTS ' || quote_ident('tension_' || r.tablename); 14 | EXECUTE 'ALTER TABLE ' || quote_ident(r.tablename) || 15 | ' RENAME TO ' || quote_ident('tension_' || r.tablename); 16 | END LOOP; 17 | END $$; 18 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import { config } from 'dotenv'; 2 | import { defineConfig } from 'drizzle-kit'; 3 | import path from 'path'; 4 | 5 | // Load .env.development.local for local dev 6 | config({ path: path.resolve(process.cwd(), '.env.development.local') }); 7 | 8 | export default defineConfig({ 9 | out: './drizzle', 10 | schema: './app/lib/db/schema.ts', 11 | dialect: 'postgresql', 12 | dbCredentials: { 13 | host: process.env.POSTGRES_HOST!, 14 | database: process.env.POSTGRES_DATABASE!, 15 | port: Number(process.env.POSTGRES_PORT || 5432), 16 | user: process.env.POSTGRES_USER, 17 | password: process.env.POSTGRES_PASSWORD, 18 | ssl: process.env.VERCEL_ENV !== 'development', 19 | }, 20 | }); 21 | -------------------------------------------------------------------------------- /drizzle/0001_unique_climbstats.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "kilter_climb_stats" ALTER COLUMN "angle" SET DATA TYPE integer; 2 | --> statement-breakpoint 3 | ALTER TABLE "tension_climb_stats" ALTER COLUMN "angle" SET DATA TYPE integer; 4 | 5 | --> statement-breakpoin 6 | 7 | CREATE TABLE "kilter_climb_stats_history" ( 8 | "id" bigserial PRIMARY KEY NOT NULL, 9 | "climb_uuid" text NOT NULL, 10 | "angle" integer NOT NULL, 11 | "display_difficulty" double precision, 12 | "benchmark_difficulty" double precision, 13 | "ascensionist_count" bigint, 14 | "difficulty_average" double precision, 15 | "quality_average" double precision, 16 | "fa_username" text, 17 | "fa_at" timestamp, 18 | "created_at" timestamp DEFAULT now() NOT NULL 19 | ); 20 | --> statement-breakpoint 21 | CREATE TABLE "tension_climb_stats_history" ( 22 | "id" bigserial PRIMARY KEY NOT NULL, 23 | "climb_uuid" text NOT NULL, 24 | "angle" integer NOT NULL, 25 | "display_difficulty" double precision, 26 | "benchmark_difficulty" double precision, 27 | "ascensionist_count" bigint, 28 | "difficulty_average" double precision, 29 | "quality_average" double precision, 30 | "fa_username" text, 31 | "fa_at" timestamp, 32 | "created_at" timestamp DEFAULT now() NOT NULL 33 | ); 34 | 35 | 36 | -- Copy existing records into history tables 37 | INSERT INTO tension_climb_stats_history ( 38 | climb_uuid, angle, display_difficulty, benchmark_difficulty, 39 | ascensionist_count, difficulty_average, quality_average, 40 | fa_username, fa_at 41 | ) 42 | SELECT 43 | climb_uuid, angle, display_difficulty, benchmark_difficulty, 44 | ascensionist_count, difficulty_average, quality_average, 45 | fa_username, fa_at 46 | FROM tension_climb_stats; 47 | --> statement-breakpoint 48 | 49 | INSERT INTO kilter_climb_stats_history ( 50 | climb_uuid, angle, display_difficulty, benchmark_difficulty, 51 | ascensionist_count, difficulty_average, quality_average, 52 | fa_username, fa_at 53 | ) 54 | SELECT 55 | climb_uuid, angle, display_difficulty, benchmark_difficulty, 56 | ascensionist_count, difficulty_average, quality_average, 57 | fa_username, fa_at 58 | FROM kilter_climb_stats; 59 | --> statement-breakpoint 60 | 61 | -- Deduplicate existing climb_stats by keeping highest id 62 | DELETE FROM tension_climb_stats a USING ( 63 | SELECT climb_uuid, angle, MAX(id) as max_id 64 | FROM tension_climb_stats 65 | GROUP BY climb_uuid, angle 66 | ) b 67 | WHERE a.climb_uuid = b.climb_uuid 68 | AND a.angle = b.angle 69 | AND a.id < b.max_id; 70 | --> statement-breakpoint 71 | 72 | DELETE FROM kilter_climb_stats a USING ( 73 | SELECT climb_uuid, angle, MAX(id) as max_id 74 | FROM kilter_climb_stats 75 | GROUP BY climb_uuid, angle 76 | ) b 77 | WHERE a.climb_uuid = b.climb_uuid 78 | AND a.angle = b.angle 79 | AND a.id < b.max_id; 80 | --> statement-breakpoint -------------------------------------------------------------------------------- /drizzle/0002_unique_climbstats.sql: -------------------------------------------------------------------------------- 1 | DROP INDEX IF EXISTS "kilter_climb_angle_idx";--> statement-breakpoint 2 | DROP INDEX IF EXISTS "idx_16791_sqlite_autoindex_holes_2";--> statement-breakpoint 3 | DROP INDEX IF EXISTS "idx_16791_sqlite_autoindex_holes_3";--> statement-breakpoint 4 | DROP INDEX IF EXISTS "idx_16797_sqlite_autoindex_leds_2";--> statement-breakpoint 5 | DROP INDEX IF EXISTS "idx_16904_sqlite_autoindex_placements_2";--> statement-breakpoint 6 | DROP INDEX IF EXISTS "idx_16813_sqlite_autoindex_product_sizes_layouts_sets_2";--> statement-breakpoint 7 | DROP INDEX IF EXISTS "tension_climb_angle_idx";--> statement-breakpoint 8 | DROP INDEX IF EXISTS "idx_16405_sqlite_autoindex_holes_2";--> statement-breakpoint 9 | DROP INDEX IF EXISTS "idx_16405_sqlite_autoindex_holes_3";--> statement-breakpoint 10 | DROP INDEX IF EXISTS "idx_16411_sqlite_autoindex_leds_2";--> statement-breakpoint 11 | DROP INDEX IF EXISTS "idx_16518_sqlite_autoindex_placements_2";--> statement-breakpoint 12 | DROP INDEX IF EXISTS "idx_16427_sqlite_autoindex_product_sizes_layouts_sets_2";--> statement-breakpoint 13 | ALTER TABLE "kilter_beta_links" DROP CONSTRAINT "idx_16883_sqlite_autoindex_beta_links_1";--> statement-breakpoint 14 | ALTER TABLE "kilter_circuits_climbs" DROP CONSTRAINT "idx_16868_sqlite_autoindex_circuits_climbs_1";--> statement-breakpoint 15 | ALTER TABLE "kilter_products_angles" DROP CONSTRAINT "idx_16800_sqlite_autoindex_products_angles_1";--> statement-breakpoint 16 | ALTER TABLE "kilter_tags" DROP CONSTRAINT "idx_16853_sqlite_autoindex_tags_1";--> statement-breakpoint 17 | ALTER TABLE "kilter_user_permissions" DROP CONSTRAINT "idx_16828_sqlite_autoindex_user_permissions_1";--> statement-breakpoint 18 | ALTER TABLE "kilter_user_syncs" DROP CONSTRAINT "idx_16833_sqlite_autoindex_user_syncs_1";--> statement-breakpoint 19 | ALTER TABLE "kilter_walls_sets" DROP CONSTRAINT "idx_16838_sqlite_autoindex_walls_sets_1";--> statement-breakpoint 20 | ALTER TABLE "tension_beta_links" DROP CONSTRAINT "idx_16497_sqlite_autoindex_beta_links_1";--> statement-breakpoint 21 | ALTER TABLE "tension_circuits_climbs" DROP CONSTRAINT "idx_16482_sqlite_autoindex_circuits_climbs_1";--> statement-breakpoint 22 | ALTER TABLE "tension_products_angles" DROP CONSTRAINT "idx_16414_sqlite_autoindex_products_angles_1";--> statement-breakpoint 23 | ALTER TABLE "tension_tags" DROP CONSTRAINT "idx_16472_sqlite_autoindex_tags_1";--> statement-breakpoint 24 | ALTER TABLE "tension_user_permissions" DROP CONSTRAINT "idx_16447_sqlite_autoindex_user_permissions_1";--> statement-breakpoint 25 | ALTER TABLE "tension_user_syncs" DROP CONSTRAINT "idx_16452_sqlite_autoindex_user_syncs_1";--> statement-breakpoint 26 | ALTER TABLE "tension_walls_sets" DROP CONSTRAINT "idx_16457_sqlite_autoindex_walls_sets_1";--> statement-breakpoint 27 | -- Drop the existing primary key constraints 28 | ALTER TABLE tension_climb_stats DROP CONSTRAINT IF EXISTS idx_33308_climb_stats_pkey; 29 | ALTER TABLE kilter_climb_stats DROP CONSTRAINT IF EXISTS idx_32922_climb_stats_pkey; 30 | 31 | ALTER TABLE tension_climb_stats DROP COLUMN IF EXISTS id; 32 | ALTER TABLE kilter_climb_stats DROP COLUMN IF EXISTS id; 33 | ALTER TABLE "kilter_climb_stats" ADD CONSTRAINT "kilter_climb_stats_pk" PRIMARY KEY("climb_uuid","angle");--> statement-breakpoint 34 | ALTER TABLE "tension_climb_stats" ADD CONSTRAINT "tension_climb_stats_pk" PRIMARY KEY("climb_uuid","angle");--> statement-breakpoint -------------------------------------------------------------------------------- /drizzle/0003_add_climbs_indexes.sql: -------------------------------------------------------------------------------- 1 | CREATE INDEX "kilter_climbs_layout_filter_idx" ON "kilter_climbs" USING btree ("layout_id","is_listed","is_draft","frames_count");--> statement-breakpoint 2 | CREATE INDEX "kilter_climbs_edges_idx" ON "kilter_climbs" USING btree ("edge_left","edge_right","edge_bottom","edge_top");--> statement-breakpoint 3 | CREATE INDEX "tension_climbs_layout_filter_idx" ON "tension_climbs" USING btree ("layout_id","is_listed","is_draft","frames_count");--> statement-breakpoint 4 | CREATE INDEX "tension_climbs_edges_idx" ON "tension_climbs" USING btree ("edge_left","edge_right","edge_bottom","edge_top"); -------------------------------------------------------------------------------- /drizzle/0004_fix_circuits.sql: -------------------------------------------------------------------------------- 1 | 2 | ALTER TABLE "kilter_circuits" ADD CONSTRAINT "kilter_circuits_uuid_unique" UNIQUE("uuid");--> statement-breakpoint 3 | ALTER TABLE "tension_circuits" ADD CONSTRAINT "tension_circuits_uuid_unique" UNIQUE("uuid"); -------------------------------------------------------------------------------- /drizzle/0005_add_pk_to_user_syncs.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "kilter_user_syncs" ADD CONSTRAINT "kilter_user_sync_pk" PRIMARY KEY("user_id","table_name");--> statement-breakpoint 2 | ALTER TABLE "tension_user_syncs" ADD CONSTRAINT "tension_user_sync_pk" PRIMARY KEY("user_id","table_name"); -------------------------------------------------------------------------------- /drizzle/0006_remove_unnecessary_unique.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "kilter_circuits" DROP CONSTRAINT "kilter_circuits_uuid_unique";--> statement-breakpoint 2 | ALTER TABLE "tension_circuits" DROP CONSTRAINT "tension_circuits_uuid_unique"; -------------------------------------------------------------------------------- /drizzle/0007_remove_climbstats_id_column.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "kilter_climb_stats" 2 | DROP COLUMN IF EXISTS "id"; 3 | 4 | ALTER TABLE "tension_climb_stats" 5 | DROP COLUMN IF EXISTS "id"; -------------------------------------------------------------------------------- /drizzle/0008_stats_foreign_keys.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE IF EXISTS public.tension_climb_stats 2 | DROP CONSTRAINT IF EXISTS climb_stats_climb_uuid_fkey; 3 | ALTER TABLE IF EXISTS public.kilter_climb_stats 4 | DROP CONSTRAINT IF EXISTS climb_stats_climb_uuid_fkey1; 5 | 6 | ALTER TABLE IF EXISTS public.tension_climb_stats 7 | DROP CONSTRAINT IF EXISTS tension_climb_stats_climb_uuid_tension_climbs_uuid_fk; 8 | ALTER TABLE IF EXISTS public.kilter_climb_stats 9 | DROP CONSTRAINT IF EXISTS kilter_climb_stats_climb_uuid_kilter_climbs_uuid_fk; 10 | 11 | -- ALTER TABLE "kilter_climb_stats" 12 | -- ADD CONSTRAINT "kilter_climb_stats_climb_uuid_kilter_climbs_uuid_fk" 13 | -- FOREIGN KEY ("climb_uuid") 14 | -- REFERENCES "public"."kilter_climbs"("uuid") 15 | -- ON DELETE cascade 16 | -- ON UPDATE cascade 17 | -- DEFERRABLE INITIALLY DEFERRED; 18 | 19 | -- ALTER TABLE "tension_climb_stats" 20 | -- ADD CONSTRAINT "tension_climb_stats_climb_uuid_tension_climbs_uuid_fk" 21 | -- FOREIGN KEY ("climb_uuid") 22 | -- REFERENCES "public"."tension_climbs"("uuid") 23 | -- ON DELETE cascade 24 | -- ON UPDATE cascade 25 | -- DEFERRABLE INITIALLY DEFERRED; 26 | -------------------------------------------------------------------------------- /drizzle/0009_nullable_stats.sql: -------------------------------------------------------------------------------- 1 | -- Skipped this one -------------------------------------------------------------------------------- /drizzle/0010_remove_stats_foreignkeys.sql: -------------------------------------------------------------------------------- 1 | --> statement-breakpoint 2 | ALTER TABLE "kilter_climb_stats" ALTER COLUMN "climb_uuid" SET NOT NULL;--> statement-breakpoint 3 | ALTER TABLE "tension_climb_stats" ALTER COLUMN "climb_uuid" SET NOT NULL; 4 | 5 | ALTER TABLE IF EXISTS public.tension_climb_stats 6 | DROP CONSTRAINT IF EXISTS climb_stats_climb_uuid_fkey; 7 | ALTER TABLE IF EXISTS public.kilter_climb_stats 8 | DROP CONSTRAINT IF EXISTS climb_stats_climb_uuid_fkey1; 9 | 10 | ALTER TABLE IF EXISTS public.tension_climb_stats 11 | DROP CONSTRAINT IF EXISTS tension_climb_stats_climb_uuid_tension_climbs_uuid_fk; 12 | ALTER TABLE IF EXISTS public.kilter_climb_stats 13 | DROP CONSTRAINT IF EXISTS kilter_climb_stats_climb_uuid_kilter_climbs_uuid_fk; -------------------------------------------------------------------------------- /drizzle/0012_drop_climb_holds_fk.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "kilter_climb_holds" DROP CONSTRAINT "kilter_climb_holds_climb_uuid_fkey"; 2 | --> statement-breakpoint 3 | ALTER TABLE "tension_climb_holds" DROP CONSTRAINT "tension_climb_holds_climb_uuid_fkey"; 4 | --> statement-breakpoint 5 | -------------------------------------------------------------------------------- /drizzle/0013_add_heatmap_indexes.sql: -------------------------------------------------------------------------------- 1 | -- 1. Index for climb holds 2 | DROP INDEX IF EXISTS idx_tension_climb_holds_main; 3 | CREATE INDEX idx_tension_climb_holds_main 4 | ON tension_climb_holds (climb_uuid, hold_id) 5 | INCLUDE (hold_state); 6 | 7 | -- 2. Index for climb stats 8 | DROP INDEX IF EXISTS idx_tension_climb_stats_main; 9 | CREATE INDEX idx_tension_climb_stats_main 10 | ON tension_climb_stats (climb_uuid, angle) 11 | INCLUDE (display_difficulty); 12 | 13 | -- 3. Index for product sizes 14 | DROP INDEX IF EXISTS idx_tension_product_sizes_main; 15 | CREATE INDEX idx_tension_product_sizes_main 16 | ON tension_product_sizes (id) 17 | INCLUDE (edge_left, edge_right, edge_bottom, edge_top); 18 | 19 | -- 4. Materialized view for product sizes (if they rarely change) 20 | DROP MATERIALIZED VIEW IF EXISTS tension_product_sizes_mv; 21 | CREATE MATERIALIZED VIEW tension_product_sizes_mv AS 22 | SELECT id, edge_left, edge_right, edge_bottom, edge_top 23 | FROM tension_product_sizes; 24 | 25 | CREATE UNIQUE INDEX idx_tension_product_sizes_mv_id 26 | ON tension_product_sizes_mv (id); 27 | 28 | -- 5. Improve planner statistics 29 | ALTER TABLE tension_climbs ALTER COLUMN is_listed SET STATISTICS 1000; 30 | ALTER TABLE tension_climbs ALTER COLUMN is_draft SET STATISTICS 1000; 31 | ALTER TABLE tension_climbs ALTER COLUMN frames_count SET STATISTICS 1000; 32 | 33 | -- 6. Update statistics 34 | ANALYZE tension_climbs; 35 | ANALYZE tension_climb_holds; 36 | ANALYZE tension_climb_stats; 37 | ANALYZE tension_product_sizes; 38 | ANALYZE tension_product_sizes_mv; 39 | 40 | -- 1. Index for climb holds 41 | DROP INDEX IF EXISTS idx_kilter_climb_holds_main; 42 | CREATE INDEX idx_kilter_climb_holds_main 43 | ON kilter_climb_holds (climb_uuid, hold_id) 44 | INCLUDE (hold_state); 45 | 46 | -- 2. Index for climb stats 47 | DROP INDEX IF EXISTS idx_kilter_climb_stats_main; 48 | CREATE INDEX idx_kilter_climb_stats_main 49 | ON kilter_climb_stats (climb_uuid, angle) 50 | INCLUDE (display_difficulty); 51 | 52 | -- 3. Index for product sizes 53 | DROP INDEX IF EXISTS idx_kilter_product_sizes_main; 54 | CREATE INDEX idx_kilter_product_sizes_main 55 | ON kilter_product_sizes (id) 56 | INCLUDE (edge_left, edge_right, edge_bottom, edge_top); 57 | 58 | -- 4. Materialized view for product sizes (if they rarely change) 59 | DROP MATERIALIZED VIEW IF EXISTS kilter_product_sizes_mv; 60 | CREATE MATERIALIZED VIEW kilter_product_sizes_mv AS 61 | SELECT id, edge_left, edge_right, edge_bottom, edge_top 62 | FROM kilter_product_sizes; 63 | 64 | CREATE UNIQUE INDEX idx_kilter_product_sizes_mv_id 65 | ON kilter_product_sizes_mv (id); 66 | 67 | -- 5. Improve planner statistics 68 | ALTER TABLE kilter_climbs ALTER COLUMN is_listed SET STATISTICS 1000; 69 | ALTER TABLE kilter_climbs ALTER COLUMN is_draft SET STATISTICS 1000; 70 | ALTER TABLE kilter_climbs ALTER COLUMN frames_count SET STATISTICS 1000; 71 | 72 | -- 6. Update statistics 73 | ANALYZE kilter_climbs; 74 | ANALYZE kilter_climb_holds; 75 | ANALYZE kilter_climb_stats; 76 | ANALYZE kilter_product_sizes; 77 | ANALYZE kilter_product_sizes_mv; -------------------------------------------------------------------------------- /drizzle/README.md: -------------------------------------------------------------------------------- 1 | # Drizzle in Boardsesh 2 | 3 | Writing this down because I will otherwise forget myself. 4 | I had issues adopting Prisma, so I decided to try some other random ORM. 5 | For no particular reason I ended up with Drizzle, which seems mostly okay. 6 | 7 | I made the mistake of deleting some of the snapshots, so dont do that again. 8 | 9 | To generate a new database migration follow these steps: 10 | 11 | 1. Modify the lib/db/schema.ts file with the changes you want 12 | 2. Run `npx drizzle-kit generate --name=$MigrationName$` 13 | 3. Edit the generated SQL file, and make sure you're happy with it 14 | 4. Run `npx drizzle-kit migrate` to exucute the migration 15 | -------------------------------------------------------------------------------- /drizzle/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "7", 3 | "dialect": "postgresql", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "7", 8 | "when": 1734255307302, 9 | "tag": "0000_cloudy_carlie_cooper", 10 | "breakpoints": true 11 | }, 12 | { 13 | "idx": 1, 14 | "version": "7", 15 | "when": 1735369313427, 16 | "tag": "0001_unique_climbstats", 17 | "breakpoints": true 18 | }, 19 | { 20 | "idx": 2, 21 | "version": "7", 22 | "when": 1735420640429, 23 | "tag": "0002_unique_climbstats", 24 | "breakpoints": true 25 | }, 26 | { 27 | "idx": 3, 28 | "version": "7", 29 | "when": 1735422473281, 30 | "tag": "0003_add_climbs_indexes", 31 | "breakpoints": true 32 | }, 33 | { 34 | "idx": 4, 35 | "version": "7", 36 | "when": 1735427173465, 37 | "tag": "0004_fix_circuits", 38 | "breakpoints": true 39 | }, 40 | { 41 | "idx": 5, 42 | "version": "7", 43 | "when": 1735427858324, 44 | "tag": "0005_add_pk_to_user_syncs", 45 | "breakpoints": true 46 | }, 47 | { 48 | "idx": 6, 49 | "version": "7", 50 | "when": 1735427971317, 51 | "tag": "0006_remove_unnecessary_unique", 52 | "breakpoints": true 53 | }, 54 | { 55 | "idx": 7, 56 | "version": "7", 57 | "when": 1735440124885, 58 | "tag": "0007_remove_climbstats_id_column", 59 | "breakpoints": true 60 | }, 61 | { 62 | "idx": 8, 63 | "version": "7", 64 | "when": 1735440793114, 65 | "tag": "0008_stats_foreign_keys", 66 | "breakpoints": true 67 | }, 68 | { 69 | "idx": 9, 70 | "version": "7", 71 | "when": 1735526188118, 72 | "tag": "0009_nullable_stats", 73 | "breakpoints": true 74 | }, 75 | { 76 | "idx": 10, 77 | "version": "7", 78 | "when": 1735526364141, 79 | "tag": "0010_remove_stats_foreignkeys", 80 | "breakpoints": true 81 | }, 82 | { 83 | "idx": 11, 84 | "version": "7", 85 | "when": 1735770051923, 86 | "tag": "0011_add_holds_map_table", 87 | "breakpoints": true 88 | }, 89 | { 90 | "idx": 12, 91 | "version": "7", 92 | "when": 1736553172493, 93 | "tag": "0012_drop_climb_holds_fk", 94 | "breakpoints": true 95 | }, 96 | { 97 | "idx": 13, 98 | "version": "7", 99 | "when": 1740054525189, 100 | "tag": "0013_add_heatmap_indexes", 101 | "breakpoints": true 102 | } 103 | ] 104 | } -------------------------------------------------------------------------------- /esphome_bluetooth_proxy/README.md: -------------------------------------------------------------------------------- 1 | # ESPHOME Aurora Bluetooth Proxy 2 | 3 | I had a go at getting chatgpt and claude to build me a ESPHOME kilter bluetooth proxy, so 4 | I could connect through that and get some extra entities for my board in home assistant. 5 | It wasnt very successful, and Im not sure if it was due to the ESP32 displays I was trying to use 6 | or something else. So I'm just leaving this here in a broken state, hoping 1 day I'll feel motivated 7 | to bash my head into a brick wall for 3 hours trying to get esphome to work 8 | -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | // middleware.ts 2 | import { NextResponse } from 'next/server'; 3 | import type { NextRequest } from 'next/server'; 4 | import { SUPPORTED_BOARDS } from './app/lib/board-data'; 5 | 6 | const SPECIAL_ROUTES = ['angles', 'grades']; // routes that don't need board validation 7 | 8 | export function middleware(request: NextRequest) { 9 | const { pathname } = request.nextUrl; 10 | 11 | // Block PHP requests 12 | if (pathname.includes('.php')) { 13 | return new NextResponse(null, { 14 | status: 404, 15 | statusText: 'Not Found', 16 | }); 17 | } 18 | 19 | // Check API routes 20 | if (pathname.startsWith('/api/v1/')) { 21 | const pathParts = pathname.split('/'); 22 | if (pathParts.length >= 4) { 23 | const routeIdentifier = pathParts[3].toLowerCase(); // either a board name or special route 24 | 25 | // Allow special routes to pass through 26 | if (SPECIAL_ROUTES.includes(routeIdentifier)) { 27 | return NextResponse.next(); 28 | } 29 | 30 | // For all other routes, validate board name 31 | if (!SUPPORTED_BOARDS.includes(routeIdentifier)) { 32 | console.info('Middleware board_name check returned 404'); 33 | return new NextResponse(null, { 34 | status: 404, 35 | statusText: 'Not Found', 36 | }); 37 | } 38 | } 39 | } 40 | 41 | return NextResponse.next(); 42 | } 43 | 44 | export const config = { 45 | matcher: ['/api/v1/:path*', '/:path*'], 46 | }; 47 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | // next.config.js 2 | 3 | /** @type {import('next').NextConfig} */ 4 | const nextConfig = { 5 | eslint: { 6 | // Warning: This allows production builds to successfully complete even if 7 | // your project has ESLint errors. 8 | ignoreDuringBuilds: true, 9 | }, 10 | typescript: { 11 | // ignoreBuildErrors: true, 12 | }, 13 | }; 14 | 15 | export default nextConfig; 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nextjs", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev --turbopack", 7 | "build": "drizzle-kit migrate && next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "lint-fix": "eslint --fix .", 11 | "format": "prettier --write ." 12 | }, 13 | "dependencies": { 14 | "@ant-design/nextjs-registry": "^1.0.2", 15 | "@atlaskit/pragmatic-drag-and-drop": "^1.5.0", 16 | "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3", 17 | "@atlaskit/pragmatic-drag-and-drop-react-accessibility": "^2.0.0", 18 | "@atlaskit/pragmatic-drag-and-drop-react-drop-indicator": "^2.0.0", 19 | "@neondatabase/serverless": "0.9.5", 20 | "@types/web-bluetooth": "^0.0.20", 21 | "@vercel/analytics": "^1.4.1", 22 | "@vercel/postgres": "^0.10.0", 23 | "antd": "^5.22.3", 24 | "chart.js": "^4.4.7", 25 | "cookies": "^0.9.1", 26 | "d3-scale": "^4.0.2", 27 | "d3-scale-chromatic": "^3.1.0", 28 | "dotenv": "^16.4.7", 29 | "drizzle-kit": "^0.30.1", 30 | "drizzle-orm": "^0.38.2", 31 | "idb": "^8.0.0", 32 | "iron-session": "^8.0.4", 33 | "next": "15.2.0", 34 | "peerjs": "^1.5.4", 35 | "react": "^18", 36 | "react-chartjs-2": "^5.2.0", 37 | "react-dom": "^18", 38 | "react-infinite-scroll-component": "^6.1.0", 39 | "react-intersection-observer": "^9.13.1", 40 | "react-router-dom": "^7.0.2", 41 | "react-swipeable": "^7.0.1", 42 | "server-only": "^0.0.1", 43 | "swr": "^2.3.2", 44 | "use-debounce": "^10.0.4", 45 | "uuid": "^11.0.3", 46 | "vite": "^6.0.1", 47 | "vitest": "^2.1.1", 48 | "zod": "^3.23.8" 49 | }, 50 | "devDependencies": { 51 | "@types/d3-interpolate": "^3.0.4", 52 | "@types/d3-scale": "^4.0.9", 53 | "@types/jest": "^29.5.13", 54 | "@types/node": "^22", 55 | "@types/react": "19.0.10", 56 | "@types/react-dom": "19.0.4", 57 | "@types/uuid": "^10.0.0", 58 | "eslint": "^8.57.1", 59 | "eslint-config-next": "15.2.0", 60 | "eslint-config-prettier": "^9.1.0", 61 | "eslint-plugin-prettier": "^5.2.1", 62 | "eslint-plugin-react": "^7.37.2", 63 | "prettier": "^3.4.2", 64 | "react": "19.0.0", 65 | "react-dom": "19.0.0", 66 | "typescript": "^5.7.2" 67 | }, 68 | "overrides": { 69 | "@types/react": "19.0.10", 70 | "@types/react-dom": "19.0.4" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /public/images/kilter/product_sizes_layouts_sets/15_5_24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/kilter/product_sizes_layouts_sets/15_5_24.png -------------------------------------------------------------------------------- /public/images/kilter/product_sizes_layouts_sets/36-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/kilter/product_sizes_layouts_sets/36-1.png -------------------------------------------------------------------------------- /public/images/kilter/product_sizes_layouts_sets/38-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/kilter/product_sizes_layouts_sets/38-1.png -------------------------------------------------------------------------------- /public/images/kilter/product_sizes_layouts_sets/39-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/kilter/product_sizes_layouts_sets/39-1.png -------------------------------------------------------------------------------- /public/images/kilter/product_sizes_layouts_sets/41-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/kilter/product_sizes_layouts_sets/41-1.png -------------------------------------------------------------------------------- /public/images/kilter/product_sizes_layouts_sets/45-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/kilter/product_sizes_layouts_sets/45-1.png -------------------------------------------------------------------------------- /public/images/kilter/product_sizes_layouts_sets/46-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/kilter/product_sizes_layouts_sets/46-1.png -------------------------------------------------------------------------------- /public/images/kilter/product_sizes_layouts_sets/47.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/kilter/product_sizes_layouts_sets/47.png -------------------------------------------------------------------------------- /public/images/kilter/product_sizes_layouts_sets/48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/kilter/product_sizes_layouts_sets/48.png -------------------------------------------------------------------------------- /public/images/kilter/product_sizes_layouts_sets/49.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/kilter/product_sizes_layouts_sets/49.png -------------------------------------------------------------------------------- /public/images/kilter/product_sizes_layouts_sets/50-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/kilter/product_sizes_layouts_sets/50-1.png -------------------------------------------------------------------------------- /public/images/kilter/product_sizes_layouts_sets/51-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/kilter/product_sizes_layouts_sets/51-1.png -------------------------------------------------------------------------------- /public/images/kilter/product_sizes_layouts_sets/53.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/kilter/product_sizes_layouts_sets/53.png -------------------------------------------------------------------------------- /public/images/kilter/product_sizes_layouts_sets/54.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/kilter/product_sizes_layouts_sets/54.png -------------------------------------------------------------------------------- /public/images/kilter/product_sizes_layouts_sets/55-v2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/kilter/product_sizes_layouts_sets/55-v2.png -------------------------------------------------------------------------------- /public/images/kilter/product_sizes_layouts_sets/56-v3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/kilter/product_sizes_layouts_sets/56-v3.png -------------------------------------------------------------------------------- /public/images/kilter/product_sizes_layouts_sets/59.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/kilter/product_sizes_layouts_sets/59.png -------------------------------------------------------------------------------- /public/images/kilter/product_sizes_layouts_sets/60-v3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/kilter/product_sizes_layouts_sets/60-v3.png -------------------------------------------------------------------------------- /public/images/kilter/product_sizes_layouts_sets/61-v3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/kilter/product_sizes_layouts_sets/61-v3.png -------------------------------------------------------------------------------- /public/images/kilter/product_sizes_layouts_sets/63-v3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/kilter/product_sizes_layouts_sets/63-v3.png -------------------------------------------------------------------------------- /public/images/kilter/product_sizes_layouts_sets/64-v3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/kilter/product_sizes_layouts_sets/64-v3.png -------------------------------------------------------------------------------- /public/images/kilter/product_sizes_layouts_sets/65-v2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/kilter/product_sizes_layouts_sets/65-v2.png -------------------------------------------------------------------------------- /public/images/kilter/product_sizes_layouts_sets/66-v2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/kilter/product_sizes_layouts_sets/66-v2.png -------------------------------------------------------------------------------- /public/images/kilter/product_sizes_layouts_sets/70-v2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/kilter/product_sizes_layouts_sets/70-v2.png -------------------------------------------------------------------------------- /public/images/kilter/product_sizes_layouts_sets/71-v3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/kilter/product_sizes_layouts_sets/71-v3.png -------------------------------------------------------------------------------- /public/images/kilter/product_sizes_layouts_sets/72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/kilter/product_sizes_layouts_sets/72.png -------------------------------------------------------------------------------- /public/images/kilter/product_sizes_layouts_sets/73.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/kilter/product_sizes_layouts_sets/73.png -------------------------------------------------------------------------------- /public/images/kilter/product_sizes_layouts_sets/77-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/kilter/product_sizes_layouts_sets/77-1.png -------------------------------------------------------------------------------- /public/images/kilter/product_sizes_layouts_sets/78-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/kilter/product_sizes_layouts_sets/78-1.png -------------------------------------------------------------------------------- /public/images/kilter/product_sizes_layouts_sets/original-16x12-bolt-ons-v2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/kilter/product_sizes_layouts_sets/original-16x12-bolt-ons-v2.png -------------------------------------------------------------------------------- /public/images/kilter/product_sizes_layouts_sets/original-16x12-screw-ons-v2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/kilter/product_sizes_layouts_sets/original-16x12-screw-ons-v2.png -------------------------------------------------------------------------------- /public/images/tension/product_sizes_layouts_sets/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/tension/product_sizes_layouts_sets/1.png -------------------------------------------------------------------------------- /public/images/tension/product_sizes_layouts_sets/10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/tension/product_sizes_layouts_sets/10.png -------------------------------------------------------------------------------- /public/images/tension/product_sizes_layouts_sets/11.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/tension/product_sizes_layouts_sets/11.png -------------------------------------------------------------------------------- /public/images/tension/product_sizes_layouts_sets/12.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/tension/product_sizes_layouts_sets/12.png -------------------------------------------------------------------------------- /public/images/tension/product_sizes_layouts_sets/12x10-tb2-plastic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/tension/product_sizes_layouts_sets/12x10-tb2-plastic.png -------------------------------------------------------------------------------- /public/images/tension/product_sizes_layouts_sets/12x10-tb2-wood.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/tension/product_sizes_layouts_sets/12x10-tb2-wood.png -------------------------------------------------------------------------------- /public/images/tension/product_sizes_layouts_sets/12x12-tb2-plastic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/tension/product_sizes_layouts_sets/12x12-tb2-plastic.png -------------------------------------------------------------------------------- /public/images/tension/product_sizes_layouts_sets/12x12-tb2-wood.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/tension/product_sizes_layouts_sets/12x12-tb2-wood.png -------------------------------------------------------------------------------- /public/images/tension/product_sizes_layouts_sets/13.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/tension/product_sizes_layouts_sets/13.png -------------------------------------------------------------------------------- /public/images/tension/product_sizes_layouts_sets/14.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/tension/product_sizes_layouts_sets/14.png -------------------------------------------------------------------------------- /public/images/tension/product_sizes_layouts_sets/15.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/tension/product_sizes_layouts_sets/15.png -------------------------------------------------------------------------------- /public/images/tension/product_sizes_layouts_sets/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/tension/product_sizes_layouts_sets/16.png -------------------------------------------------------------------------------- /public/images/tension/product_sizes_layouts_sets/17.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/tension/product_sizes_layouts_sets/17.png -------------------------------------------------------------------------------- /public/images/tension/product_sizes_layouts_sets/18.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/tension/product_sizes_layouts_sets/18.png -------------------------------------------------------------------------------- /public/images/tension/product_sizes_layouts_sets/19.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/tension/product_sizes_layouts_sets/19.png -------------------------------------------------------------------------------- /public/images/tension/product_sizes_layouts_sets/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/tension/product_sizes_layouts_sets/2.png -------------------------------------------------------------------------------- /public/images/tension/product_sizes_layouts_sets/20.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/tension/product_sizes_layouts_sets/20.png -------------------------------------------------------------------------------- /public/images/tension/product_sizes_layouts_sets/21-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/tension/product_sizes_layouts_sets/21-2.png -------------------------------------------------------------------------------- /public/images/tension/product_sizes_layouts_sets/22-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/tension/product_sizes_layouts_sets/22-2.png -------------------------------------------------------------------------------- /public/images/tension/product_sizes_layouts_sets/23.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/tension/product_sizes_layouts_sets/23.png -------------------------------------------------------------------------------- /public/images/tension/product_sizes_layouts_sets/24-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/tension/product_sizes_layouts_sets/24-2.png -------------------------------------------------------------------------------- /public/images/tension/product_sizes_layouts_sets/25.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/tension/product_sizes_layouts_sets/25.png -------------------------------------------------------------------------------- /public/images/tension/product_sizes_layouts_sets/26.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/tension/product_sizes_layouts_sets/26.png -------------------------------------------------------------------------------- /public/images/tension/product_sizes_layouts_sets/27.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/tension/product_sizes_layouts_sets/27.png -------------------------------------------------------------------------------- /public/images/tension/product_sizes_layouts_sets/28.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/tension/product_sizes_layouts_sets/28.png -------------------------------------------------------------------------------- /public/images/tension/product_sizes_layouts_sets/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/tension/product_sizes_layouts_sets/3.png -------------------------------------------------------------------------------- /public/images/tension/product_sizes_layouts_sets/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/tension/product_sizes_layouts_sets/4.png -------------------------------------------------------------------------------- /public/images/tension/product_sizes_layouts_sets/5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/tension/product_sizes_layouts_sets/5.png -------------------------------------------------------------------------------- /public/images/tension/product_sizes_layouts_sets/6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/tension/product_sizes_layouts_sets/6.png -------------------------------------------------------------------------------- /public/images/tension/product_sizes_layouts_sets/7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/tension/product_sizes_layouts_sets/7.png -------------------------------------------------------------------------------- /public/images/tension/product_sizes_layouts_sets/8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/tension/product_sizes_layouts_sets/8.png -------------------------------------------------------------------------------- /public/images/tension/product_sizes_layouts_sets/8x10-tb2-plastic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/tension/product_sizes_layouts_sets/8x10-tb2-plastic.png -------------------------------------------------------------------------------- /public/images/tension/product_sizes_layouts_sets/8x10-tb2-wood.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/tension/product_sizes_layouts_sets/8x10-tb2-wood.png -------------------------------------------------------------------------------- /public/images/tension/product_sizes_layouts_sets/8x12-tb2-plastic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/tension/product_sizes_layouts_sets/8x12-tb2-plastic.png -------------------------------------------------------------------------------- /public/images/tension/product_sizes_layouts_sets/8x12-tb2-wood.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/tension/product_sizes_layouts_sets/8x12-tb2-wood.png -------------------------------------------------------------------------------- /public/images/tension/product_sizes_layouts_sets/9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodejongh/boardsesh/bf69a9d327c16673f9e658d7e89868479840ad8e/public/images/tension/product_sizes_layouts_sets/9.png -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | React App 7 | 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /scripts/download.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Base URL 4 | kilter_url="https://api.kilterboardapp.com/img/" 5 | tension_url="https://api.tensionboardapp2.com/img/" 6 | 7 | kilter_images=( 8 | "product_sizes_layouts_sets/47.png" 9 | "product_sizes_layouts_sets/48.png" 10 | "product_sizes_layouts_sets/49.png" 11 | "product_sizes_layouts_sets/15_5_24.png" 12 | "product_sizes_layouts_sets/53.png" 13 | "product_sizes_layouts_sets/54.png" 14 | "product_sizes_layouts_sets/55-v2.png" 15 | "product_sizes_layouts_sets/56-v3.png" 16 | "product_sizes_layouts_sets/55-v2.png" 17 | "product_sizes_layouts_sets/56-v3.png" 18 | "product_sizes_layouts_sets/59.png" 19 | "product_sizes_layouts_sets/65-v2.png" 20 | "product_sizes_layouts_sets/66-v2.png" 21 | "product_sizes_layouts_sets/65-v2.png" 22 | "product_sizes_layouts_sets/66-v2.png" 23 | "product_sizes_layouts_sets/72.png" 24 | "product_sizes_layouts_sets/73.png" 25 | "product_sizes_layouts_sets/72.png" 26 | "product_sizes_layouts_sets/73.png" 27 | "product_sizes_layouts_sets/36-1.png" 28 | "product_sizes_layouts_sets/38-1.png" 29 | "product_sizes_layouts_sets/39-1.png" 30 | "product_sizes_layouts_sets/41-1.png" 31 | "product_sizes_layouts_sets/45-1.png" 32 | "product_sizes_layouts_sets/46-1.png" 33 | "product_sizes_layouts_sets/50-1.png" 34 | "product_sizes_layouts_sets/51-1.png" 35 | "product_sizes_layouts_sets/77-1.png" 36 | "product_sizes_layouts_sets/78-1.png" 37 | "product_sizes_layouts_sets/60-v3.png" 38 | "product_sizes_layouts_sets/60-v3.png" 39 | "product_sizes_layouts_sets/63-v3.png" 40 | "product_sizes_layouts_sets/63-v3.png" 41 | "product_sizes_layouts_sets/70-v2.png" 42 | "product_sizes_layouts_sets/70-v2.png" 43 | "product_sizes_layouts_sets/61-v3.png" 44 | "product_sizes_layouts_sets/64-v3.png" 45 | "product_sizes_layouts_sets/71-v3.png" 46 | "product_sizes_layouts_sets/original-16x12-bolt-ons-v2.png" 47 | "product_sizes_layouts_sets/original-16x12-screw-ons-v2.png" 48 | "product_sizes_layouts_sets/61-v3.png") 49 | 50 | # Array of image paths 51 | tension_images=( 52 | "product_sizes_layouts_sets/1.png" 53 | "product_sizes_layouts_sets/2.png" 54 | "product_sizes_layouts_sets/3.png" 55 | "product_sizes_layouts_sets/4.png" 56 | "product_sizes_layouts_sets/5.png" 57 | "product_sizes_layouts_sets/6.png" 58 | "product_sizes_layouts_sets/7.png" 59 | "product_sizes_layouts_sets/8.png" 60 | "product_sizes_layouts_sets/9.png" 61 | "product_sizes_layouts_sets/10.png" 62 | "product_sizes_layouts_sets/11.png" 63 | "product_sizes_layouts_sets/12.png" 64 | "product_sizes_layouts_sets/13.png" 65 | "product_sizes_layouts_sets/14.png" 66 | "product_sizes_layouts_sets/15.png" 67 | "product_sizes_layouts_sets/16.png" 68 | "product_sizes_layouts_sets/17.png" 69 | "product_sizes_layouts_sets/18.png" 70 | "product_sizes_layouts_sets/19.png" 71 | "product_sizes_layouts_sets/20.png" 72 | "product_sizes_layouts_sets/23.png" 73 | "product_sizes_layouts_sets/25.png" 74 | "product_sizes_layouts_sets/26.png" 75 | "product_sizes_layouts_sets/27.png" 76 | "product_sizes_layouts_sets/28.png" 77 | "product_sizes_layouts_sets/12x12-tb2-wood.png" 78 | "product_sizes_layouts_sets/12x12-tb2-plastic.png" 79 | "product_sizes_layouts_sets/12x10-tb2-wood.png" 80 | "product_sizes_layouts_sets/12x10-tb2-plastic.png" 81 | "product_sizes_layouts_sets/8x12-tb2-wood.png" 82 | "product_sizes_layouts_sets/8x12-tb2-plastic.png" 83 | "product_sizes_layouts_sets/8x10-tb2-wood.png" 84 | "product_sizes_layouts_sets/8x10-tb2-plastic.png" 85 | "product_sizes_layouts_sets/21-2.png" 86 | "product_sizes_layouts_sets/22-2.png" 87 | "product_sizes_layouts_sets/24-2.png" 88 | ) 89 | 90 | # Loop through the array and download each image 91 | for image in "${tension_images[@]}" 92 | do 93 | echo $image 94 | wget "${tension_url}${image}" -P ../public/images/tension 95 | done 96 | -------------------------------------------------------------------------------- /scripts/get-image-size-json.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Check if ImageMagick's 'identify' command is available 4 | if ! command -v identify &> /dev/null 5 | then 6 | echo "ImageMagick's 'identify' command could not be found. Please install it first." 7 | exit 1 8 | fi 9 | 10 | # Initialize an empty JSON object 11 | output="{" 12 | 13 | # Set the image directory to process 14 | image_directory="../public/images/tension" 15 | 16 | # Iterate over all image files in the target directory 17 | for file in "$image_directory"/*.{jpg,jpeg,png,gif,bmp,tiff}; do 18 | # Check if the file exists (handles case where no images are found) 19 | if [ ! -e "$file" ]; then 20 | continue 21 | fi 22 | 23 | # Get image dimensions using ImageMagick's identify command 24 | dimensions=$(identify -format "%w %h" "$file") 25 | width=$(echo $dimensions | cut -d' ' -f1) 26 | height=$(echo $dimensions | cut -d' ' -f2) 27 | 28 | # Get just the filename (without the directory path) 29 | filename=$(basename "$file") 30 | 31 | # Append to JSON object 32 | output+="\"$filename\": { \"width\": $width, \"height\": $height }," 33 | done 34 | 35 | # Remove the trailing comma and close the JSON object 36 | output="${output%,}}" 37 | 38 | # Print the result 39 | echo $output 40 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./*"], 22 | "@/c/*": ["./app/components/*"], 23 | "@/lib/*": ["./app/lib/*"] 24 | }, 25 | "target": "ES2017" 26 | }, 27 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 28 | "exclude": ["node_modules"] 29 | } 30 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "installCommand": "npm install --legacy-peer-deps", 3 | "crons": [ 4 | { 5 | "path": "/api/internal/shared-sync/tension", 6 | "schedule": "0 */2 * * *" 7 | }, 8 | { 9 | "path": "/api/internal/shared-sync/kilter", 10 | "schedule": "15 */2 * * *" 11 | } 12 | ] 13 | } 14 | --------------------------------------------------------------------------------