init commit,
6
99_references/beacon-main/.browserslistrc
Normal file
@@ -0,0 +1,6 @@
|
||||
Chrome >=79
|
||||
ChromeAndroid >=79
|
||||
Firefox >=70
|
||||
Edge >=79
|
||||
Safari >=14
|
||||
iOS >=14
|
148
99_references/beacon-main/.dockerignore
Normal file
@@ -0,0 +1,148 @@
|
||||
# Test reports
|
||||
/test
|
||||
|
||||
# Supabase
|
||||
.branches
|
||||
.temp
|
||||
|
||||
# Ionic
|
||||
.ionic
|
||||
.idea
|
||||
.sass-cache
|
||||
.sourcemaps
|
||||
.versions
|
||||
platforms
|
||||
plugins
|
||||
www
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
*.env
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
9
99_references/beacon-main/.editorconfig
Normal file
@@ -0,0 +1,9 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
151
99_references/beacon-main/.eslintignore
Normal file
@@ -0,0 +1,151 @@
|
||||
# Runtime variables
|
||||
public/runtime-vars.js
|
||||
|
||||
# Test reports
|
||||
/test
|
||||
|
||||
# Supabase
|
||||
.branches
|
||||
.temp
|
||||
|
||||
# Ionic
|
||||
.ionic
|
||||
.idea
|
||||
.sass-cache
|
||||
.sourcemaps
|
||||
.versions
|
||||
platforms
|
||||
plugins
|
||||
www
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
*.env
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
253
99_references/beacon-main/.eslintrc.json
Normal file
@@ -0,0 +1,253 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"node": true
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:@typescript-eslint/strict",
|
||||
"plugin:jsdoc/recommended",
|
||||
"plugin:jsdoc/recommended-typescript",
|
||||
"plugin:prettier/recommended",
|
||||
"plugin:react/recommended",
|
||||
"plugin:unicorn/recommended"
|
||||
],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": "latest",
|
||||
"project": [
|
||||
"tsconfig.json"
|
||||
],
|
||||
"sourceType": "module"
|
||||
},
|
||||
"plugins": [
|
||||
"@limegrass/import-alias",
|
||||
"@typescript-eslint",
|
||||
"jsdoc",
|
||||
"prettier",
|
||||
"simple-import-sort",
|
||||
"unicorn"
|
||||
],
|
||||
"settings": {
|
||||
"react": {
|
||||
"version": "detect"
|
||||
}
|
||||
},
|
||||
"rules": {
|
||||
"@limegrass/import-alias/import-alias": "warn",
|
||||
"@typescript-eslint/ban-types": [
|
||||
"error",
|
||||
{
|
||||
"types": {
|
||||
"{}": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/consistent-type-definitions": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-non-null-assertion": "off",
|
||||
"@typescript-eslint/no-this-alias": "off",
|
||||
"@typescript-eslint/no-unnecessary-condition": "off",
|
||||
"@typescript-eslint/non-nullable-type-assertion-style": "off",
|
||||
"camelcase": [
|
||||
"warn",
|
||||
{
|
||||
"properties": "always"
|
||||
}
|
||||
],
|
||||
"eqeqeq": "error",
|
||||
"jsdoc/check-param-names": "off",
|
||||
"jsdoc/require-jsdoc": [
|
||||
"warn",
|
||||
{
|
||||
"require": {
|
||||
"ArrowFunctionExpression": true,
|
||||
"ClassDeclaration": true,
|
||||
"ClassExpression": true,
|
||||
"FunctionDeclaration": true,
|
||||
"FunctionExpression": true,
|
||||
"MethodDefinition": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"jsdoc/require-param": [
|
||||
"warn",
|
||||
{
|
||||
"checkDestructured": false,
|
||||
"checkDestructuredRoots": false
|
||||
}
|
||||
],
|
||||
"padding-line-between-statements": [
|
||||
"warn",
|
||||
{
|
||||
"blankLine": "always",
|
||||
"prev": "*",
|
||||
"next": [
|
||||
"block",
|
||||
"block-like",
|
||||
"class",
|
||||
"do",
|
||||
"for",
|
||||
"function",
|
||||
"if",
|
||||
"iife",
|
||||
"multiline-block-like",
|
||||
"multiline-const",
|
||||
"multiline-expression",
|
||||
"multiline-let",
|
||||
"multiline-var",
|
||||
"switch",
|
||||
"try",
|
||||
"while",
|
||||
"with"
|
||||
]
|
||||
},
|
||||
{
|
||||
"blankLine": "always",
|
||||
"prev": [
|
||||
"block",
|
||||
"block-like",
|
||||
"class",
|
||||
"do",
|
||||
"for",
|
||||
"function",
|
||||
"if",
|
||||
"iife",
|
||||
"multiline-block-like",
|
||||
"multiline-const",
|
||||
"multiline-expression",
|
||||
"multiline-let",
|
||||
"multiline-var",
|
||||
"switch",
|
||||
"try",
|
||||
"while",
|
||||
"with"
|
||||
],
|
||||
"next": "*"
|
||||
},
|
||||
{
|
||||
"blankLine": "always",
|
||||
"prev": "import",
|
||||
"next": [
|
||||
"block",
|
||||
"block-like",
|
||||
"break",
|
||||
"case",
|
||||
"cjs-export",
|
||||
"class",
|
||||
"const",
|
||||
"continue",
|
||||
"debugger",
|
||||
"default",
|
||||
"directive",
|
||||
"do",
|
||||
"empty",
|
||||
"export",
|
||||
"expression",
|
||||
"for",
|
||||
"function",
|
||||
"if",
|
||||
"iife",
|
||||
"let",
|
||||
"multiline-block-like",
|
||||
"multiline-const",
|
||||
"multiline-expression",
|
||||
"multiline-let",
|
||||
"multiline-var",
|
||||
"return",
|
||||
"singleline-const",
|
||||
"singleline-let",
|
||||
"singleline-var",
|
||||
"switch",
|
||||
"throw",
|
||||
"try",
|
||||
"var",
|
||||
"while",
|
||||
"with"
|
||||
]
|
||||
},
|
||||
{
|
||||
"blankLine": "always",
|
||||
"prev": [
|
||||
"block",
|
||||
"block-like",
|
||||
"break",
|
||||
"case",
|
||||
"cjs-import",
|
||||
"class",
|
||||
"const",
|
||||
"continue",
|
||||
"debugger",
|
||||
"default",
|
||||
"directive",
|
||||
"do",
|
||||
"empty",
|
||||
"expression",
|
||||
"for",
|
||||
"function",
|
||||
"if",
|
||||
"iife",
|
||||
"import",
|
||||
"let",
|
||||
"multiline-block-like",
|
||||
"multiline-const",
|
||||
"multiline-expression",
|
||||
"multiline-let",
|
||||
"multiline-var",
|
||||
"return",
|
||||
"singleline-const",
|
||||
"singleline-let",
|
||||
"singleline-var",
|
||||
"switch",
|
||||
"throw",
|
||||
"try",
|
||||
"var",
|
||||
"while",
|
||||
"with"
|
||||
],
|
||||
"next": "export"
|
||||
}
|
||||
],
|
||||
"no-async-promise-executor": "off",
|
||||
"no-extra-semi": "warn",
|
||||
"no-undef": "off",
|
||||
"no-var": "error",
|
||||
"prefer-const": "warn",
|
||||
"prettier/prettier": "warn",
|
||||
"react/prop-types": "off",
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"simple-import-sort/imports": "error",
|
||||
"simple-import-sort/exports": "error",
|
||||
"spaced-comment": [
|
||||
"warn",
|
||||
"always",
|
||||
{
|
||||
"block": {
|
||||
"balanced": true
|
||||
},
|
||||
"markers": [
|
||||
"/"
|
||||
]
|
||||
}
|
||||
],
|
||||
"unicorn/filename-case": "off",
|
||||
"unicorn/import-style": [
|
||||
"error",
|
||||
{
|
||||
"styles": {
|
||||
"node:path": {
|
||||
"named": true
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"unicorn/no-object-as-default-parameter": "off",
|
||||
"unicorn/no-useless-undefined": "off",
|
||||
"unicorn/numeric-separators-style": "off",
|
||||
"unicorn/prefer-query-selector": "off",
|
||||
"unicorn/prefer-spread": "off",
|
||||
"unicorn/prevent-abbreviations": "off",
|
||||
"unicorn/switch-case-braces": "off"
|
||||
}
|
||||
}
|
37
99_references/beacon-main/.github/ISSUE_TEMPLATE/bug-report.md
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: "[BUG] Brief title here"
|
||||
labels: ""
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is. If you can include a screenshot/screen recording, it would be much appreciated.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
19
99_references/beacon-main/.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: "[FEAT] Brief title here"
|
||||
labels: ""
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
16
99_references/beacon-main/.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: docker
|
||||
directory: /
|
||||
schedule:
|
||||
interval: monthly
|
||||
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: monthly
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: /
|
||||
schedule:
|
||||
interval: monthly
|
52
99_references/beacon-main/.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
# Create a release when a new tag is pushed
|
||||
name: release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Generate metadata
|
||||
id: docker_metadata
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ steps.docker_metadata.outputs.tags }}
|
||||
labels: ${{ steps.docker_metadata.outputs.labels }}
|
148
99_references/beacon-main/.gitignore
vendored
Normal file
@@ -0,0 +1,148 @@
|
||||
# Test reports
|
||||
/test
|
||||
|
||||
# Supabase
|
||||
.branches
|
||||
.temp
|
||||
|
||||
# Ionic
|
||||
.ionic
|
||||
.idea
|
||||
.sass-cache
|
||||
.sourcemaps
|
||||
.versions
|
||||
platforms
|
||||
plugins
|
||||
www
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
*.env
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
10
99_references/beacon-main/.prettierrc copy.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"arrowParens": "avoid",
|
||||
"bracketSameLine": false,
|
||||
"bracketSpacing": false,
|
||||
"multilineArraysWrapThreshold": 2,
|
||||
"htmlWhitespaceSensitivity": "strict",
|
||||
"plugins": [
|
||||
"prettier-plugin-multiline-arrays"
|
||||
]
|
||||
}
|
10
99_references/beacon-main/.prettierrc.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"arrowParens": "avoid",
|
||||
"bracketSameLine": false,
|
||||
"bracketSpacing": false,
|
||||
"multilineArraysWrapThreshold": 2,
|
||||
"htmlWhitespaceSensitivity": "strict",
|
||||
"plugins": [
|
||||
"prettier-plugin-multiline-arrays"
|
||||
]
|
||||
}
|
12
99_references/beacon-main/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"antfu.unocss",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"editorconfig.editorconfig",
|
||||
"esbenp.prettier-vscode",
|
||||
"seatonjiang.gitmoji-vscode",
|
||||
"yoavbls.pretty-ts-errors",
|
||||
"zamerick.vscode-caddyfile-syntax",
|
||||
"vitest.explorer"
|
||||
]
|
||||
}
|
22
99_references/beacon-main/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"editor.formatOnSave": true,
|
||||
"editor.formatOnSaveMode": "file",
|
||||
"editor.codeActionsOnSave": [
|
||||
"source.fixAll"
|
||||
],
|
||||
"[html]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[css]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
}
|
||||
}
|
53
99_references/beacon-main/Caddyfile
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
admin off
|
||||
auto_https off
|
||||
persist_config off
|
||||
log default {
|
||||
output stderr
|
||||
format console
|
||||
}
|
||||
grace_period 10s
|
||||
shutdown_delay 30s
|
||||
}
|
||||
|
||||
# Main server
|
||||
:8080 {
|
||||
header {
|
||||
# https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html
|
||||
|
||||
# Remove headers which leak server information
|
||||
-Server
|
||||
-X-Powered-By
|
||||
|
||||
# Add security headers
|
||||
X-Frame-Options "DENY"
|
||||
X-Content-Type-Options "nosniff"
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
Strict-Transport-Security "max-age=63072000; includeSubDomains"
|
||||
Cross-Origin-Opener-Policy "same-origin"
|
||||
Cross-Origin-Resource-Policy "same-site"
|
||||
Content-Security-Policy "default-src 'self'; connect-src 'self' {$CADDY_SUPABASE_URL} https://hcaptcha.com https://*.hcaptcha.com https://*.sentry.io; font-src 'self' data:; frame-src 'self' https://hcaptcha.com https://*.hcaptcha.com; img-src 'self' blob: https://*.basemaps.cartocdn.com; media-src 'self' blob:; script-src 'self' 'sha256-MS6/3FCg4WjP9gwgaBGwLpRCY6fZBgwmhVCdrPrNf3E=' 'sha256-tQjf8gvb2ROOMapIxFvFAYBeUJ0v1HCbOcSmDNXGtDo=' 'sha256-VA8O2hAdooB288EpSTrGLl7z3QikbWU9wwoebO/QaYk=' 'sha256-+5XkZFazzJo8n0iOP4ti/cLCMUudTf//Mzkb7xNPXIc=' https://hcaptcha.com https://*.hcaptcha.com; style-src 'self' 'unsafe-inline' https://hcaptcha.com https://*.hcaptcha.com; object-src 'none';"
|
||||
Permissions-Policy "accelerometer=(), ambient-light-sensor=(), autoplay=self, battery=(), bluetooth=(), browsing-topics=(), camera=(), display-capture=(), document-domain=(), encrypted-media=(), execution-while-not-rendered=(), execution-while-out-of-viewport=(), fullscreen=(), gamepad=(), geolocation=self, gyroscope=(), hid=(), identity-credentials-get=(), idle-detection=(), local-fonts=(), magnetometer=(), microphone=(), midi=(), otp-credentials=(), payment=(), picture-in-picture=(), publickey-credentials-create=self, publickey-credentials-get=self, screen-wake-lock=(), serial=(), speaker-selection=(), storage-access=(), usb=(), web-shared=(), window-management=(), xr-spatial-tracking=(), interest-cohort=()"
|
||||
}
|
||||
|
||||
# Single Page Application (https://caddyserver.com/docs/caddyfile/patterns#single-page-apps-spas)
|
||||
handle {
|
||||
root * /usr/share/caddy
|
||||
encode gzip
|
||||
try_files {path} /index.html
|
||||
templates /runtime-vars.js {
|
||||
mime text/javascript
|
||||
}
|
||||
file_server
|
||||
}
|
||||
}
|
||||
|
||||
# Internal health check
|
||||
:8081 {
|
||||
# See https://caddyserver.com/docs/caddyfile/options#shutdown-delay
|
||||
handle {
|
||||
@goingDown vars {http.shutting_down} true
|
||||
respond @goingDown "Going down in {http.time_until_shutdown}" 503
|
||||
respond "OK" 200
|
||||
}
|
||||
}
|
45
99_references/beacon-main/Dockerfile
Normal file
@@ -0,0 +1,45 @@
|
||||
# Static content Dockerfile
|
||||
|
||||
# Builder image (https://hub.docker.com/layers/library/node/20.12.2-alpine3.18/images/sha256-5cfa23de5d7e5e6226dea49eab15fdf4e53fde84b8feccbce97aa27695242bb9?context=explore)
|
||||
FROM node:22.2.0-alpine3.18@sha256:a46d9fcb38cae53de45b35b90f6df232342242bebc9323a417416eb67942979e AS builder
|
||||
|
||||
# Install packages
|
||||
RUN apk add --no-cache git
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /build
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Install production dependencies
|
||||
RUN npm install --omit=dev
|
||||
|
||||
# Build static content
|
||||
RUN npm run build
|
||||
|
||||
# Base image (https://hub.docker.com/layers/library/caddy/2.7.6-alpine/images/sha256-a6054d207060158cd0f019d6a35907bf47d1f8dacf58cdb63075a930d8ebca38?context=explore)
|
||||
FROM caddy:2.8.4-alpine@sha256:221bcf3be161b0d856bdb7bea76b42386d732d19348f79692404829532d83f4a
|
||||
|
||||
# Install packages
|
||||
RUN apk add --no-cache wget
|
||||
|
||||
# Remove old files
|
||||
RUN rm -rf /etc/caddy/Caddyfile /usr/share/caddy
|
||||
|
||||
# Create the non-root user
|
||||
RUN adduser -D caddy
|
||||
USER caddy
|
||||
|
||||
# Copy files
|
||||
COPY --from=builder /build/Caddyfile /etc/caddy/Caddyfile
|
||||
COPY --from=builder /build/dist /usr/share/caddy
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8080
|
||||
|
||||
# Run Caddy
|
||||
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile"]
|
||||
|
||||
# Health check (Note: the server grace period is 30 seconds)
|
||||
HEALTHCHECK --interval=10s --timeout=5s --start-period=5s --retries=2 CMD wget --quiet --tries=1 --spider http://localhost:8081 || exit 1
|
7
99_references/beacon-main/LICENSE
Normal file
@@ -0,0 +1,7 @@
|
||||
Copyright 2024 Association for Computing Machinery Colorado School of Mines Chapter
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
140
99_references/beacon-main/README.md
Normal file
@@ -0,0 +1,140 @@
|
||||
# Beacon
|
||||
|
||||
<p align="center">
|
||||
<img alt="Beacon card" height="640" width="512" src="src/assets/card.png">
|
||||
</p>
|
||||
|
||||
A location-based social network.
|
||||
|
||||
[](https://github.com/ColoradoSchoolOfMines/beacon/actions/workflows/release.yml)
|
||||
|
||||
> [!WARNING]
|
||||
> This project is under active development and is not yet ready for production use.
|
||||
|
||||
## Documentation
|
||||
|
||||
### Setup
|
||||
|
||||
1. Install dependencies
|
||||
|
||||
- [NodeJS](https://nodejs.org/en/download/) (LTS recommended)
|
||||
- [Git](https://git-scm.com/downloads)
|
||||
- If running Supabase locally:
|
||||
- [Docker/Docker Engine](https://docs.docker.com/engine/install/)
|
||||
|
||||
2. Clone the repository:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/ColoradoSchoolOfMines/beacon.git
|
||||
```
|
||||
|
||||
3. Inside the repository, install the dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
4. If you want to run Supabase locally, start the Docker container:
|
||||
|
||||
```bash
|
||||
# This can take a while the first time you run it because it has to download a bunch of Docker images
|
||||
npm run supabase:start
|
||||
|
||||
# Check the status of the Supabase (Including the dashboard URL and mock email server URL)
|
||||
npm run supabase:status
|
||||
```
|
||||
|
||||
5. Update [`.env`](.env) with the appropriate values (See [Frontend Environment Variables](#frontend-environment-variables)).
|
||||
|
||||
6. Still inside the repository, start the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
7. Open [`http://localhost:3000`](http://localhost:3000) in your browser to access the frontend
|
||||
|
||||
8. If running Supabase locally, reset the database after each schema change:
|
||||
|
||||
```bash
|
||||
npm run supabase:reset
|
||||
```
|
||||
|
||||
### Production
|
||||
|
||||
#### Build
|
||||
|
||||
To build the frontend for production, run:
|
||||
|
||||
```bash
|
||||
docker build -t beacon -f Dockerfile .
|
||||
```
|
||||
|
||||
#### Run
|
||||
|
||||
To run the frontend in production, run:
|
||||
|
||||
```bash
|
||||
docker run -p 80:8080 beacon
|
||||
```
|
||||
|
||||
### Frontend Environment Variables
|
||||
|
||||
| Development Name (i.e.: not in the container) | Production Name (i.e.: in the container) | Description | Default/Required |
|
||||
| --------------------------------------------- | ---------------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `VITE_HCAPTCHA_SITE_KEY` | `CADDY_HCAPTCHA_SITE_KEY` | The hCaptcha site key | Required ( :warning: **Must be manually set** :warning:; see [hCaptcha dashboard](https://dashboard.hcaptcha.com/sites)) |
|
||||
| `VITE_SUPABASE_URL` | `CADDY_SUPABASE_URL` | The absolute Supabase API URL | Required (Automatically set by the setup script) |
|
||||
| `VITE_SUPABASE_ANON_KEY` | `CADDY_SUPABASE_ANON_KEY` | The Supabase API anonymous key | Required (Automatically set by the setup script) |
|
||||
| `VITE_SENTRY_DSN` | `CADDY_SENTRY_DSN` | The Sentry DSN | Optional (Automatically set by the setup script) |
|
||||
|
||||
### Technologies
|
||||
|
||||
- Frontend
|
||||
- Language: [TypeScript](https://www.typescriptlang.org)
|
||||
- Web framework: [React](https://reactjs.org) + [Vite](https://vitejs.dev)
|
||||
- Component library: [Ionic React](https://ionicframework.com/docs/react)
|
||||
- Styling: [UnoCSS (Wind preset)](https://unocss.dev/presets/wind#wind-preset) (Tailwind/WindiCSS compatible)
|
||||
- Backend: [Supabase](https://supabase.com)
|
||||
|
||||
### Algorithm
|
||||
|
||||
Beacon's ranking algorithm is somewhat inspired by the [Lemmy algorithm](https://join-lemmy.org/docs/contributors/07-ranking-algo.html), but has the following properties:
|
||||
|
||||
| Description | Reasoning |
|
||||
| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Quadratic distance **contribution** | Posts that are closer to the user should have a higher rank. This helps users see posts that are more geographically relevant to them. |
|
||||
| Logarithmic score **contribution** | The first $10$, next $100$, next $1000$, etc. votes should have the same contribution to the rank. This helps counteract the bandwagon effect. |
|
||||
| Exponential age **reduction** | The older a post is, the less relevant it likely is. This helps newer posts rank higher. |
|
||||
|
||||
The algorithm is as follows:
|
||||
|
||||
$$
|
||||
\text{Distance component} = (\text{Distance weight} - 1) \cdot (\min\bigg(1, \frac{\text{Distance}}{\text{Distance range}}\bigg) - 1)^{2} + 1
|
||||
$$
|
||||
|
||||
$$
|
||||
\text{Score component} = \log_{10}(\max(1, (\text{Upvotes} - \text{Downvotes}) - \text{Score threshold} + 1))
|
||||
$$
|
||||
|
||||
$$
|
||||
\text{Age component} = \text{Age weight}^{- \text{Age}}
|
||||
$$
|
||||
|
||||
$$
|
||||
\text{Rank} = \lfloor(\text{Scale} \cdot \text{Distance component} \cdot \text{Score component} \cdot \text{Age component}\rfloor
|
||||
$$
|
||||
|
||||
with the following variables:
|
||||
|
||||
| Name | Definition | Min value | Default value | Max value |
|
||||
| ------------------------ | ------------------------------------------------------------- | ---------------------------- | ------------- | --------- |
|
||||
| $\text{Rank}$ | Integer post sorting order (Higher will be sorted first) | - | - | - |
|
||||
| $\text{Scale}$ | Ranking scale factor (To allow the rank to be rounded) | $1$ (Don't rank) | $10000$ | - |
|
||||
| $\text{Distance}$ | Distance between the post and the user's location (In meters) | - | - | - |
|
||||
| $\text{Distance weight}$ | Distance weight factor | $1$ (Don't rank by distance) | $5$ | - |
|
||||
| $\text{Distance range}$ | Maximum distance to be considered (In meters) | $1$ | $5000$ | $50000$ |
|
||||
| $\text{Upvotes}$ | Post upvotes | - | - | - |
|
||||
| $\text{Downvotes}$ | Post downvotes | - | - | - |
|
||||
| $\text{Score threshold}$ | Minimum score threshold considered | $-5$ | $-5$ | - |
|
||||
| $\text{Age}$ | Post age (In hours) | - | - | - |
|
||||
| $\text{Age weight}$ | Age weight factor | $1$ (Don't weight by age) | $1.075$ | - |
|
44
99_references/beacon-main/index.html
Normal file
@@ -0,0 +1,44 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Beacon</title>
|
||||
<meta name="description" content="Beacon Social Network" />
|
||||
<meta
|
||||
name="keywords"
|
||||
content="Beacon,Location,Proximity,Social,Social Network"
|
||||
/>
|
||||
|
||||
<base href="/" />
|
||||
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
||||
/>
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<meta name="msapplication-tap-highlight" content="no" />
|
||||
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
|
||||
<link rel="shortcut icon" type="image/png" href="/favicon.png" />
|
||||
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-title" content="Beacon" />
|
||||
<meta
|
||||
name="apple-mobile-web-app-status-bar-style"
|
||||
content="black-translucent"
|
||||
/>
|
||||
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
|
||||
<% if (!isDev) { %>
|
||||
<script type="module" crossorigin src="/runtime-vars.js"></script>
|
||||
<% } %>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
13485
99_references/beacon-main/package-lock.json
generated
Normal file
94
99_references/beacon-main/package.json
Normal file
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"name": "beacon",
|
||||
"private": true,
|
||||
"version": "0.0.1-alpha.7",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"clean": "rm -rf dist",
|
||||
"analyze": "vite-bundle-visualizer --open",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test": "vitest --run",
|
||||
"test:preview": "vite preview --outDir test/vitest",
|
||||
"test:watch": "vitest",
|
||||
"supabase:reset": "tsx scripts/reset.ts",
|
||||
"supabase:start": "tsx scripts/start.ts",
|
||||
"supabase:stop": "supabase stop",
|
||||
"supabase:status": "supabase status",
|
||||
"supabase:schema": "tsx scripts/schema.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hcaptcha/react-hcaptcha": "^1.10.1",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@ionic/react": "^8.2.4",
|
||||
"@ionic/react-router": "^8.2.2",
|
||||
"@ionic/storage": "^4.0.0",
|
||||
"@sentry/browser": "^8.9.2",
|
||||
"@supabase/sentry-js-integration": "^0.2.0",
|
||||
"@supabase/supabase-js": "^2.45.0",
|
||||
"@types/humanize-duration": "^3.27.4",
|
||||
"@types/leaflet": "^1.9.12",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@unocss/preset-wind": "^0.61.0",
|
||||
"@unocss/transformer-directives": "^0.61.0",
|
||||
"@vitejs/plugin-legacy": "^5.4.1",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"blurhash": "^2.0.5",
|
||||
"colord": "^2.9.3",
|
||||
"dotenv": "^16.4.5",
|
||||
"execa": "^9.3.0",
|
||||
"hast-util-sanitize": "^5.0.1",
|
||||
"humanize-duration": "^3.32.1",
|
||||
"ionicons": "^7.4.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"lodash-es": "^4.17.21",
|
||||
"luxon": "^3.4.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.52.0",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-router-dom": "^5.3.4",
|
||||
"react-use": "^17.5.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"tsx": "^4.15.5",
|
||||
"typescript": "^5.4.5",
|
||||
"unocss": "^0.61.0",
|
||||
"virtua": "^0.33.2",
|
||||
"vite": "^5.3.3",
|
||||
"vite-plugin-ejs": "^1.7.0",
|
||||
"vite-plugin-pwa": "^0.20.0",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
"vite-tsconfig-paths": "^4.3.2",
|
||||
"zod": "^3.23.8",
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@limegrass/eslint-plugin-import-alias": "^1.4.1",
|
||||
"@typescript-eslint/eslint-plugin": "^7.13.0",
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"@vitest/coverage-v8": "^1.6.0",
|
||||
"@vitest/ui": "^1.6.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-jsdoc": "^48.2.12",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-react": "^7.34.2",
|
||||
"eslint-plugin-simple-import-sort": "^12.1.0",
|
||||
"eslint-plugin-unicorn": "^55.0.0",
|
||||
"prettier": "^3.3.2",
|
||||
"prettier-plugin-multiline-arrays": "^3.0.6",
|
||||
"supabase": "^1.178.2",
|
||||
"vite-bundle-visualizer": "^1.2.1",
|
||||
"vitest": "^1.6.0"
|
||||
}
|
||||
}
|
97
99_references/beacon-main/public/custom/faq.md
Normal file
@@ -0,0 +1,97 @@
|
||||
### Why can I not see any posts?
|
||||
|
||||
There are a few reasons why you may not see any posts:
|
||||
|
||||
- There are no posts in your area - that means you can be the first person to post in your area!
|
||||
- You denied location permissions to your browser and/or Beacon. Check your device/browser settings to ensure you have granted location permissions to the browser itself **and** to Beacon.
|
||||
|
||||
### Why is my location incorrect?
|
||||
|
||||
We use the location information provided by your device, which may not always be accurate. You can try the following to improve the accuracy of your location:
|
||||
|
||||
1. Ensure you have granted precise location permissions to your browser.
|
||||
2. Ensure sure you have allowed the browser to access your location.
|
||||
3. Ensure you have enabled location services on your device.
|
||||
4. Turn on Wi-Fi and Bluetooth on your device (Which many devices use to improve location accuracy).
|
||||
|
||||
_Note that when you make a post, we also add a small random offset to your location to protect your privacy. This offset can be up to 10% of the radius you specify for the post._
|
||||
|
||||
### Why do I keep getting geolocation permission errors? / Why can't I add a photo or video to my post?
|
||||
|
||||
You have not granted (sufficient) permissions to your browser to access your device's camera and/or gallery. See the **What permissions does Beacon require?** section for more information.
|
||||
|
||||
### What permissions does Beacon require?
|
||||
|
||||
In general, Beacon requires precise geolocation permissions to show you posts in your area. Furthermore, if you want to make a post, Beacon requires permissions to access your device's camera and/or gallery to add photos and videos to your post. The following are the bare-minimum permissions that have been tested to work with Beacon:
|
||||
|
||||
#### Chrome for Android
|
||||
|
||||
- `Camera`: `Ask every time`
|
||||
|
||||
#### Firefox for Android
|
||||
|
||||
- `Location`: `Allow only while using the app` with `Use precise location` enabled
|
||||
- `Music and audio`: `Allow`
|
||||
- `Photos and videos`: `Always allow all`
|
||||
- `Camera`: `Ask every time`
|
||||
- `Microphone`: `Ask every time`
|
||||
|
||||
### How do I add rich-text features to posts or comments?
|
||||
|
||||
Beacon supports Markdown (specifically a subset of Github-Flavored Markdown/GFM), including:
|
||||
|
||||
**\*\*Bolded text\*\***, _\*Italicized text\*_, <u>\<u>Underlined text\</u></u>, <del>\~\~Striked-through text\~\~</del>, <sup>\<sup>Superscript text\</sup></sup>, <sub>\<sub>Subscript text\</sub></sub>,`` `inline code` ``,
|
||||
|
||||
````
|
||||
```
|
||||
block code
|
||||
```
|
||||
````
|
||||
|
||||
> \> First-level quote
|
||||
|
||||
> > \>\> Second-level quote
|
||||
|
||||
_Etc._
|
||||
|
||||
# \# Top-level heading
|
||||
|
||||
## \#\# Second-level heading
|
||||
|
||||
_Etc._
|
||||
|
||||
Thematic breaks:
|
||||
|
||||
```markdown
|
||||
---
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Expandable sections:
|
||||
|
||||
```markdown
|
||||
<details>
|
||||
<summary>Click to expand</summary>
|
||||
|
||||
This is a collapsible section.
|
||||
|
||||
</details>
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Click to expand</summary>
|
||||
|
||||
This is a collapsible section.
|
||||
|
||||
</details>
|
||||
|
||||
```markdown
|
||||
| Tables | Are | Cool |
|
||||
| ------ | ---- | ---- |
|
||||
| Yes | They | Are |
|
||||
```
|
||||
|
||||
| Tables | Are | Cool |
|
||||
| ------ | ---- | ---- |
|
||||
| Yes | They | Are |
|
@@ -0,0 +1,7 @@
|
||||
<!--
|
||||
|
||||
Suggested privacy policy generator: https://termly.io/resources/templates/privacy-policy-template/
|
||||
|
||||
-->
|
||||
|
||||
Add your privacy policy here.
|
@@ -0,0 +1,7 @@
|
||||
<!--
|
||||
|
||||
Suggested privacy policy generator: https://termly.io/resources/templates/terms-of-service-template/
|
||||
|
||||
-->
|
||||
|
||||
Add your terms and conditions here.
|
BIN
99_references/beacon-main/public/favicon.png
Normal file
After Width: | Height: | Size: 4.0 KiB |
BIN
99_references/beacon-main/public/logo-192x192.png
Normal file
After Width: | Height: | Size: 13 KiB |
BIN
99_references/beacon-main/public/logo-512x512.png
Normal file
After Width: | Height: | Size: 40 KiB |
6
99_references/beacon-main/public/runtime-vars.js
Normal file
@@ -0,0 +1,6 @@
|
||||
window.__VITE_VARS__ = {
|
||||
VITE_SUPABASE_URL: {{env "CADDY_SUPABASE_URL" | mustToRawJson}},
|
||||
VITE_SUPABASE_ANON_KEY: {{env "CADDY_SUPABASE_ANON_KEY" | mustToRawJson}},
|
||||
VITE_HCAPTCHA_SITE_KEY: {{env "CADDY_HCAPTCHA_SITE_KEY" | mustToRawJson}},
|
||||
VITE_SENTRY_DSN: {{env "CADDY_SENTRY_DSN" | mustToRawJson}},
|
||||
};
|
98
99_references/beacon-main/scripts/lib.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/* eslint-disable unicorn/no-process-exit */
|
||||
/**
|
||||
* @file Script utilities
|
||||
*/
|
||||
|
||||
import {constants, writeFile} from "node:fs/promises";
|
||||
import {dirname, join} from "node:path";
|
||||
import {fileURLToPath} from "node:url";
|
||||
|
||||
import {execa} from "execa";
|
||||
|
||||
import {getSchema, getStatus} from "#/supabase/supabase";
|
||||
|
||||
/**
|
||||
* Project root directory
|
||||
*/
|
||||
export const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
/**
|
||||
* Frontend environment file
|
||||
*/
|
||||
const frontendEnv = join(root, ".env");
|
||||
|
||||
/**
|
||||
* Environment file flags
|
||||
*/
|
||||
const envFlags = constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY;
|
||||
|
||||
/**
|
||||
* Write environment files
|
||||
*/
|
||||
export const writeEnvs = async () => {
|
||||
// Get the status
|
||||
const status = await getStatus();
|
||||
|
||||
// Create the environment files
|
||||
try {
|
||||
await writeFile(
|
||||
frontendEnv,
|
||||
`VITE_HCAPTCHA_SITE_KEY = "" # Required!
|
||||
VITE_SUPABASE_URL = ${JSON.stringify(status.apiUrl)}
|
||||
VITE_SUPABASE_ANON_KEY = ${JSON.stringify(status.anonKey)}
|
||||
VITE_SENTRY_DSN = "" # Required!`,
|
||||
{
|
||||
flag: envFlags,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if ((error as any)?.code !== "EEXIST") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Frontend schema file
|
||||
*/
|
||||
const frontendSchema = join(root, "src", "lib", "schema.ts");
|
||||
|
||||
/**
|
||||
* Write Supabase schema files
|
||||
*/
|
||||
export const writeSupabaseSchema = async () => {
|
||||
// Generate the content
|
||||
const content = `/**
|
||||
* @file Supabase schema
|
||||
*
|
||||
* This file is automatically generated by npm run supabase:schema. DO NOT EDIT THIS FILE!
|
||||
*/
|
||||
|
||||
${await getSchema()}`;
|
||||
|
||||
// Write the files
|
||||
await writeFile(frontendSchema, content);
|
||||
|
||||
const {all, exitCode, failed} = await execa(
|
||||
"eslint",
|
||||
[
|
||||
frontendSchema,
|
||||
"--fix",
|
||||
"--debug",
|
||||
],
|
||||
{
|
||||
all: true,
|
||||
cwd: root,
|
||||
preferLocal: true,
|
||||
reject: false,
|
||||
},
|
||||
);
|
||||
|
||||
if (failed) {
|
||||
console.error(
|
||||
`Formatting Supabase schema failed (Exit code ${exitCode}): ${all}`,
|
||||
);
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
21
99_references/beacon-main/scripts/reset.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* @file Reset Supabase
|
||||
*/
|
||||
|
||||
import {writeEnvs} from "#/scripts/lib";
|
||||
import {reset} from "#/supabase/supabase";
|
||||
|
||||
/**
|
||||
* Main async function
|
||||
*/
|
||||
const main = async () => {
|
||||
await reset();
|
||||
await writeEnvs();
|
||||
|
||||
// Log
|
||||
console.info(
|
||||
"Reset complete. You may start your frontend now. (If it's already running, please restart it)",
|
||||
);
|
||||
};
|
||||
|
||||
main();
|
17
99_references/beacon-main/scripts/schema.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* @file Generate Supabase schema files
|
||||
*/
|
||||
|
||||
import {writeSupabaseSchema} from "#/scripts/lib";
|
||||
|
||||
/**
|
||||
* Main async function
|
||||
*/
|
||||
const main = async () => {
|
||||
await writeSupabaseSchema();
|
||||
|
||||
// Log
|
||||
console.info("Schema files generated.");
|
||||
};
|
||||
|
||||
main();
|
21
99_references/beacon-main/scripts/start.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* @file Start Supabase
|
||||
*/
|
||||
|
||||
import {writeEnvs} from "#/scripts/lib";
|
||||
import {start} from "#/supabase/supabase";
|
||||
|
||||
/**
|
||||
* Main async function
|
||||
*/
|
||||
const main = async () => {
|
||||
await start();
|
||||
await writeEnvs();
|
||||
|
||||
// Log
|
||||
console.info(
|
||||
"Setup complete. You may start your frontend now. (If it's already running, please restart it)",
|
||||
);
|
||||
};
|
||||
|
||||
main();
|
375
99_references/beacon-main/src/app.tsx
Normal file
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* @file App shell
|
||||
*/
|
||||
|
||||
// Setup geolocation
|
||||
import "~/lib/geolocation";
|
||||
|
||||
import {IonRouterOutlet, IonSplitPane} from "@ionic/react";
|
||||
import {User} from "@supabase/supabase-js";
|
||||
import {isEqual} from "lodash-es";
|
||||
import {ComponentProps, FC, useEffect} from "react";
|
||||
import {Route, useHistory, useLocation} from "react-router-dom";
|
||||
|
||||
import {GlobalMessage} from "~/components/global-message";
|
||||
import {Menu} from "~/components/menu";
|
||||
import {useEphemeralStore} from "~/lib/stores/ephemeral";
|
||||
import {usePersistentStore} from "~/lib/stores/persistent";
|
||||
import {client} from "~/lib/supabase";
|
||||
import {AuthState, GlobalMessageMetadata, Theme} from "~/lib/types";
|
||||
import {getAuthState} from "~/lib/utils";
|
||||
import {Step1 as AuthStep1} from "~/pages/auth/step1";
|
||||
import {Step2 as AuthStep2} from "~/pages/auth/step2";
|
||||
import {Step3 as AuthStep3} from "~/pages/auth/step3";
|
||||
import {Error} from "~/pages/error";
|
||||
import {Index} from "~/pages/index";
|
||||
import {Markdown} from "~/pages/markdown";
|
||||
import {Nearby} from "~/pages/nearby";
|
||||
import {Step1 as CreateCommentStep1} from "~/pages/posts/[id]/comments/create/step1";
|
||||
import {PostIndex} from "~/pages/posts/[id]/index";
|
||||
import {Step1 as CreatePostStep1} from "~/pages/posts/create/step1";
|
||||
import {Step2 as CreatePostStep2} from "~/pages/posts/create/step2";
|
||||
import {Settings} from "~/pages/settings";
|
||||
|
||||
/**
|
||||
* Signed out message metadata
|
||||
*/
|
||||
const SIGNED_OUT_MESSAGE_METADATA: GlobalMessageMetadata = {
|
||||
symbol: Symbol("app.signed-out"),
|
||||
name: "Signed out",
|
||||
description: "You have been signed out.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Route metadata
|
||||
*/
|
||||
interface RouteMetadata<T extends FC> {
|
||||
/**
|
||||
* Unique identifier
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* Regex route path
|
||||
*/
|
||||
regexPath: RegExp;
|
||||
|
||||
/**
|
||||
* React route path
|
||||
*/
|
||||
routerPath?: string;
|
||||
|
||||
/**
|
||||
* Whether the React route is exact
|
||||
*/
|
||||
routerExact?: boolean;
|
||||
|
||||
/**
|
||||
* Required authentication state or undefined if the route is always available
|
||||
*/
|
||||
requiredState?: AuthState;
|
||||
|
||||
/**
|
||||
* Route component
|
||||
*/
|
||||
component: T;
|
||||
|
||||
/**
|
||||
* React component props
|
||||
*/
|
||||
componentProps?: ComponentProps<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route metadata
|
||||
*/
|
||||
const routeMetadata: RouteMetadata<any>[] = [
|
||||
{
|
||||
id: "index",
|
||||
regexPath: /^\/$/,
|
||||
routerPath: "/",
|
||||
routerExact: true,
|
||||
component: Index,
|
||||
},
|
||||
{
|
||||
id: "faq",
|
||||
regexPath: /^\/faq$/,
|
||||
routerPath: "/faq",
|
||||
routerExact: true,
|
||||
component: Markdown,
|
||||
componentProps: {
|
||||
title: "Frequently Asked Questions",
|
||||
url: "/custom/faq.md",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "terms-and-conditions",
|
||||
regexPath: /^\/terms-and-conditions$/,
|
||||
routerPath: "/terms-and-conditions",
|
||||
routerExact: true,
|
||||
component: Markdown,
|
||||
componentProps: {
|
||||
title: "Terms and Conditions",
|
||||
url: "/custom/terms-and-conditions.md",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "privacy-policy",
|
||||
regexPath: /^\/privacy-policy$/,
|
||||
routerPath: "/privacy-policy",
|
||||
routerExact: true,
|
||||
component: Markdown,
|
||||
componentProps: {
|
||||
title: "Privacy Policy",
|
||||
url: "/custom/privacy-policy.md",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "auth-step-1",
|
||||
regexPath: /^\/auth\/1$/,
|
||||
routerPath: "/auth/1",
|
||||
routerExact: true,
|
||||
requiredState: AuthState.UNAUTHENTICATED,
|
||||
component: AuthStep1,
|
||||
},
|
||||
{
|
||||
id: "auth-step-2",
|
||||
regexPath: /^\/auth\/2$/,
|
||||
routerPath: "/auth/2",
|
||||
routerExact: true,
|
||||
requiredState: AuthState.UNAUTHENTICATED,
|
||||
component: AuthStep2,
|
||||
},
|
||||
{
|
||||
id: "auth-step-3",
|
||||
regexPath: /^\/auth\/3$/,
|
||||
routerPath: "/auth/3",
|
||||
routerExact: true,
|
||||
requiredState: AuthState.AUTHENTICATED_NO_TERMS,
|
||||
component: AuthStep3,
|
||||
},
|
||||
{
|
||||
id: "nearby",
|
||||
regexPath: /^\/nearby$/,
|
||||
routerPath: "/nearby",
|
||||
routerExact: true,
|
||||
requiredState: AuthState.AUTHENTICATED_TERMS,
|
||||
component: Nearby,
|
||||
},
|
||||
{
|
||||
id: "posts-create-1",
|
||||
regexPath: /^\/posts\/create\/1$/,
|
||||
routerPath: "/posts/create/1",
|
||||
routerExact: true,
|
||||
requiredState: AuthState.AUTHENTICATED_TERMS,
|
||||
component: CreatePostStep1,
|
||||
},
|
||||
{
|
||||
id: "posts-create-2",
|
||||
regexPath: /^\/posts\/create\/2$/,
|
||||
routerPath: "/posts/create/2",
|
||||
routerExact: true,
|
||||
requiredState: AuthState.AUTHENTICATED_TERMS,
|
||||
component: CreatePostStep2,
|
||||
},
|
||||
{
|
||||
id: "posts-comments-create-1",
|
||||
regexPath:
|
||||
/^\/posts\/[\dA-Fa-f]{8}(?:-[\dA-Fa-f]{4}){3}-[\dA-Fa-f]{12}\/comments\/create\/1$/,
|
||||
routerPath: "/posts/:id/comments/create/1",
|
||||
routerExact: true,
|
||||
requiredState: AuthState.AUTHENTICATED_TERMS,
|
||||
component: CreateCommentStep1,
|
||||
},
|
||||
{
|
||||
id: "posts-index",
|
||||
regexPath: /^\/posts\/[\dA-Fa-f]{8}(?:-[\dA-Fa-f]{4}){3}-[\dA-Fa-f]{12}$/,
|
||||
routerPath: "/posts/:id",
|
||||
routerExact: true,
|
||||
requiredState: AuthState.AUTHENTICATED_TERMS,
|
||||
component: PostIndex,
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
regexPath: /^\/settings$/,
|
||||
routerPath: "/settings",
|
||||
routerExact: true,
|
||||
requiredState: AuthState.AUTHENTICATED_TERMS,
|
||||
component: Settings,
|
||||
},
|
||||
{
|
||||
id: "error",
|
||||
regexPath: /^.*$/,
|
||||
component: Error,
|
||||
componentProps: {
|
||||
name: "404",
|
||||
description: "The requested page was not found!",
|
||||
homeButton: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Set the user from the backend (Don't block because this makes a request to the backend)
|
||||
// eslint-disable-next-line unicorn/prefer-top-level-await
|
||||
(async () => {
|
||||
// If there is no user, return
|
||||
if (useEphemeralStore.getState().user === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the user
|
||||
const {data, error} = await client.auth.getUser();
|
||||
|
||||
// If the backend returns an error or the user is null, sign out
|
||||
if (data.user === null || error !== null) {
|
||||
await client.auth.signOut();
|
||||
}
|
||||
// Otherwise the user is logged in
|
||||
else {
|
||||
useEphemeralStore.getState().setUser(data.user);
|
||||
}
|
||||
})();
|
||||
|
||||
/**
|
||||
* App shell
|
||||
* @returns JSX
|
||||
*/
|
||||
export const App: FC = () => {
|
||||
// Hooks
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
|
||||
const setMessage = useEphemeralStore(state => state.setMessage);
|
||||
const user = useEphemeralStore(state => state.user);
|
||||
const setUser = useEphemeralStore(state => state.setUser);
|
||||
const theme = usePersistentStore(state => state.theme);
|
||||
|
||||
// Methods
|
||||
/**
|
||||
* Guard the current route
|
||||
* @param pathname Current route pathname
|
||||
* @param user Current user
|
||||
*/
|
||||
const guardRoute = (pathname: string, user?: User | null) => {
|
||||
// Require the user's state to be initialized (even if it's null)
|
||||
if (user === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the required authentication state
|
||||
const requiredState = routeMetadata.find(({regexPath: regex}) =>
|
||||
regex.test(pathname),
|
||||
)?.requiredState;
|
||||
|
||||
if (requiredState === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the user's current authentication state
|
||||
const authState = getAuthState(user);
|
||||
|
||||
// User needs to authenticate
|
||||
if (
|
||||
authState === AuthState.UNAUTHENTICATED &&
|
||||
[
|
||||
AuthState.AUTHENTICATED_NO_TERMS,
|
||||
AuthState.AUTHENTICATED_TERMS,
|
||||
].includes(requiredState)
|
||||
) {
|
||||
history.push("/auth/1");
|
||||
return;
|
||||
}
|
||||
|
||||
// User needs to accept the terms and conditions
|
||||
if (
|
||||
authState === AuthState.AUTHENTICATED_NO_TERMS &&
|
||||
requiredState === AuthState.AUTHENTICATED_TERMS
|
||||
) {
|
||||
history.push("/auth/3");
|
||||
return;
|
||||
}
|
||||
|
||||
// User is already authenticated and has accepted the terms and conditions
|
||||
if (
|
||||
([
|
||||
AuthState.AUTHENTICATED_NO_TERMS,
|
||||
AuthState.AUTHENTICATED_TERMS,
|
||||
].includes(authState) &&
|
||||
requiredState === AuthState.UNAUTHENTICATED) ||
|
||||
(authState === AuthState.AUTHENTICATED_TERMS &&
|
||||
requiredState === AuthState.AUTHENTICATED_NO_TERMS)
|
||||
) {
|
||||
history.push("/nearby");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Effects
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle("dark", theme === Theme.DARK);
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => guardRoute(location.pathname, user), [location, user]);
|
||||
|
||||
// Subscribe to auth changes
|
||||
useEffect(() => {
|
||||
client.auth.onAuthStateChange(async (event, session) => {
|
||||
// eslint-disable-next-line unicorn/no-null
|
||||
let newUser = user ?? null;
|
||||
|
||||
switch (event) {
|
||||
case "INITIAL_SESSION":
|
||||
case "SIGNED_IN":
|
||||
case "TOKEN_REFRESHED":
|
||||
case "USER_UPDATED":
|
||||
// Set the user
|
||||
// eslint-disable-next-line unicorn/no-null
|
||||
newUser = session?.user ?? null;
|
||||
break;
|
||||
|
||||
case "SIGNED_OUT": {
|
||||
// Display the message
|
||||
setMessage(SIGNED_OUT_MESSAGE_METADATA);
|
||||
|
||||
// Clear the user
|
||||
// eslint-disable-next-line unicorn/no-null
|
||||
newUser = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Set the user
|
||||
if (user === undefined || !isEqual(user, newUser)) {
|
||||
setUser(newUser);
|
||||
}
|
||||
|
||||
// Guard the route against the new authentication state
|
||||
guardRoute(location.pathname, newUser);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IonSplitPane contentId="main">
|
||||
{location.pathname !== "/" && <Menu />}
|
||||
|
||||
<IonRouterOutlet id="main">
|
||||
{routeMetadata.map(
|
||||
({
|
||||
id,
|
||||
routerPath,
|
||||
routerExact,
|
||||
component: Component,
|
||||
componentProps,
|
||||
}) => (
|
||||
<Route key={id} path={routerPath} exact={routerExact}>
|
||||
<Component {...componentProps} />
|
||||
</Route>
|
||||
),
|
||||
)}
|
||||
</IonRouterOutlet>
|
||||
</IonSplitPane>
|
||||
|
||||
<GlobalMessage />
|
||||
</>
|
||||
);
|
||||
};
|
BIN
99_references/beacon-main/src/assets/card.png
Normal file
After Width: | Height: | Size: 263 KiB |
391
99_references/beacon-main/src/assets/card.svg
Normal file
@@ -0,0 +1,391 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="2048"
|
||||
height="2560"
|
||||
viewBox="0 0 512 640"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
inkscape:version="1.3.2 (091e20ef0f, 2023-11-25, custom)"
|
||||
sodipodi:docname="card.svg"
|
||||
inkscape:export-filename="card.png"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#505050"
|
||||
bordercolor="#ffffff"
|
||||
borderopacity="1"
|
||||
inkscape:showpageshadow="0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pagecheckerboard="1"
|
||||
inkscape:deskcolor="#505050"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:zoom="0.21251184"
|
||||
inkscape:cx="665.84525"
|
||||
inkscape:cy="941.12403"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1014"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="layer1"
|
||||
inkscape:export-bgcolor="#ffffff00"
|
||||
showgrid="false"
|
||||
showguides="true" />
|
||||
<defs
|
||||
id="defs1">
|
||||
<rect
|
||||
x="337.00638"
|
||||
y="2258.6978"
|
||||
width="2100.5049"
|
||||
height="442.32831"
|
||||
id="rect3" />
|
||||
<linearGradient
|
||||
id="linearGradient9"
|
||||
inkscape:collect="always">
|
||||
<stop
|
||||
style="stop-color:#89bbcf;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop9" />
|
||||
<stop
|
||||
style="stop-color:#89bbcf;stop-opacity:0;"
|
||||
offset="1"
|
||||
id="stop10" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="linearGradient5"
|
||||
inkscape:collect="always">
|
||||
<stop
|
||||
style="stop-color:#f9cb7e;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop6" />
|
||||
<stop
|
||||
style="stop-color:#f4676f;stop-opacity:1;"
|
||||
offset="1"
|
||||
id="stop8" />
|
||||
</linearGradient>
|
||||
<rect
|
||||
x="337.00638"
|
||||
y="2253.9922"
|
||||
width="1829.6202"
|
||||
height="411.42795"
|
||||
id="rect2" />
|
||||
<inkscape:path-effect
|
||||
effect="offset"
|
||||
id="path-effect11"
|
||||
is_visible="true"
|
||||
lpeversion="1.2"
|
||||
linejoin_type="miter"
|
||||
unit="mm"
|
||||
offset="10"
|
||||
miter_limit="4"
|
||||
attempt_force_join="false"
|
||||
update_on_knot_move="true" />
|
||||
<inkscape:path-effect
|
||||
effect="offset"
|
||||
id="path-effect10"
|
||||
is_visible="true"
|
||||
lpeversion="1.2"
|
||||
linejoin_type="miter"
|
||||
unit="px"
|
||||
offset="-10"
|
||||
miter_limit="4"
|
||||
attempt_force_join="false"
|
||||
update_on_knot_move="true" />
|
||||
<linearGradient
|
||||
id="linearGradient3"
|
||||
inkscape:collect="always">
|
||||
<stop
|
||||
style="stop-color:#0ea2c0;stop-opacity:0;"
|
||||
offset="0.2"
|
||||
id="stop3" />
|
||||
<stop
|
||||
style="stop-color:#4a7a90;stop-opacity:0.28981349;"
|
||||
offset="0.60000002"
|
||||
id="stop7" />
|
||||
<stop
|
||||
style="stop-color:#5adbf3;stop-opacity:1;"
|
||||
offset="1"
|
||||
id="stop5" />
|
||||
</linearGradient>
|
||||
<inkscape:path-effect
|
||||
effect="powerclip"
|
||||
id="path-effect5"
|
||||
is_visible="true"
|
||||
lpeversion="1"
|
||||
inverse="true"
|
||||
flatten="false"
|
||||
hide_clip="false"
|
||||
message="Use fill-rule evenodd on <b>fill and stroke</b> dialog if no flatten result after convert clip to paths." />
|
||||
<inkscape:path-effect
|
||||
effect="powerclip"
|
||||
id="path-effect3"
|
||||
is_visible="true"
|
||||
lpeversion="1"
|
||||
inverse="true"
|
||||
flatten="false"
|
||||
hide_clip="false"
|
||||
message="Use fill-rule evenodd on <b>fill and stroke</b> dialog if no flatten result after convert clip to paths." />
|
||||
<linearGradient
|
||||
id="linearGradient1"
|
||||
inkscape:collect="always">
|
||||
<stop
|
||||
style="stop-color:#0e3c5e;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop1" />
|
||||
<stop
|
||||
style="stop-color:#0c1922;stop-opacity:1;"
|
||||
offset="1"
|
||||
id="stop2" />
|
||||
</linearGradient>
|
||||
<inkscape:path-effect
|
||||
effect="powerclip"
|
||||
id="path-effect2"
|
||||
is_visible="true"
|
||||
lpeversion="1"
|
||||
inverse="true"
|
||||
flatten="false"
|
||||
hide_clip="false"
|
||||
message="Use fill-rule evenodd on <b>fill and stroke</b> dialog if no flatten result after convert clip to paths." />
|
||||
<linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient1"
|
||||
id="linearGradient2"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="512.49982"
|
||||
y2="512.49982"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="scale(1.0000001,1.5625001)" />
|
||||
<clipPath
|
||||
clipPathUnits="userSpaceOnUse"
|
||||
id="clipPath3-5">
|
||||
<circle
|
||||
style="display:none;fill:#ff0000;fill-opacity:1;stroke:none;stroke-width:0.638045"
|
||||
id="circle3-6"
|
||||
cx="256"
|
||||
cy="121.46362"
|
||||
r="36.474045"
|
||||
inkscape:label="Pin hole" />
|
||||
<path
|
||||
id="lpe_path-effect3-2"
|
||||
style="fill:#ff0000;fill-opacity:1;stroke:none;stroke-width:0.638045"
|
||||
class="powerclip"
|
||||
d="M 194.25883,60.055353 H 317.74117 V 234.07164 H 194.25883 Z m 98.21521,61.408267 A 36.474045,36.474045 0 0 0 256,84.989578 36.474045,36.474045 0 0 0 219.52596,121.46362 36.474045,36.474045 0 0 0 256,157.93767 36.474045,36.474045 0 0 0 292.47404,121.46362 Z" />
|
||||
</clipPath>
|
||||
<inkscape:path-effect
|
||||
effect="powerclip"
|
||||
id="path-effect3-9"
|
||||
is_visible="true"
|
||||
lpeversion="1"
|
||||
inverse="true"
|
||||
flatten="false"
|
||||
hide_clip="false"
|
||||
message="Use fill-rule evenodd on <b>fill and stroke</b> dialog if no flatten result after convert clip to paths." />
|
||||
<clipPath
|
||||
clipPathUnits="userSpaceOnUse"
|
||||
id="clipath_lpe_path-effect3-9">
|
||||
<circle
|
||||
style="display:none;fill:#ff0000;fill-opacity:1;stroke:none;stroke-width:0.638045"
|
||||
id="circle4"
|
||||
cx="256"
|
||||
cy="121.46362"
|
||||
r="36.474045"
|
||||
inkscape:label="Pin hole" />
|
||||
</clipPath>
|
||||
<clipPath
|
||||
clipPathUnits="userSpaceOnUse"
|
||||
id="clipPath5">
|
||||
<circle
|
||||
style="display:none;fill:#ff0000;fill-opacity:1;stroke:none;stroke-width:0.638045"
|
||||
id="circle5"
|
||||
cx="256"
|
||||
cy="121.46362"
|
||||
r="36.474045"
|
||||
inkscape:label="Pin hole"
|
||||
d="M 292.47404,121.46362 A 36.474045,36.474045 0 0 1 256,157.93767 36.474045,36.474045 0 0 1 219.52596,121.46362 36.474045,36.474045 0 0 1 256,84.989578 36.474045,36.474045 0 0 1 292.47404,121.46362 Z" />
|
||||
<path
|
||||
id="lpe_path-effect5"
|
||||
style="display:inline;fill:#ff0000;fill-opacity:1;stroke:none;stroke-width:0.638045"
|
||||
class="powerclip"
|
||||
d="M 194.25883,60.055353 H 317.74117 V 225.23719 H 194.25883 Z m 98.21521,61.408267 A 36.474045,36.474045 0 0 0 256,84.989578 36.474045,36.474045 0 0 0 219.52596,121.46362 36.474045,36.474045 0 0 0 256,157.93767 36.474045,36.474045 0 0 0 292.47404,121.46362 Z" />
|
||||
</clipPath>
|
||||
<clipPath
|
||||
clipPathUnits="userSpaceOnUse"
|
||||
id="clipPath5-6">
|
||||
<circle
|
||||
style="display:none;fill:#ff0000;fill-opacity:1;stroke:none;stroke-width:0.638045"
|
||||
id="circle5-2"
|
||||
cx="256"
|
||||
cy="121.46362"
|
||||
r="36.474045"
|
||||
inkscape:label="Pin hole" />
|
||||
<path
|
||||
id="lpe_path-effect5-6"
|
||||
style="display:inline;fill:#ff0000;fill-opacity:1;stroke:none;stroke-width:0.638045"
|
||||
class="powerclip"
|
||||
d="M 194.25883,60.055353 H 317.74117 V 225.23719 H 194.25883 Z m 98.21521,61.408267 A 36.474045,36.474045 0 0 0 256,84.989578 36.474045,36.474045 0 0 0 219.52596,121.46362 36.474045,36.474045 0 0 0 256,157.93767 36.474045,36.474045 0 0 0 292.47404,121.46362 Z" />
|
||||
</clipPath>
|
||||
<inkscape:path-effect
|
||||
effect="powerclip"
|
||||
id="path-effect5-1"
|
||||
is_visible="true"
|
||||
lpeversion="1"
|
||||
inverse="true"
|
||||
flatten="false"
|
||||
hide_clip="false"
|
||||
message="Use fill-rule evenodd on <b>fill and stroke</b> dialog if no flatten result after convert clip to paths." />
|
||||
<clipPath
|
||||
clipPathUnits="userSpaceOnUse"
|
||||
id="clipath_lpe_path-effect5-1">
|
||||
<circle
|
||||
style="display:none;fill:#ff0000;fill-opacity:1;stroke:none;stroke-width:0.638045"
|
||||
id="circle6"
|
||||
cx="256"
|
||||
cy="121.46362"
|
||||
r="36.474045"
|
||||
inkscape:label="Pin hole" />
|
||||
<path
|
||||
id="lpe_path-effect5-1"
|
||||
style="display:inline;fill:#ff0000;fill-opacity:1;stroke:none;stroke-width:0.638045"
|
||||
class="powerclip"
|
||||
d="M 194.25883,60.055353 H 317.74117 V 225.23719 H 194.25883 Z" />
|
||||
</clipPath>
|
||||
<clipPath
|
||||
clipPathUnits="userSpaceOnUse"
|
||||
id="clipPath7">
|
||||
<path
|
||||
style="display:inline;fill:#ff0000;fill-opacity:1;stroke:none;stroke-width:0.999685"
|
||||
d="m 309.7613,103.60218 c 0,0 2.67465,17.09909 -4.74175,29.62387 -7.4164,12.52478 -58.70442,80.30628 -58.70442,80.30628 L 256,229.07164 325.68692,117.29773 Z"
|
||||
id="path7"
|
||||
inkscape:label="Pin shadow"
|
||||
sodipodi:nodetypes="czcccc" />
|
||||
</clipPath>
|
||||
<linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient5"
|
||||
id="linearGradient8"
|
||||
x1="257.5"
|
||||
y1="100.90868"
|
||||
x2="257.5"
|
||||
y2="252.75639"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(1.2109929,0,0,1.2109929,-51.431878,-22.595562)" />
|
||||
<linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient9"
|
||||
id="linearGradient10"
|
||||
x1="246.31512"
|
||||
y1="161.91969"
|
||||
x2="312.74118"
|
||||
y2="161.91969"
|
||||
gradientUnits="userSpaceOnUse" />
|
||||
<linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient3"
|
||||
id="linearGradient12"
|
||||
x1="256"
|
||||
y1="326.57394"
|
||||
x2="256"
|
||||
y2="476.50009"
|
||||
gradientUnits="userSpaceOnUse" />
|
||||
</defs>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1">
|
||||
<rect
|
||||
style="fill:url(#linearGradient2);stroke:none;stroke-width:1.24962"
|
||||
id="rect1"
|
||||
width="512"
|
||||
height="640"
|
||||
x="0"
|
||||
y="0"
|
||||
inkscape:label="Background"
|
||||
rx="64"
|
||||
ry="64"
|
||||
sodipodi:insensitive="true" />
|
||||
<ellipse
|
||||
style="display:inline;fill:none;fill-opacity:1;stroke:url(#linearGradient12);stroke-width:16;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="path11"
|
||||
inkscape:label="Pulse"
|
||||
transform="translate(1.83422e-6)"
|
||||
ry="70.463081"
|
||||
rx="185.85986"
|
||||
cy="401.53702"
|
||||
cx="256" />
|
||||
<g
|
||||
id="g8"
|
||||
inkscape:label="Pin"
|
||||
transform="matrix(2.3144339,0,0,2.3144339,-336.49508,-100.44052)"
|
||||
style="display:inline">
|
||||
<path
|
||||
id="path1"
|
||||
style="fill:url(#linearGradient8);stroke-width:0.300124"
|
||||
d="m 257.63548,99.60687 c -6.8485,-0.0483 -13.87486,0.53873 -19.7683,1.77085 -3.62672,0.75822 -3.59047,0.68692 -1.2005,2.36054 1.03167,0.72245 5.04958,3.63248 8.92868,6.46674 11.43762,8.35686 14.14226,9.49292 17.93239,7.53299 0.50737,-0.26238 1.84273,-1.52639 2.96783,-2.80898 2.53516,-2.89001 8.70573,-9.10332 10.89707,-10.9727 1.92407,-1.64137 1.96941,-1.54237 -1.05571,-2.3072 -5.18239,-1.31023 -11.85296,-1.99395 -18.70146,-2.04224 z m -37.25462,7.58867 c -1.17642,-0.13023 -1.97693,0.26934 -5.17948,1.86816 -19.68456,9.82714 -34.16078,24.49558 -44.46815,45.05842 -1.61718,3.22625 -1.66644,2.78373 0.64833,5.89462 1.80698,2.42848 5.92133,8.56345 10.58756,15.78815 4.30987,6.67294 5.84902,7.84005 13.20545,10.01195 4.84901,1.4316 6.15925,2.17392 8.80324,4.98662 1.84692,1.96478 2.61016,2.52579 5.01593,3.68882 2.0267,0.97979 3.5151,1.98363 5.20879,3.51181 2.89889,2.61561 6.80042,4.18004 10.42344,4.18004 0.70651,0 -0.13082,2.8049 -2.28492,7.65316 -4.79274,10.78704 -1.74556,18.9498 9.95507,26.66705 8.27128,5.45538 9.63099,8.58316 10.00081,23.00343 0.29369,11.45175 0.94909,14.39407 4.343,19.49925 2.59911,3.90962 5.08165,5.21935 7.76688,4.0974 2.21161,-0.92407 2.42657,-3.20188 0.68348,-7.2399 -2.00247,-4.6389 -0.40839,-8.26728 4.84946,-11.03952 3.48413,-1.83703 4.6969,-2.86866 8.54415,-7.26804 3.55025,-4.05974 5.03521,-5.39107 8.82844,-7.91341 4.47349,-2.97467 6.58655,-5.64443 7.75105,-9.79388 1.44947,-5.16491 2.00088,-6.19539 4.9626,-9.2804 7.22472,-7.52544 4.4691,-11.98656 -10.46389,-16.94174 -9.23821,-3.06551 -9.69774,-3.34201 -15.80925,-9.51604 -5.25234,-5.30608 -6.41101,-6.24857 -9.75285,-7.93158 -6.47694,-3.26193 -11.75217,-3.76228 -20.54676,-1.94847 -8.97507,1.85103 -10.77165,1.52972 -15.16387,-2.71459 -3.10105,-2.99663 -3.98715,-3.56888 -6.29029,-4.06281 -1.33543,-0.28641 -4.79489,-3.30994 -4.18708,-3.65953 0.0647,-0.0372 0.51968,-0.27395 1.01116,-0.5258 2.17931,-1.11671 2.96321,-4.25942 1.57566,-6.31843 -2.24942,-3.33789 -6.77978,-3.16384 -8.37885,0.32181 -1.93313,4.21382 -9.65009,-0.66643 -10.34607,-6.54292 -0.41876,-3.53576 1.12052,-4.97518 6.78444,-6.34481 7.83863,-1.89551 13.00182,-1.90856 16.56132,-0.0411 0.97002,0.50894 2.55741,1.34214 3.52764,1.85115 4.05682,2.12844 5.30264,1.66437 5.18709,-1.93204 -0.2769,-8.61856 7.22086,-15.90459 23.82877,-23.15583 6.70977,-2.92957 7.4861,-3.71056 7.4861,-7.52831 0,-2.65954 -0.81553,-4.02891 -3.6478,-6.12498 -27.08451,-20.04425 -25.56834,-19.02155 -29.61612,-19.97581 -0.57852,-0.1364 -1.01235,-0.23855 -1.40448,-0.28196 z m 91.87597,11.95689 c -0.13453,0.0142 -0.24293,0.0601 -0.3224,0.1395 -0.5127,0.51259 -1.6535,1.0571 -3.11788,1.48773 -5.90807,1.73737 -4.3632,7.30447 2.47895,8.93338 4.72275,1.12433 7.30339,5.9021 5.71994,10.5899 -1.76728,5.23202 2.06672,9.31372 6.60625,7.03299 1.66801,-0.83805 2.40684,-2.21945 2.40684,-4.49952 0,-2.97427 1.10926,-4.36081 4.13081,-5.16423 1.56558,-0.41628 1.56401,-0.41988 -1.63193,-4.25977 -6.29436,-7.5626 -14.25271,-14.47314 -16.27058,-14.25998 z m 24.03511,28.38924 c -3.94796,-0.47228 -9.44755,3.49613 -11.44984,9.38356 -7.68328,22.59156 -2.89338,37.16203 13.64157,41.49739 7.92079,2.07679 10.92472,5.27031 10.94982,11.63976 0.008,1.98155 0.524,1.20583 0.89862,-1.35056 2.93093,-20.00106 -1.54735,-44.52622 -10.79625,-59.12438 -0.78413,-1.23762 -1.92793,-1.88832 -3.24392,-2.04577 z m -118.01722,27.22217 c -0.35891,0.0258 -0.54795,0.081 -0.51643,0.17117 0.21988,0.62872 5.07189,5.8297 6.2522,6.70178 2.33768,1.72726 5.09103,2.20578 11.68079,2.02993 2.25093,-0.0601 2.25107,-0.0601 2.15949,-1.2005 -0.19964,-2.48522 -1.87415,-4.69471 -4.41043,-5.81957 -1.81495,-0.80496 -12.6533,-2.06381 -15.16562,-1.88281 z"
|
||||
sodipodi:nodetypes="ssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss"
|
||||
inkscape:label="Earth"
|
||||
transform="matrix(0.38684422,0,0,0.38684422,156.96788,44.992292)" />
|
||||
<path
|
||||
id="path5-6"
|
||||
style="display:inline;vector-effect:non-scaling-stroke;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.221645;stroke-dasharray:none;stroke-opacity:1;-inkscape-stroke:hairline"
|
||||
d="m 256,65.055353 c -31.33729,-2.2e-5 -56.74119,25.403878 -56.74117,56.741167 0.0149,10.65621 3.0303,21.09286 8.70093,30.115 l -0.11393,-0.10289 38.06169,61.0697 c 0,0 3.73217,7.35886 10.09248,7.35886 6.36031,0 10.09248,-7.35886 10.09248,-7.35886 l 38.06169,-61.0697 -0.11251,0.1004 c 5.66968,-9.02158 8.68456,-19.45728 8.69951,-30.11251 C 312.74119,90.459231 287.33729,65.055331 256,65.055353 Z"
|
||||
sodipodi:nodetypes="ccccczccccc"
|
||||
inkscape:label="Pin"
|
||||
clip-path="url(#clipPath5)"
|
||||
inkscape:path-effect="#path-effect5"
|
||||
inkscape:original-d="m 256,65.055353 c -31.33729,-2.2e-5 -56.74119,25.403878 -56.74117,56.741167 0.0149,10.65621 3.0303,21.09286 8.70093,30.115 l -0.11393,-0.10289 38.06169,61.0697 c 0,0 3.73217,7.35886 10.09248,7.35886 6.36031,0 10.09248,-7.35886 10.09248,-7.35886 l 38.06169,-61.0697 -0.11251,0.1004 c 5.66968,-9.02158 8.68456,-19.45728 8.69951,-30.11251 C 312.74119,90.459231 287.33729,65.055331 256,65.055353 Z" />
|
||||
<path
|
||||
id="path5-6-7"
|
||||
style="display:inline;vector-effect:non-scaling-stroke;fill:url(#linearGradient10);stroke:none;stroke-width:0.221645;stroke-dasharray:none;stroke-opacity:1;-inkscape-stroke:hairline"
|
||||
d="m 256,65.055353 c -31.33729,-2.2e-5 -56.74119,25.403878 -56.74117,56.741167 0.0149,10.65621 3.0303,21.09286 8.70093,30.115 l -0.11393,-0.10289 38.06169,61.0697 c 0,0 3.73217,7.35886 10.09248,7.35886 6.36031,0 10.09248,-7.35886 10.09248,-7.35886 l 38.06169,-61.0697 -0.11251,0.1004 c 5.66968,-9.02158 8.68456,-19.45728 8.69951,-30.11251 C 312.74119,90.459231 287.33729,65.055331 256,65.055353 Z"
|
||||
sodipodi:nodetypes="ccccczccccc"
|
||||
inkscape:label="Pin shadow"
|
||||
clip-path="url(#clipPath7)"
|
||||
inkscape:original-d="m 256,65.055353 c -31.33729,-2.2e-5 -56.74119,25.403878 -56.74117,56.741167 0.0149,10.65621 3.0303,21.09286 8.70093,30.115 l -0.11393,-0.10289 38.06169,61.0697 c 0,0 3.73217,7.35886 10.09248,7.35886 6.36031,0 10.09248,-7.35886 10.09248,-7.35886 l 38.06169,-61.0697 -0.11251,0.1004 c 5.66968,-9.02158 8.68456,-19.45728 8.69951,-30.11251 C 312.74119,90.459231 287.33729,65.055331 256,65.055353 Z"
|
||||
inkscape:path-effect="#path-effect5-1" />
|
||||
</g>
|
||||
<g
|
||||
id="g4"
|
||||
inkscape:label="Frame guide"
|
||||
style="display:inline">
|
||||
<path
|
||||
id="path10-5-7"
|
||||
style="fill:none;fill-opacity:1;fill-rule:nonzero;stroke:#ffffff;stroke-width:16;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 40,451.7485 v -24 m 20.251593,44.25159 h 24 M 40,451.7485 a 20.251593,20.251593 0 0 0 20.251593,20.25159"
|
||||
inkscape:label="Bottom left" />
|
||||
<path
|
||||
id="path10-5-7-6"
|
||||
style="fill:none;fill-opacity:1;fill-rule:nonzero;stroke:#ffffff;stroke-width:16;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 472.00009,451.7485 v -24 m -20.25159,44.25159 h -24 m 44.25159,-20.25159 a 20.251593,20.251593 0 0 1 -20.25159,20.25159"
|
||||
inkscape:label="Bottom right" />
|
||||
<path
|
||||
id="path10-5-2"
|
||||
style="fill:none;fill-opacity:1;fill-rule:nonzero;stroke:#ffffff;stroke-width:16;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 472.00009,60.251592 v 24 M 451.7485,40 h -24 m 44.25159,20.251592 A 20.251593,20.251593 0 0 0 451.7485,40"
|
||||
inkscape:label="Top right" />
|
||||
<path
|
||||
id="path10-5"
|
||||
style="fill:none;fill-opacity:1;fill-rule:nonzero;stroke:#ffffff;stroke-width:16;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 40,60.251593 v 24 M 60.251593,40 h 24 M 40,60.251593 A 20.251593,20.251593 0 0 1 60.251593,40"
|
||||
inkscape:label="Top left" />
|
||||
</g>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
transform="matrix(0.25,0,0,0.25,-90.814683,-42.424043)"
|
||||
id="text1"
|
||||
style="font-weight:bold;font-size:266.667px;font-family:Lato;-inkscape-font-specification:'Lato Bold';text-align:center;letter-spacing:60px;word-spacing:0px;white-space:pre;shape-inside:url(#rect3);display:inline;fill:none;stroke:#ffffff;stroke-width:64"><tspan
|
||||
x="685.99138"
|
||||
y="2506.6976"
|
||||
id="tspan4"><tspan
|
||||
style="fill:#ffffff;stroke:none"
|
||||
id="tspan3">BEACON</tspan></tspan></text>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 19 KiB |
9
99_references/beacon-main/src/assets/icons/README.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Icons
|
||||
|
||||
This directory contains the SVG versions of various custom icons.
|
||||
|
||||
## Changes
|
||||
|
||||
- Run just the path through [this](https://yqnn.github.io/svg-path-editor/) SVG editor
|
||||
- Recenter the viewbox at `(0, 0)`
|
||||
- Remove the `fill` and `stroke` attributes
|
@@ -0,0 +1,5 @@
|
||||
<!-- Credit: https://fonts.google.com/icons -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0.9 0.9 680.2 880.2">
|
||||
<path
|
||||
d="M 201 881 q -33 0 -56.5 -23.5 T 121 801 v -160 h 80 v 40 h 400 v -480 H 201 v 40 h -80 v -160 q 0 -33 23.5 -56.5 T 201 1 h 400 q 33 0 56.5 23.5 T 681 81 v 720 q 0 33 -23.5 56.5 T 601 881 H 201 Z M 57 641 l -56 -56 l 224 -224 H 81 v -80 h 280 v 280 h -80 v -144 L 57 641 Z" />
|
||||
</svg>
|
After Width: | Height: | Size: 416 B |
@@ -0,0 +1,5 @@
|
||||
<!-- Credit: https://github.com/brendanballon/sfsymbols-svg -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="1 1.0024 37.02 37.02">
|
||||
<path
|
||||
d="M35.8692 3.1724C34.2578 1.604 32.1309 1.0024 27.791 1.0024L10.9473 1.0024C6.9512 1.0024 4.7598 1.6255 3.1914 3.2153 1.6016 4.7837 1 6.9106 1 10.9497L1 27.7935C1 32.1548 1.5801 34.2817 3.1699 35.8286 4.7598 37.4185 6.8867 38.02 11.2266 38.02L27.791 38.02C32.1309 38.02 34.2793 37.4185 35.8692 35.8286 37.4375 34.2603 38.0176 32.1548 38.0176 27.7935L38.0176 11.229C38.0176 6.8462 37.4375 4.7407 35.8692 3.1724ZM36.9649 10.6704 36.9649 28.3306C36.9649 31.5317 36.3633 33.6802 35.0528 35.0122 33.6992 36.3872 31.5078 36.9673 28.3496 36.9673L10.6895 36.9673C7.5098 36.9673 5.3184 36.3657 3.9863 35.0122 2.6328 33.6802 2.0527 31.5317 2.0527 28.3306L2.0527 10.9497C2.0527 7.5337 2.6328 5.3208 3.9434 3.9673 5.2969 2.5923 7.5742 2.0552 10.9688 2.0552L28.3496 2.0552C31.5078 2.0552 33.6992 2.6567 35.0528 4.0103 36.3848 5.3423 36.9649 7.4907 36.9649 10.6704ZM19.4766 29.6841C19.8203 29.6841 20.0567 29.4478 20.0567 29.1685L20.0567 20.0376 29.166 20.0376C29.4453 20.0376 29.7031 19.7583 29.7031 19.5005 29.7031 19.1997 29.4668 18.9204 29.166 18.9204L20.0567 18.9204 20.0567 9.8325C20.0567 9.5317 19.8203 9.2954 19.4766 9.2954 19.1973 9.2954 18.961 9.5317 18.961 9.8325L18.961 18.9204 9.8731 18.9204C9.5508 18.9204 9.3145 19.1997 9.3145 19.5005 9.3145 19.7583 9.5723 20.0376 9.8731 20.0376L18.961 20.0376 18.961 29.1685C18.961 29.4478 19.1973 29.6841 19.4766 29.6841Z" />
|
||||
</svg>
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,5 @@
|
||||
<!-- Credit: https://github.com/brendanballon/sfsymbols-svg -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="1 0.9967 35.94 46.36">
|
||||
<path
|
||||
d="M19.0039 31.3971C19.2832 31.3971 19.5195 31.1393 19.5195 30.86L19.5195 5.5944 19.4981 2.6725 22.3555 5.5514 26.7813 9.9772C26.8887 10.0846 27.0606 10.1705 27.1895 10.1705 27.4258 10.1705 27.6621 9.9342 27.6621 9.6549 27.6621 9.526 27.5977 9.4186 27.4902 9.3112L19.3692 1.1471C19.2617 1.0612 19.1328.9967 19.0039.9967 18.875.9967 18.7246 1.0612 18.6387 1.1471L10.4746 9.3112C10.3672 9.4186 10.3027 9.526 10.3027 9.6549 10.3027 9.9342 10.5176 10.1705 10.7754 10.1705 10.9258 10.1705 11.0762 10.0846 11.1836 9.9772L15.6094 5.5514 18.4883 2.651 18.4668 5.5944 18.4668 30.86C18.4668 31.1393 18.7031 31.3971 19.0039 31.3971ZM6.5215 47.36 31.4219 47.36C35.0313 47.36 36.9434 45.3834 36.9434 41.8385L36.9434 18.8287C36.9434 15.2838 35.0313 13.3073 31.4219 13.3073L25.0195 13.3073 25.0195 14.36 31.3574 14.36C34.2363 14.36 35.8906 15.9069 35.8906 18.8932L35.8906 41.7955C35.8906 44.7604 34.2363 46.3073 31.3574 46.3073L6.5645 46.3073C3.5996 46.3073 2.0527 44.7604 2.0527 41.7955L2.0527 18.8932C2.0527 15.9069 3.5996 14.36 6.5645 14.36L12.9668 14.36 12.9668 13.3073 6.5215 13.3073C2.9121 13.3073 1 15.2194 1 18.8287L1 41.8385C1 45.4479 2.9121 47.36 6.5215 47.36Z" />
|
||||
</svg>
|
After Width: | Height: | Size: 1.3 KiB |
BIN
99_references/beacon-main/src/assets/logo.png
Normal file
After Width: | Height: | Size: 223 KiB |
375
99_references/beacon-main/src/assets/logo.svg
Normal file
@@ -0,0 +1,375 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="2048"
|
||||
height="2048"
|
||||
viewBox="0 0 512 512"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
inkscape:version="1.3.2 (091e20ef0f, 2023-11-25, custom)"
|
||||
sodipodi:docname="logo.svg"
|
||||
inkscape:export-filename="logo.png"
|
||||
inkscape:export-xdpi="24"
|
||||
inkscape:export-ydpi="24"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#505050"
|
||||
bordercolor="#ffffff"
|
||||
borderopacity="1"
|
||||
inkscape:showpageshadow="0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pagecheckerboard="1"
|
||||
inkscape:deskcolor="#505050"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:zoom="0.10625592"
|
||||
inkscape:cx="-258.80911"
|
||||
inkscape:cy="207.04729"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1014"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="layer1"
|
||||
inkscape:export-bgcolor="#ffffff00"
|
||||
showgrid="false"
|
||||
showguides="true" />
|
||||
<defs
|
||||
id="defs1">
|
||||
<linearGradient
|
||||
id="linearGradient9"
|
||||
inkscape:collect="always">
|
||||
<stop
|
||||
style="stop-color:#89bbcf;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop9" />
|
||||
<stop
|
||||
style="stop-color:#89bbcf;stop-opacity:0;"
|
||||
offset="1"
|
||||
id="stop10" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="linearGradient5"
|
||||
inkscape:collect="always">
|
||||
<stop
|
||||
style="stop-color:#f9cb7e;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop6" />
|
||||
<stop
|
||||
style="stop-color:#f4676f;stop-opacity:1;"
|
||||
offset="1"
|
||||
id="stop8" />
|
||||
</linearGradient>
|
||||
<rect
|
||||
x="337.00638"
|
||||
y="2253.9922"
|
||||
width="1829.6202"
|
||||
height="411.42795"
|
||||
id="rect2" />
|
||||
<inkscape:path-effect
|
||||
effect="offset"
|
||||
id="path-effect11"
|
||||
is_visible="true"
|
||||
lpeversion="1.2"
|
||||
linejoin_type="miter"
|
||||
unit="mm"
|
||||
offset="10"
|
||||
miter_limit="4"
|
||||
attempt_force_join="false"
|
||||
update_on_knot_move="true" />
|
||||
<inkscape:path-effect
|
||||
effect="offset"
|
||||
id="path-effect10"
|
||||
is_visible="true"
|
||||
lpeversion="1.2"
|
||||
linejoin_type="miter"
|
||||
unit="px"
|
||||
offset="-10"
|
||||
miter_limit="4"
|
||||
attempt_force_join="false"
|
||||
update_on_knot_move="true" />
|
||||
<linearGradient
|
||||
id="linearGradient3"
|
||||
inkscape:collect="always">
|
||||
<stop
|
||||
style="stop-color:#0ea2c0;stop-opacity:0;"
|
||||
offset="0.2"
|
||||
id="stop3" />
|
||||
<stop
|
||||
style="stop-color:#4a7a90;stop-opacity:0.28981349;"
|
||||
offset="0.60000002"
|
||||
id="stop7" />
|
||||
<stop
|
||||
style="stop-color:#5adbf3;stop-opacity:1;"
|
||||
offset="1"
|
||||
id="stop5" />
|
||||
</linearGradient>
|
||||
<inkscape:path-effect
|
||||
effect="powerclip"
|
||||
id="path-effect5"
|
||||
is_visible="true"
|
||||
lpeversion="1"
|
||||
inverse="true"
|
||||
flatten="false"
|
||||
hide_clip="false"
|
||||
message="Use fill-rule evenodd on <b>fill and stroke</b> dialog if no flatten result after convert clip to paths." />
|
||||
<inkscape:path-effect
|
||||
effect="powerclip"
|
||||
id="path-effect3"
|
||||
is_visible="true"
|
||||
lpeversion="1"
|
||||
inverse="true"
|
||||
flatten="false"
|
||||
hide_clip="false"
|
||||
message="Use fill-rule evenodd on <b>fill and stroke</b> dialog if no flatten result after convert clip to paths." />
|
||||
<linearGradient
|
||||
id="linearGradient1"
|
||||
inkscape:collect="always">
|
||||
<stop
|
||||
style="stop-color:#0e3c5e;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop1" />
|
||||
<stop
|
||||
style="stop-color:#0c1922;stop-opacity:1;"
|
||||
offset="1"
|
||||
id="stop2" />
|
||||
</linearGradient>
|
||||
<inkscape:path-effect
|
||||
effect="powerclip"
|
||||
id="path-effect2"
|
||||
is_visible="true"
|
||||
lpeversion="1"
|
||||
inverse="true"
|
||||
flatten="false"
|
||||
hide_clip="false"
|
||||
message="Use fill-rule evenodd on <b>fill and stroke</b> dialog if no flatten result after convert clip to paths." />
|
||||
<linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient1"
|
||||
id="linearGradient2"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="512.49982"
|
||||
y2="512.49982"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="scale(1.0000001,1.2500001)" />
|
||||
<clipPath
|
||||
clipPathUnits="userSpaceOnUse"
|
||||
id="clipPath3-5">
|
||||
<circle
|
||||
style="display:none;fill:#ff0000;fill-opacity:1;stroke:none;stroke-width:0.638045"
|
||||
id="circle3-6"
|
||||
cx="256"
|
||||
cy="121.46362"
|
||||
r="36.474045"
|
||||
inkscape:label="Pin hole" />
|
||||
<path
|
||||
id="lpe_path-effect3-2"
|
||||
style="fill:#ff0000;fill-opacity:1;stroke:none;stroke-width:0.638045"
|
||||
class="powerclip"
|
||||
d="M 194.25883,60.055353 H 317.74117 V 234.07164 H 194.25883 Z m 98.21521,61.408267 A 36.474045,36.474045 0 0 0 256,84.989578 36.474045,36.474045 0 0 0 219.52596,121.46362 36.474045,36.474045 0 0 0 256,157.93767 36.474045,36.474045 0 0 0 292.47404,121.46362 Z" />
|
||||
</clipPath>
|
||||
<inkscape:path-effect
|
||||
effect="powerclip"
|
||||
id="path-effect3-9"
|
||||
is_visible="true"
|
||||
lpeversion="1"
|
||||
inverse="true"
|
||||
flatten="false"
|
||||
hide_clip="false"
|
||||
message="Use fill-rule evenodd on <b>fill and stroke</b> dialog if no flatten result after convert clip to paths." />
|
||||
<clipPath
|
||||
clipPathUnits="userSpaceOnUse"
|
||||
id="clipath_lpe_path-effect3-9">
|
||||
<circle
|
||||
style="display:none;fill:#ff0000;fill-opacity:1;stroke:none;stroke-width:0.638045"
|
||||
id="circle4"
|
||||
cx="256"
|
||||
cy="121.46362"
|
||||
r="36.474045"
|
||||
inkscape:label="Pin hole" />
|
||||
</clipPath>
|
||||
<clipPath
|
||||
clipPathUnits="userSpaceOnUse"
|
||||
id="clipPath5">
|
||||
<circle
|
||||
style="display:none;fill:#ff0000;fill-opacity:1;stroke:none;stroke-width:0.638045"
|
||||
id="circle5"
|
||||
cx="256"
|
||||
cy="121.46362"
|
||||
r="36.474045"
|
||||
inkscape:label="Pin hole"
|
||||
d="M 292.47404,121.46362 A 36.474045,36.474045 0 0 1 256,157.93767 36.474045,36.474045 0 0 1 219.52596,121.46362 36.474045,36.474045 0 0 1 256,84.989578 36.474045,36.474045 0 0 1 292.47404,121.46362 Z" />
|
||||
<path
|
||||
id="lpe_path-effect5"
|
||||
style="display:inline;fill:#ff0000;fill-opacity:1;stroke:none;stroke-width:0.638045"
|
||||
class="powerclip"
|
||||
d="M 194.25883,60.055353 H 317.74117 V 225.23719 H 194.25883 Z m 98.21521,61.408267 A 36.474045,36.474045 0 0 0 256,84.989578 36.474045,36.474045 0 0 0 219.52596,121.46362 36.474045,36.474045 0 0 0 256,157.93767 36.474045,36.474045 0 0 0 292.47404,121.46362 Z" />
|
||||
</clipPath>
|
||||
<clipPath
|
||||
clipPathUnits="userSpaceOnUse"
|
||||
id="clipPath5-6">
|
||||
<circle
|
||||
style="display:none;fill:#ff0000;fill-opacity:1;stroke:none;stroke-width:0.638045"
|
||||
id="circle5-2"
|
||||
cx="256"
|
||||
cy="121.46362"
|
||||
r="36.474045"
|
||||
inkscape:label="Pin hole" />
|
||||
<path
|
||||
id="lpe_path-effect5-6"
|
||||
style="display:inline;fill:#ff0000;fill-opacity:1;stroke:none;stroke-width:0.638045"
|
||||
class="powerclip"
|
||||
d="M 194.25883,60.055353 H 317.74117 V 225.23719 H 194.25883 Z m 98.21521,61.408267 A 36.474045,36.474045 0 0 0 256,84.989578 36.474045,36.474045 0 0 0 219.52596,121.46362 36.474045,36.474045 0 0 0 256,157.93767 36.474045,36.474045 0 0 0 292.47404,121.46362 Z" />
|
||||
</clipPath>
|
||||
<inkscape:path-effect
|
||||
effect="powerclip"
|
||||
id="path-effect5-1"
|
||||
is_visible="true"
|
||||
lpeversion="1"
|
||||
inverse="true"
|
||||
flatten="false"
|
||||
hide_clip="false"
|
||||
message="Use fill-rule evenodd on <b>fill and stroke</b> dialog if no flatten result after convert clip to paths." />
|
||||
<clipPath
|
||||
clipPathUnits="userSpaceOnUse"
|
||||
id="clipath_lpe_path-effect5-1">
|
||||
<circle
|
||||
style="display:none;fill:#ff0000;fill-opacity:1;stroke:none;stroke-width:0.638045"
|
||||
id="circle6"
|
||||
cx="256"
|
||||
cy="121.46362"
|
||||
r="36.474045"
|
||||
inkscape:label="Pin hole" />
|
||||
<path
|
||||
id="lpe_path-effect5-1"
|
||||
style="display:inline;fill:#ff0000;fill-opacity:1;stroke:none;stroke-width:0.638045"
|
||||
class="powerclip"
|
||||
d="M 194.25883,60.055353 H 317.74117 V 225.23719 H 194.25883 Z" />
|
||||
</clipPath>
|
||||
<clipPath
|
||||
clipPathUnits="userSpaceOnUse"
|
||||
id="clipPath7">
|
||||
<path
|
||||
style="display:inline;fill:#ff0000;fill-opacity:1;stroke:none;stroke-width:0.999685"
|
||||
d="m 309.7613,103.60218 c 0,0 2.67465,17.09909 -4.74175,29.62387 -7.4164,12.52478 -58.70442,80.30628 -58.70442,80.30628 L 256,229.07164 325.68692,117.29773 Z"
|
||||
id="path7"
|
||||
inkscape:label="Pin shadow"
|
||||
sodipodi:nodetypes="czcccc" />
|
||||
</clipPath>
|
||||
<linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient5"
|
||||
id="linearGradient8"
|
||||
x1="257.5"
|
||||
y1="100.90868"
|
||||
x2="257.5"
|
||||
y2="252.75639"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(1.2109929,0,0,1.2109929,-51.431878,-22.595562)" />
|
||||
<linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient9"
|
||||
id="linearGradient10"
|
||||
x1="246.31512"
|
||||
y1="161.91969"
|
||||
x2="312.74118"
|
||||
y2="161.91969"
|
||||
gradientUnits="userSpaceOnUse" />
|
||||
<linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient3"
|
||||
id="linearGradient12"
|
||||
x1="256"
|
||||
y1="326.57394"
|
||||
x2="256"
|
||||
y2="476.50009"
|
||||
gradientUnits="userSpaceOnUse" />
|
||||
</defs>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1">
|
||||
<rect
|
||||
style="fill:url(#linearGradient2);stroke:none;stroke-width:1.11769"
|
||||
id="rect1"
|
||||
width="512"
|
||||
height="512"
|
||||
x="0"
|
||||
y="0"
|
||||
inkscape:label="Background"
|
||||
sodipodi:insensitive="true"
|
||||
rx="64"
|
||||
ry="64" />
|
||||
<ellipse
|
||||
style="display:inline;fill:none;fill-opacity:1;stroke:url(#linearGradient12);stroke-width:16;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="path11"
|
||||
inkscape:label="Pulse"
|
||||
transform="translate(1.83422e-6)"
|
||||
ry="70.463081"
|
||||
rx="185.85986"
|
||||
cy="401.53702"
|
||||
cx="256" />
|
||||
<g
|
||||
id="g8"
|
||||
inkscape:label="Pin"
|
||||
transform="matrix(2.3144339,0,0,2.3144339,-336.49508,-100.44052)"
|
||||
style="display:inline">
|
||||
<path
|
||||
id="path1"
|
||||
style="fill:url(#linearGradient8);stroke-width:0.300124"
|
||||
d="m 257.63548,99.60687 c -6.8485,-0.0483 -13.87486,0.53873 -19.7683,1.77085 -3.62672,0.75822 -3.59047,0.68692 -1.2005,2.36054 1.03167,0.72245 5.04958,3.63248 8.92868,6.46674 11.43762,8.35686 14.14226,9.49292 17.93239,7.53299 0.50737,-0.26238 1.84273,-1.52639 2.96783,-2.80898 2.53516,-2.89001 8.70573,-9.10332 10.89707,-10.9727 1.92407,-1.64137 1.96941,-1.54237 -1.05571,-2.3072 -5.18239,-1.31023 -11.85296,-1.99395 -18.70146,-2.04224 z m -37.25462,7.58867 c -1.17642,-0.13023 -1.97693,0.26934 -5.17948,1.86816 -19.68456,9.82714 -34.16078,24.49558 -44.46815,45.05842 -1.61718,3.22625 -1.66644,2.78373 0.64833,5.89462 1.80698,2.42848 5.92133,8.56345 10.58756,15.78815 4.30987,6.67294 5.84902,7.84005 13.20545,10.01195 4.84901,1.4316 6.15925,2.17392 8.80324,4.98662 1.84692,1.96478 2.61016,2.52579 5.01593,3.68882 2.0267,0.97979 3.5151,1.98363 5.20879,3.51181 2.89889,2.61561 6.80042,4.18004 10.42344,4.18004 0.70651,0 -0.13082,2.8049 -2.28492,7.65316 -4.79274,10.78704 -1.74556,18.9498 9.95507,26.66705 8.27128,5.45538 9.63099,8.58316 10.00081,23.00343 0.29369,11.45175 0.94909,14.39407 4.343,19.49925 2.59911,3.90962 5.08165,5.21935 7.76688,4.0974 2.21161,-0.92407 2.42657,-3.20188 0.68348,-7.2399 -2.00247,-4.6389 -0.40839,-8.26728 4.84946,-11.03952 3.48413,-1.83703 4.6969,-2.86866 8.54415,-7.26804 3.55025,-4.05974 5.03521,-5.39107 8.82844,-7.91341 4.47349,-2.97467 6.58655,-5.64443 7.75105,-9.79388 1.44947,-5.16491 2.00088,-6.19539 4.9626,-9.2804 7.22472,-7.52544 4.4691,-11.98656 -10.46389,-16.94174 -9.23821,-3.06551 -9.69774,-3.34201 -15.80925,-9.51604 -5.25234,-5.30608 -6.41101,-6.24857 -9.75285,-7.93158 -6.47694,-3.26193 -11.75217,-3.76228 -20.54676,-1.94847 -8.97507,1.85103 -10.77165,1.52972 -15.16387,-2.71459 -3.10105,-2.99663 -3.98715,-3.56888 -6.29029,-4.06281 -1.33543,-0.28641 -4.79489,-3.30994 -4.18708,-3.65953 0.0647,-0.0372 0.51968,-0.27395 1.01116,-0.5258 2.17931,-1.11671 2.96321,-4.25942 1.57566,-6.31843 -2.24942,-3.33789 -6.77978,-3.16384 -8.37885,0.32181 -1.93313,4.21382 -9.65009,-0.66643 -10.34607,-6.54292 -0.41876,-3.53576 1.12052,-4.97518 6.78444,-6.34481 7.83863,-1.89551 13.00182,-1.90856 16.56132,-0.0411 0.97002,0.50894 2.55741,1.34214 3.52764,1.85115 4.05682,2.12844 5.30264,1.66437 5.18709,-1.93204 -0.2769,-8.61856 7.22086,-15.90459 23.82877,-23.15583 6.70977,-2.92957 7.4861,-3.71056 7.4861,-7.52831 0,-2.65954 -0.81553,-4.02891 -3.6478,-6.12498 -27.08451,-20.04425 -25.56834,-19.02155 -29.61612,-19.97581 -0.57852,-0.1364 -1.01235,-0.23855 -1.40448,-0.28196 z m 91.87597,11.95689 c -0.13453,0.0142 -0.24293,0.0601 -0.3224,0.1395 -0.5127,0.51259 -1.6535,1.0571 -3.11788,1.48773 -5.90807,1.73737 -4.3632,7.30447 2.47895,8.93338 4.72275,1.12433 7.30339,5.9021 5.71994,10.5899 -1.76728,5.23202 2.06672,9.31372 6.60625,7.03299 1.66801,-0.83805 2.40684,-2.21945 2.40684,-4.49952 0,-2.97427 1.10926,-4.36081 4.13081,-5.16423 1.56558,-0.41628 1.56401,-0.41988 -1.63193,-4.25977 -6.29436,-7.5626 -14.25271,-14.47314 -16.27058,-14.25998 z m 24.03511,28.38924 c -3.94796,-0.47228 -9.44755,3.49613 -11.44984,9.38356 -7.68328,22.59156 -2.89338,37.16203 13.64157,41.49739 7.92079,2.07679 10.92472,5.27031 10.94982,11.63976 0.008,1.98155 0.524,1.20583 0.89862,-1.35056 2.93093,-20.00106 -1.54735,-44.52622 -10.79625,-59.12438 -0.78413,-1.23762 -1.92793,-1.88832 -3.24392,-2.04577 z m -118.01722,27.22217 c -0.35891,0.0258 -0.54795,0.081 -0.51643,0.17117 0.21988,0.62872 5.07189,5.8297 6.2522,6.70178 2.33768,1.72726 5.09103,2.20578 11.68079,2.02993 2.25093,-0.0601 2.25107,-0.0601 2.15949,-1.2005 -0.19964,-2.48522 -1.87415,-4.69471 -4.41043,-5.81957 -1.81495,-0.80496 -12.6533,-2.06381 -15.16562,-1.88281 z"
|
||||
sodipodi:nodetypes="ssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss"
|
||||
inkscape:label="Earth"
|
||||
transform="matrix(0.38684422,0,0,0.38684422,156.96788,44.992292)" />
|
||||
<path
|
||||
id="path5-6"
|
||||
style="display:inline;vector-effect:non-scaling-stroke;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.221645;stroke-dasharray:none;stroke-opacity:1;-inkscape-stroke:hairline"
|
||||
d="m 256,65.055353 c -31.33729,-2.2e-5 -56.74119,25.403878 -56.74117,56.741167 0.0149,10.65621 3.0303,21.09286 8.70093,30.115 l -0.11393,-0.10289 38.06169,61.0697 c 0,0 3.73217,7.35886 10.09248,7.35886 6.36031,0 10.09248,-7.35886 10.09248,-7.35886 l 38.06169,-61.0697 -0.11251,0.1004 c 5.66968,-9.02158 8.68456,-19.45728 8.69951,-30.11251 C 312.74119,90.459231 287.33729,65.055331 256,65.055353 Z"
|
||||
sodipodi:nodetypes="ccccczccccc"
|
||||
inkscape:label="Pin"
|
||||
clip-path="url(#clipPath5)"
|
||||
inkscape:path-effect="#path-effect5"
|
||||
inkscape:original-d="m 256,65.055353 c -31.33729,-2.2e-5 -56.74119,25.403878 -56.74117,56.741167 0.0149,10.65621 3.0303,21.09286 8.70093,30.115 l -0.11393,-0.10289 38.06169,61.0697 c 0,0 3.73217,7.35886 10.09248,7.35886 6.36031,0 10.09248,-7.35886 10.09248,-7.35886 l 38.06169,-61.0697 -0.11251,0.1004 c 5.66968,-9.02158 8.68456,-19.45728 8.69951,-30.11251 C 312.74119,90.459231 287.33729,65.055331 256,65.055353 Z" />
|
||||
<path
|
||||
id="path5-6-7"
|
||||
style="display:inline;vector-effect:non-scaling-stroke;fill:url(#linearGradient10);stroke:none;stroke-width:0.221645;stroke-dasharray:none;stroke-opacity:1;-inkscape-stroke:hairline"
|
||||
d="m 256,65.055353 c -31.33729,-2.2e-5 -56.74119,25.403878 -56.74117,56.741167 0.0149,10.65621 3.0303,21.09286 8.70093,30.115 l -0.11393,-0.10289 38.06169,61.0697 c 0,0 3.73217,7.35886 10.09248,7.35886 6.36031,0 10.09248,-7.35886 10.09248,-7.35886 l 38.06169,-61.0697 -0.11251,0.1004 c 5.66968,-9.02158 8.68456,-19.45728 8.69951,-30.11251 C 312.74119,90.459231 287.33729,65.055331 256,65.055353 Z"
|
||||
sodipodi:nodetypes="ccccczccccc"
|
||||
inkscape:label="Pin shadow"
|
||||
clip-path="url(#clipPath7)"
|
||||
inkscape:original-d="m 256,65.055353 c -31.33729,-2.2e-5 -56.74119,25.403878 -56.74117,56.741167 0.0149,10.65621 3.0303,21.09286 8.70093,30.115 l -0.11393,-0.10289 38.06169,61.0697 c 0,0 3.73217,7.35886 10.09248,7.35886 6.36031,0 10.09248,-7.35886 10.09248,-7.35886 l 38.06169,-61.0697 -0.11251,0.1004 c 5.66968,-9.02158 8.68456,-19.45728 8.69951,-30.11251 C 312.74119,90.459231 287.33729,65.055331 256,65.055353 Z"
|
||||
inkscape:path-effect="#path-effect5-1" />
|
||||
</g>
|
||||
<g
|
||||
id="g4"
|
||||
inkscape:label="Frame guide"
|
||||
style="display:inline">
|
||||
<path
|
||||
id="path10-5-7"
|
||||
style="fill:none;fill-opacity:1;fill-rule:nonzero;stroke:#ffffff;stroke-width:16;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 40,451.7485 v -24 m 20.251593,44.25159 h 24 M 40,451.7485 a 20.251593,20.251593 0 0 0 20.251593,20.25159"
|
||||
inkscape:label="Bottom left" />
|
||||
<path
|
||||
id="path10-5-7-6"
|
||||
style="fill:none;fill-opacity:1;fill-rule:nonzero;stroke:#ffffff;stroke-width:16;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 472.00009,451.7485 v -24 m -20.25159,44.25159 h -24 m 44.25159,-20.25159 a 20.251593,20.251593 0 0 1 -20.25159,20.25159"
|
||||
inkscape:label="Bottom right" />
|
||||
<path
|
||||
id="path10-5-2"
|
||||
style="fill:none;fill-opacity:1;fill-rule:nonzero;stroke:#ffffff;stroke-width:16;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 472.00009,60.251592 v 24 M 451.7485,40 h -24 m 44.25159,20.251592 A 20.251593,20.251593 0 0 0 451.7485,40"
|
||||
inkscape:label="Top right" />
|
||||
<path
|
||||
id="path10-5"
|
||||
style="fill:none;fill-opacity:1;fill-rule:nonzero;stroke:#ffffff;stroke-width:16;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 40,60.251593 v 24 M 60.251593,40 h 24 M 40,60.251593 A 20.251593,20.251593 0 0 1 60.251593,40"
|
||||
inkscape:label="Top left" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 18 KiB |
65
99_references/beacon-main/src/components/auth-container.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* @file Auth page container component
|
||||
*/
|
||||
|
||||
import {
|
||||
IonBackButton,
|
||||
IonButtons,
|
||||
IonCard,
|
||||
IonCardContent,
|
||||
IonCardHeader,
|
||||
IonCardTitle,
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonMenuButton,
|
||||
IonPage,
|
||||
IonTitle,
|
||||
IonToolbar,
|
||||
} from "@ionic/react";
|
||||
import {FC, ReactNode} from "react";
|
||||
|
||||
/**
|
||||
* Auth page container component props
|
||||
*/
|
||||
interface AuthContainerProps {
|
||||
/**
|
||||
* Whether to show the back button or the menu button
|
||||
*/
|
||||
back: boolean;
|
||||
|
||||
/**
|
||||
* Children
|
||||
*/
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth page container component
|
||||
* @param props Props
|
||||
* @returns JSX
|
||||
*/
|
||||
export const AuthContainer: FC<AuthContainerProps> = ({back, children}) => (
|
||||
<IonPage>
|
||||
<IonHeader className="ion-no-border">
|
||||
<IonToolbar>
|
||||
<IonButtons slot="start">
|
||||
{back ? <IonBackButton defaultHref="/auth/1" /> : <IonMenuButton />}
|
||||
</IonButtons>
|
||||
|
||||
<IonTitle>Authentication</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<IonContent>
|
||||
<div className="flex flex-col items-center justify-center h-full w-full">
|
||||
<IonCard>
|
||||
<IonCardHeader>
|
||||
<IonCardTitle>Authentication</IonCardTitle>
|
||||
</IonCardHeader>
|
||||
|
||||
<IonCardContent>{children}</IonCardContent>
|
||||
</IonCard>
|
||||
</div>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
58
99_references/beacon-main/src/components/avatar.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* @file Profile avatar Component
|
||||
*/
|
||||
|
||||
import {IonIcon} from "@ionic/react";
|
||||
import {helpOutline, helpSharp} from "ionicons/icons";
|
||||
import {FC} from "react";
|
||||
|
||||
import {TextFill} from "~/components/text-fill";
|
||||
import {usePersistentStore} from "~/lib/stores/persistent";
|
||||
import {Profile, Theme} from "~/lib/types";
|
||||
|
||||
/**
|
||||
* Profile avatar component props
|
||||
*/
|
||||
interface AvatarProps {
|
||||
/**
|
||||
* Profile
|
||||
*/
|
||||
profile: Partial<Pick<Profile, "color" | "emoji">>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Profile avatar component
|
||||
* @param props Props
|
||||
* @returns JSX
|
||||
*/
|
||||
export const Avatar: FC<AvatarProps> = ({profile}) => {
|
||||
// Hooks
|
||||
const theme = usePersistentStore(state => state.theme);
|
||||
|
||||
// Variables
|
||||
let color = profile.color;
|
||||
|
||||
if (color === undefined) {
|
||||
color = theme === Theme.DARK ? "#e5e5e5" : "#404040";
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-row h-full items-center justify-center rounded-full w-full p-1"
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
boxShadow: `0 0 20px -2px ${color}`,
|
||||
}}
|
||||
>
|
||||
{profile.emoji === undefined ? (
|
||||
<IonIcon
|
||||
className="dark:text-black h-full text-white w-full"
|
||||
ios={helpOutline}
|
||||
md={helpSharp}
|
||||
/>
|
||||
) : (
|
||||
<TextFill>{profile.emoji}</TextFill>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
161
99_references/beacon-main/src/components/blurhash.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* @file Blurhash component
|
||||
*/
|
||||
|
||||
import {decode} from "blurhash";
|
||||
import {FC, HTMLProps, useEffect, useRef, useState} from "react";
|
||||
|
||||
import {
|
||||
BLURHASH_COMPONENT_X,
|
||||
BLURHASH_COMPONENT_Y,
|
||||
BLURHASH_PIXELS_PER_COMPONENT,
|
||||
} from "~/lib/media";
|
||||
import {usePersistentStore} from "~/lib/stores/persistent";
|
||||
import {MediaDimensions, Theme} from "~/lib/types";
|
||||
|
||||
/**
|
||||
* Blurhash component props
|
||||
*/
|
||||
interface BlurhashProps extends HTMLProps<HTMLCanvasElement> {
|
||||
/**
|
||||
* Ambient effect
|
||||
*/
|
||||
ambient: boolean;
|
||||
|
||||
/**
|
||||
* Blurhash string
|
||||
*/
|
||||
hash: string;
|
||||
|
||||
/**
|
||||
* Height of the blurhash
|
||||
*/
|
||||
height: number;
|
||||
|
||||
/**
|
||||
* Width of the blurhash
|
||||
*/
|
||||
width: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blurhash component
|
||||
* @param props Props
|
||||
* @returns JSX
|
||||
*/
|
||||
export const Blurhash: FC<BlurhashProps> = ({
|
||||
hash,
|
||||
height,
|
||||
width,
|
||||
ambient,
|
||||
...props
|
||||
}) => {
|
||||
// Hooks
|
||||
const canvas = useRef<HTMLCanvasElement>(null);
|
||||
const [pixels, setPixels] = useState<Uint8ClampedArray | undefined>();
|
||||
const [avg, setAvg] = useState<string | undefined>();
|
||||
|
||||
const theme = usePersistentStore(state => state.theme);
|
||||
|
||||
// Constants
|
||||
const scaledDimensions = {
|
||||
height: BLURHASH_COMPONENT_X * BLURHASH_PIXELS_PER_COMPONENT,
|
||||
width: BLURHASH_COMPONENT_Y * BLURHASH_PIXELS_PER_COMPONENT,
|
||||
} as MediaDimensions;
|
||||
|
||||
// Effects
|
||||
useEffect(() => {
|
||||
// Decode the blurhash
|
||||
setPixels(decode(hash, scaledDimensions.width, scaledDimensions.height));
|
||||
}, [hash]);
|
||||
|
||||
useEffect(() => {
|
||||
if (ambient && pixels !== undefined) {
|
||||
// Compute the average color of the bottom row
|
||||
let r = 0;
|
||||
let g = 0;
|
||||
let b = 0;
|
||||
|
||||
for (let col = 0; col < scaledDimensions.width; col++) {
|
||||
const index =
|
||||
4 * (scaledDimensions.height - 1) * scaledDimensions.width;
|
||||
|
||||
r += pixels[index]!;
|
||||
g += pixels[index + 1]!;
|
||||
b += pixels[index + 2]!;
|
||||
}
|
||||
|
||||
r /= scaledDimensions.width;
|
||||
g /= scaledDimensions.width;
|
||||
b /= scaledDimensions.width;
|
||||
|
||||
// Convert to hex
|
||||
const hex = `#${[
|
||||
r,
|
||||
g,
|
||||
b,
|
||||
]
|
||||
.map(component => component.toString(16).padStart(2, "0"))
|
||||
.join("")}`;
|
||||
|
||||
setAvg(hex);
|
||||
} else {
|
||||
setAvg(undefined);
|
||||
}
|
||||
}, [ambient, pixels]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (
|
||||
canvas.current === null ||
|
||||
height === 0 ||
|
||||
width === 0 ||
|
||||
pixels === undefined
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the context
|
||||
const context = canvas.current.getContext("2d")!;
|
||||
|
||||
// Get the image data
|
||||
const imageData = context.createImageData(
|
||||
scaledDimensions.width,
|
||||
scaledDimensions.height,
|
||||
);
|
||||
|
||||
// Set the pixels
|
||||
imageData.data.set(pixels);
|
||||
context.putImageData(imageData, 0, 0);
|
||||
|
||||
// Scale the canvas
|
||||
context.scale(
|
||||
width / scaledDimensions.width,
|
||||
height / scaledDimensions.height,
|
||||
);
|
||||
|
||||
context.drawImage(canvas.current, 0, 0);
|
||||
})();
|
||||
}, [
|
||||
hash,
|
||||
width,
|
||||
height,
|
||||
]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
{...props}
|
||||
style={{
|
||||
...props.style,
|
||||
...(avg === undefined
|
||||
? {}
|
||||
: {
|
||||
boxShadow: `0 0 300px ${theme === Theme.DARK ? 5 : 50}px ${avg}`,
|
||||
}),
|
||||
}}
|
||||
height={height}
|
||||
width={width}
|
||||
ref={canvas}
|
||||
/>
|
||||
);
|
||||
};
|
@@ -0,0 +1,3 @@
|
||||
.iconButton:global(::part(native)) {
|
||||
@apply px-1 py-0;
|
||||
}
|
397
99_references/beacon-main/src/components/comment-card.tsx
Normal file
@@ -0,0 +1,397 @@
|
||||
/**
|
||||
* @file Comment card component
|
||||
*/
|
||||
|
||||
import {
|
||||
IonButton,
|
||||
IonCard,
|
||||
IonCardContent,
|
||||
IonIcon,
|
||||
IonItem,
|
||||
IonList,
|
||||
IonPopover,
|
||||
IonRouterLink,
|
||||
useIonActionSheet,
|
||||
} from "@ionic/react";
|
||||
import {
|
||||
arrowDownOutline,
|
||||
arrowDownSharp,
|
||||
arrowUpOutline,
|
||||
arrowUpSharp,
|
||||
ellipsisVerticalOutline,
|
||||
ellipsisVerticalSharp,
|
||||
shareSocialOutline,
|
||||
shareSocialSharp,
|
||||
timeOutline,
|
||||
timeSharp,
|
||||
trashBinOutline,
|
||||
trashBinSharp,
|
||||
warningOutline,
|
||||
warningSharp,
|
||||
} from "ionicons/icons";
|
||||
import {Duration} from "luxon";
|
||||
import {FC, HTMLAttributes, useEffect, useId, useState} from "react";
|
||||
|
||||
import {Avatar} from "~/components/avatar";
|
||||
import styles from "~/components/comment-card.module.css";
|
||||
import {Markdown} from "~/components/markdown";
|
||||
import {useEphemeralStore} from "~/lib/stores/ephemeral";
|
||||
import {client} from "~/lib/supabase";
|
||||
import {Comment, GlobalMessageMetadata} from "~/lib/types";
|
||||
import {formatDuration, formatScalar} from "~/lib/utils";
|
||||
|
||||
/**
|
||||
* Copied link message metadata
|
||||
*/
|
||||
const COPIED_LINK_MESSAGE_METADATA: GlobalMessageMetadata = {
|
||||
symbol: Symbol("comment-card.copied-link"),
|
||||
name: "Copied link",
|
||||
description: "The link to the comment has been copied to your clipboard.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Already reported message metadata
|
||||
*/
|
||||
const ALREADY_REPORTED_MESSAGE_METADATA: GlobalMessageMetadata = {
|
||||
symbol: Symbol("comment-card.already-reported"),
|
||||
name: "Already reported",
|
||||
description: "The comment has already been reported.",
|
||||
};
|
||||
|
||||
/**
|
||||
* New report message metadata
|
||||
*/
|
||||
const NEW_REPORT_MESSAGE_METADATA: GlobalMessageMetadata = {
|
||||
symbol: Symbol("comment-card.new-report"),
|
||||
name: "New report",
|
||||
description: "The comment has been reported. Thank you for your feedback.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Comment card component props
|
||||
*/
|
||||
interface CommentCardProps extends HTMLAttributes<HTMLIonCardElement> {
|
||||
/**
|
||||
* Comment
|
||||
*/
|
||||
comment: Comment;
|
||||
|
||||
/**
|
||||
* Comment load event handler
|
||||
*/
|
||||
onLoad?: () => void;
|
||||
|
||||
/**
|
||||
* Toggle a vote on the comment
|
||||
* @param upvote Whether the vote is an upvote or a downvote
|
||||
*/
|
||||
toggleVote: (upvote: boolean) => void;
|
||||
|
||||
/**
|
||||
* Comment deleted event handler
|
||||
*/
|
||||
onDeleted?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Comment card component
|
||||
* @param props Props
|
||||
* @returns JSX
|
||||
*/
|
||||
export const CommentCard: FC<CommentCardProps> = ({
|
||||
comment,
|
||||
onLoad,
|
||||
toggleVote,
|
||||
onDeleted,
|
||||
...props
|
||||
}) => {
|
||||
// Variables
|
||||
const AvatarContainer = comment.commenter_id === null ? "div" : IonRouterLink;
|
||||
|
||||
// Hooks
|
||||
const id = useId();
|
||||
const [time, setTime] = useState<string | undefined>();
|
||||
|
||||
const [present] = useIonActionSheet();
|
||||
|
||||
const setMessage = useEphemeralStore(state => state.setMessage);
|
||||
|
||||
// Effects
|
||||
useEffect(() => {
|
||||
// Recompute ago every five seconds
|
||||
updateAgo();
|
||||
setInterval(updateAgo, 5000);
|
||||
|
||||
// Emit the load event
|
||||
onLoad?.();
|
||||
}, []);
|
||||
|
||||
// Methods
|
||||
/**
|
||||
* Update the ago time
|
||||
*/
|
||||
const updateAgo = () => {
|
||||
const duration = Date.now() - new Date(comment.created_at).getTime();
|
||||
|
||||
setTime(
|
||||
Duration.fromMillis(duration).as("days") < 1
|
||||
? `${formatDuration(duration)} ago`
|
||||
: new Date(comment.created_at).toLocaleDateString(),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Share the comment
|
||||
*/
|
||||
const shareComment = async () => {
|
||||
// Generate the URL
|
||||
const url = new URL(`/posts/${comment.post_id}`, window.location.origin);
|
||||
|
||||
url.search = new URLSearchParams({
|
||||
comment: comment.id.toString(),
|
||||
}).toString();
|
||||
|
||||
const strUrl = url.toString();
|
||||
|
||||
// Share
|
||||
await (navigator.share === undefined
|
||||
? navigator.clipboard.writeText(strUrl)
|
||||
: navigator.share({
|
||||
url: strUrl,
|
||||
}));
|
||||
|
||||
// Display the message
|
||||
setMessage(COPIED_LINK_MESSAGE_METADATA);
|
||||
};
|
||||
|
||||
/**
|
||||
* Report the comment
|
||||
* @returns Promise
|
||||
*/
|
||||
const reportComment = () =>
|
||||
present({
|
||||
header: "Report Comment",
|
||||
subHeader:
|
||||
"Are you sure you want to report this comment? This action cannot be undone.",
|
||||
buttons: [
|
||||
{
|
||||
text: "Cancel",
|
||||
role: "cancel",
|
||||
},
|
||||
{
|
||||
text: "Report",
|
||||
role: "destructive",
|
||||
/**
|
||||
* Comment report handler
|
||||
*/
|
||||
handler: async () => {
|
||||
// Insert the report
|
||||
const {error} = await client.from("comment_reports").insert({
|
||||
// eslint-disable-next-line camelcase
|
||||
comment_id: comment.id,
|
||||
});
|
||||
|
||||
// Handle error
|
||||
if (error !== null) {
|
||||
if (error.code === "23505") {
|
||||
// Display the message
|
||||
setMessage(ALREADY_REPORTED_MESSAGE_METADATA);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Display the message
|
||||
setMessage(NEW_REPORT_MESSAGE_METADATA);
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
/**
|
||||
* Delete the comment
|
||||
* @returns Promise
|
||||
*/
|
||||
const deleteComment = () =>
|
||||
present({
|
||||
header: "Delete Comment",
|
||||
subHeader:
|
||||
"Are you sure you want to delete this comment? This action cannot be undone.",
|
||||
buttons: [
|
||||
{
|
||||
text: "Cancel",
|
||||
role: "cancel",
|
||||
},
|
||||
{
|
||||
text: "Delete",
|
||||
role: "destructive",
|
||||
/**
|
||||
* Comment delete handler
|
||||
*/
|
||||
handler: onDeleted,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return (
|
||||
<IonCard
|
||||
{...props}
|
||||
className={`cursor-pointer dark:text-neutral-300 overflow-hidden rounded-xl text-neutral-700 ${
|
||||
props.className ?? ""
|
||||
}`}
|
||||
>
|
||||
<IonCardContent className="p-4">
|
||||
<div className="flex flex-row items-center justify-between w-full">
|
||||
<AvatarContainer
|
||||
className="h-8 w-8"
|
||||
{...(comment.commenter_id === null
|
||||
? {}
|
||||
: {
|
||||
routerLink: `/users/${comment.commenter_id}`,
|
||||
})}
|
||||
>
|
||||
<Avatar
|
||||
profile={{
|
||||
emoji: comment.commenter_emoji ?? undefined,
|
||||
color: comment.commenter_color ?? undefined,
|
||||
}}
|
||||
/>
|
||||
</AvatarContainer>
|
||||
|
||||
<div className="flex flex-row items-center justify-center h-full">
|
||||
{time !== undefined && (
|
||||
<>
|
||||
<IonIcon
|
||||
className="text-[1.4rem]"
|
||||
ios={timeOutline}
|
||||
md={timeSharp}
|
||||
/>
|
||||
<p className="!mb-0 !ml-1.5 !mt-0.5">{time}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Markdown className="mt-4 mb-1" raw={comment.content} />
|
||||
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<div />
|
||||
|
||||
<div className="flex flex-row items-center">
|
||||
<IonButton
|
||||
className={`m-0 ${styles.iconButton}`}
|
||||
color={comment.upvote === true ? "success" : "medium"}
|
||||
fill="clear"
|
||||
onClick={event => {
|
||||
event.preventDefault();
|
||||
toggleVote(true);
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
slot="icon-only"
|
||||
ios={arrowUpOutline}
|
||||
md={arrowUpSharp}
|
||||
/>
|
||||
</IonButton>
|
||||
|
||||
<p className="!mb-0 !mx-1.5">
|
||||
{formatScalar(comment.upvotes - comment.downvotes)}
|
||||
</p>
|
||||
|
||||
<IonButton
|
||||
className={`m-0 ${styles.iconButton}`}
|
||||
color={comment.upvote === false ? "danger" : "medium"}
|
||||
fill="clear"
|
||||
onClick={event => {
|
||||
event.preventDefault();
|
||||
toggleVote(false);
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
slot="icon-only"
|
||||
ios={arrowDownOutline}
|
||||
md={arrowDownSharp}
|
||||
/>
|
||||
</IonButton>
|
||||
|
||||
<IonButton
|
||||
className={`m-0 ml-1.5 ${styles.iconButton}`}
|
||||
color="medium"
|
||||
fill="clear"
|
||||
onClick={event => event.preventDefault()}
|
||||
id={`${id}-options`}
|
||||
>
|
||||
<IonIcon
|
||||
className="text-[1.4rem]"
|
||||
slot="icon-only"
|
||||
ios={ellipsisVerticalOutline}
|
||||
md={ellipsisVerticalSharp}
|
||||
/>
|
||||
</IonButton>
|
||||
<IonPopover trigger={`${id}-options`} triggerAction="click">
|
||||
<IonList>
|
||||
<IonItem>
|
||||
<IonButton
|
||||
className="w-full"
|
||||
fill="clear"
|
||||
onClick={event => {
|
||||
event.preventDefault();
|
||||
shareComment();
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
slot="start"
|
||||
ios={shareSocialOutline}
|
||||
md={shareSocialSharp}
|
||||
/>
|
||||
Share
|
||||
</IonButton>
|
||||
</IonItem>
|
||||
|
||||
<IonItem lines={onDeleted === undefined ? "none" : undefined}>
|
||||
<IonButton
|
||||
className="w-full"
|
||||
color="danger"
|
||||
fill="clear"
|
||||
onClick={event => {
|
||||
event.preventDefault();
|
||||
reportComment();
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
slot="start"
|
||||
ios={warningOutline}
|
||||
md={warningSharp}
|
||||
/>
|
||||
Report
|
||||
</IonButton>
|
||||
</IonItem>
|
||||
|
||||
{onDeleted !== undefined && (
|
||||
<IonItem lines="none">
|
||||
<IonButton
|
||||
className="w-full"
|
||||
color="danger"
|
||||
fill="clear"
|
||||
onClick={event => {
|
||||
event.preventDefault();
|
||||
deleteComment();
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
slot="start"
|
||||
ios={trashBinOutline}
|
||||
md={trashBinSharp}
|
||||
/>
|
||||
Delete
|
||||
</IonButton>
|
||||
</IonItem>
|
||||
)}
|
||||
</IonList>
|
||||
</IonPopover>
|
||||
</div>
|
||||
</div>
|
||||
</IonCardContent>
|
||||
</IonCard>
|
||||
);
|
||||
};
|
@@ -0,0 +1,3 @@
|
||||
.content:global(::part(scroll)) {
|
||||
@apply p-0;
|
||||
}
|
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* @file Create comment page container component
|
||||
*/
|
||||
/* eslint-disable jsdoc/require-jsdoc */
|
||||
|
||||
import {
|
||||
IonBackButton,
|
||||
IonButtons,
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonPage,
|
||||
IonTitle,
|
||||
IonToolbar,
|
||||
} from "@ionic/react";
|
||||
import {FC, ReactNode} from "react";
|
||||
|
||||
import styles from "~/components/create-comment-container.module.css";
|
||||
|
||||
/**
|
||||
* Create comment page container component props
|
||||
*/
|
||||
interface CreateCommentContainerProps {
|
||||
/**
|
||||
* Parent post ID
|
||||
*/
|
||||
postID: string;
|
||||
|
||||
/**
|
||||
* Children
|
||||
*/
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create comment page container component
|
||||
* @param props Props
|
||||
* @returns JSX
|
||||
*/
|
||||
export const CreateCommentContainer: FC<CreateCommentContainerProps> = ({
|
||||
postID,
|
||||
children,
|
||||
}) => (
|
||||
<IonPage>
|
||||
<IonHeader className="ion-no-border">
|
||||
<IonToolbar>
|
||||
<IonButtons slot="start">
|
||||
<IonBackButton defaultHref={`/posts/${postID}`} />
|
||||
</IonButtons>
|
||||
|
||||
<IonTitle>Create Comment</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<IonContent className={styles.content}>{children}</IonContent>
|
||||
</IonPage>
|
||||
);
|
@@ -0,0 +1,3 @@
|
||||
.content:global(::part(scroll)) {
|
||||
@apply p-0;
|
||||
}
|
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @file Create post page container component
|
||||
*/
|
||||
/* eslint-disable jsdoc/require-jsdoc */
|
||||
|
||||
import {
|
||||
IonBackButton,
|
||||
IonButtons,
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonPage,
|
||||
IonTitle,
|
||||
IonToolbar,
|
||||
} from "@ionic/react";
|
||||
import {FC, ReactNode} from "react";
|
||||
|
||||
import styles from "~/components/create-post-container.module.css";
|
||||
|
||||
/**
|
||||
* Create post page container component props
|
||||
*/
|
||||
interface CreatePostContainerProps {
|
||||
/**
|
||||
* Children
|
||||
*/
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create post page container component
|
||||
* @param props Props
|
||||
* @returns JSX
|
||||
*/
|
||||
export const CreatePostContainer: FC<CreatePostContainerProps> = ({
|
||||
children,
|
||||
}) => (
|
||||
<IonPage>
|
||||
<IonHeader className="ion-no-border">
|
||||
<IonToolbar>
|
||||
<IonButtons slot="start">
|
||||
<IonBackButton defaultHref="/nearby" />
|
||||
</IonButtons>
|
||||
|
||||
<IonTitle>Create Post</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<IonContent className={styles.content}>{children}</IonContent>
|
||||
</IonPage>
|
||||
);
|
29
99_references/beacon-main/src/components/global-message.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* @file Global message component
|
||||
*/
|
||||
|
||||
import {IonAlert} from "@ionic/react";
|
||||
import {FC} from "react";
|
||||
|
||||
import {useEphemeralStore} from "~/lib/stores/ephemeral";
|
||||
|
||||
/**
|
||||
* Global message component
|
||||
* @returns JSX
|
||||
*/
|
||||
export const GlobalMessage: FC = () => {
|
||||
// Hooks
|
||||
const message = useEphemeralStore(state => state.message);
|
||||
const setMessage = useEphemeralStore(state => state.setMessage);
|
||||
|
||||
// JSX
|
||||
return (
|
||||
<IonAlert
|
||||
isOpen={message !== undefined}
|
||||
header={message?.name}
|
||||
subHeader={message?.description}
|
||||
buttons={["OK"]}
|
||||
onIonAlertDidDismiss={() => setMessage()}
|
||||
/>
|
||||
);
|
||||
};
|
16
99_references/beacon-main/src/components/header.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* @file Header component
|
||||
*/
|
||||
|
||||
import {IonMenuButton} from "@ionic/react";
|
||||
import {FC} from "react";
|
||||
|
||||
/**
|
||||
* Header component
|
||||
* @returns JSX
|
||||
*/
|
||||
export const Header: FC = () => (
|
||||
<div className="absolute h-14 left-0 overflow-hidden top-0 w-14 z-10">
|
||||
<IonMenuButton />
|
||||
</div>
|
||||
);
|
24
99_references/beacon-main/src/components/map.module.css
Normal file
@@ -0,0 +1,24 @@
|
||||
.container :global(.leaflet-control-zoom) {
|
||||
@apply bg-transparent border-1 border-black dark:border-white;
|
||||
}
|
||||
|
||||
.container :global(.leaflet-control-zoom-in),
|
||||
.container :global(.leaflet-control-zoom-out) {
|
||||
@apply bg-opacity-20 hover:bg-opacity-80 hover:dark:bg-opacity-80 bg-white dark:bg-black dark:bg-opacity-20 dark:text-white text-black;
|
||||
}
|
||||
|
||||
.container :global(.leaflet-control-zoom-in) {
|
||||
@apply border-b-1;
|
||||
}
|
||||
|
||||
.container :global(.leaflet-control-zoom-out) {
|
||||
@apply border-t-1;
|
||||
}
|
||||
|
||||
.container :global(.leaflet-control-scale-line) {
|
||||
@apply bg-opacity-20 bg-white border-1 border-black dark:bg-black dark:bg-opacity-20 dark:border-white dark:text-neutral-200 text-neutral-700 text-shadow-none;
|
||||
}
|
||||
|
||||
.container :global(.leaflet-control-attribution) {
|
||||
@apply bg-opacity-20 bg-white dark:bg-black dark:bg-opacity-20 dark:text-neutral-200 text-neutral-700;
|
||||
}
|
152
99_references/beacon-main/src/components/map.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* @file Geography map component
|
||||
*/
|
||||
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import "leaflet";
|
||||
|
||||
import {Map as LeafletMap} from "leaflet";
|
||||
import {FC, HTMLAttributes, useEffect, useRef} from "react";
|
||||
import {
|
||||
AttributionControl,
|
||||
Circle,
|
||||
MapContainer,
|
||||
MapContainerProps,
|
||||
ScaleControl,
|
||||
TileLayer,
|
||||
} from "react-leaflet";
|
||||
import {useMeasure} from "react-use";
|
||||
|
||||
import styles from "~/components/map.module.css";
|
||||
import {usePersistentStore} from "~/lib/stores/persistent";
|
||||
import {Theme} from "~/lib/types";
|
||||
|
||||
/**
|
||||
* Geography map component props
|
||||
*/
|
||||
interface MapProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* Position (latitude, longitude)
|
||||
*/
|
||||
position: [number, number];
|
||||
|
||||
/**
|
||||
* Whether or not to lock the position
|
||||
*/
|
||||
lockPosition?: boolean;
|
||||
|
||||
/**
|
||||
* Bounds (corner 1 latitude, corner 1 longitude, corner 2 latitude, corner 2 longitude)
|
||||
*/
|
||||
bounds?: [[number, number], [number, number]];
|
||||
|
||||
/**
|
||||
* Zoom level (0-20)
|
||||
*/
|
||||
zoom?: number;
|
||||
|
||||
/**
|
||||
* Whether or not to lock the zoom level
|
||||
*/
|
||||
lockZoom?: boolean;
|
||||
|
||||
/**
|
||||
* Minimum zoom level (0-20)
|
||||
*/
|
||||
minZoom?: number;
|
||||
|
||||
/**
|
||||
* Maximum zoom level (0-20)
|
||||
*/
|
||||
maxZoom?: number;
|
||||
|
||||
/**
|
||||
* Circle overlay
|
||||
*/
|
||||
circle?: {
|
||||
/**
|
||||
* Circle radius (in meters)
|
||||
*/
|
||||
radius: number;
|
||||
|
||||
/**
|
||||
* Circle center (latitude, longitude)
|
||||
*/
|
||||
center: [number, number];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Geography map component
|
||||
* @returns JSX
|
||||
*/
|
||||
export const Map: FC<MapProps> = ({
|
||||
position,
|
||||
lockPosition = false,
|
||||
bounds,
|
||||
zoom = 10,
|
||||
lockZoom = false,
|
||||
minZoom,
|
||||
maxZoom,
|
||||
circle,
|
||||
...props
|
||||
}) => {
|
||||
// Hooks
|
||||
const mapRef = useRef<MapContainerProps & LeafletMap>(null);
|
||||
const theme = usePersistentStore(state => state.theme);
|
||||
const [containerRef, {height, width}] = useMeasure<HTMLDivElement>();
|
||||
|
||||
// Effects
|
||||
useEffect(() => {
|
||||
if (mapRef.current === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
mapRef.current.invalidateSize();
|
||||
}, [height, width]);
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={`${styles.container} ${props.className ?? ""}`}
|
||||
ref={containerRef}
|
||||
>
|
||||
<MapContainer
|
||||
className="h-full w-full !bg-white dark:!bg-black"
|
||||
ref={mapRef}
|
||||
attributionControl={false}
|
||||
center={position}
|
||||
doubleClickZoom={!lockZoom}
|
||||
dragging={!lockPosition}
|
||||
maxBounds={lockPosition ? [position, position] : bounds}
|
||||
maxZoom={maxZoom}
|
||||
minZoom={minZoom}
|
||||
scrollWheelZoom={!lockZoom}
|
||||
zoom={zoom}
|
||||
zoomControl={!lockZoom}
|
||||
>
|
||||
<ScaleControl />
|
||||
<AttributionControl prefix="" />
|
||||
{circle !== undefined && (
|
||||
<Circle
|
||||
className="!fill-primary-400 !stroke-primary-700"
|
||||
center={circle.center}
|
||||
radius={circle.radius}
|
||||
pathOptions={{
|
||||
lineCap: "square",
|
||||
lineJoin: "miter",
|
||||
fillOpacity: 0.25,
|
||||
opacity: 0.8,
|
||||
weight: 2,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<TileLayer
|
||||
className={theme === Theme.DARK ? "invert grayscale-30" : ""}
|
||||
attribution='<a rel="noreferrer" target="_blank" href="https://leafletjs.com/">Leaflet</a> | © <a rel="noreferrer" target="_blank" href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors, © <a rel="noreferrer" target="_blank" href="https://carto.com/attributions">CARTO</a>'
|
||||
url="https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png"
|
||||
/>
|
||||
</MapContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
184
99_references/beacon-main/src/components/markdown.module.css
Normal file
@@ -0,0 +1,184 @@
|
||||
.markdown {
|
||||
@apply min-w-fit;
|
||||
}
|
||||
|
||||
.markdown h1 {
|
||||
@apply text-4xl;
|
||||
}
|
||||
|
||||
.markdown h2 {
|
||||
@apply text-3xl;
|
||||
}
|
||||
|
||||
.markdown h3 {
|
||||
@apply text-2xl;
|
||||
}
|
||||
|
||||
.markdown h4 {
|
||||
@apply text-xl;
|
||||
}
|
||||
|
||||
.markdown h5 {
|
||||
@apply text-lg;
|
||||
}
|
||||
|
||||
.markdown h6 {
|
||||
@apply text-sm;
|
||||
}
|
||||
|
||||
.markdown h1,
|
||||
.markdown h2,
|
||||
.markdown h3,
|
||||
.markdown h4,
|
||||
.markdown h5,
|
||||
.markdown h6 {
|
||||
@apply font-bold mt-4 mb-2;
|
||||
}
|
||||
|
||||
.markdown p {
|
||||
@apply my-2;
|
||||
}
|
||||
|
||||
.markdown ol {
|
||||
@apply list-decimal;
|
||||
}
|
||||
|
||||
.markdown ul {
|
||||
@apply list-disc;
|
||||
}
|
||||
|
||||
.markdown ol,
|
||||
.markdown ul {
|
||||
@apply ml-4;
|
||||
}
|
||||
|
||||
.markdown th,
|
||||
.markdown td {
|
||||
@apply b-1 p-1;
|
||||
}
|
||||
|
||||
.markdown table {
|
||||
@apply my-4;
|
||||
}
|
||||
|
||||
.markdown hr {
|
||||
@apply my-4;
|
||||
}
|
||||
|
||||
*:not(pre) code,
|
||||
.markdown pre {
|
||||
@apply bg-neutral-100 dark:bg-neutral-800 rounded-md;
|
||||
}
|
||||
|
||||
.markdown *:not(pre) code {
|
||||
@apply p-0.5;
|
||||
}
|
||||
|
||||
.markdown pre {
|
||||
@apply p-2;
|
||||
}
|
||||
|
||||
.markdown blockquote {
|
||||
@apply border-l-4 italic pl-2 border-red-400;
|
||||
}
|
||||
|
||||
.markdown blockquote blockquote {
|
||||
@apply border-orange-400;
|
||||
}
|
||||
|
||||
.markdown blockquote blockquote blockquote {
|
||||
@apply border-yellow-400;
|
||||
}
|
||||
|
||||
.markdown blockquote blockquote blockquote blockquote {
|
||||
@apply border-lime-400;
|
||||
}
|
||||
|
||||
.markdown blockquote blockquote blockquote blockquote blockquote {
|
||||
@apply border-green-400;
|
||||
}
|
||||
|
||||
.markdown blockquote blockquote blockquote blockquote blockquote blockquote {
|
||||
@apply border-teal-400;
|
||||
}
|
||||
|
||||
.markdown
|
||||
blockquote
|
||||
blockquote
|
||||
blockquote
|
||||
blockquote
|
||||
blockquote
|
||||
blockquote
|
||||
blockquote {
|
||||
@apply border-sky-400;
|
||||
}
|
||||
|
||||
.markdown
|
||||
blockquote
|
||||
blockquote
|
||||
blockquote
|
||||
blockquote
|
||||
blockquote
|
||||
blockquote
|
||||
blockquote
|
||||
blockquote {
|
||||
@apply border-blue-400;
|
||||
}
|
||||
|
||||
.markdown
|
||||
blockquote
|
||||
blockquote
|
||||
blockquote
|
||||
blockquote
|
||||
blockquote
|
||||
blockquote
|
||||
blockquote
|
||||
blockquote
|
||||
blockquote {
|
||||
@apply border-indigo-400;
|
||||
}
|
||||
|
||||
.markdown
|
||||
blockquote
|
||||
blockquote
|
||||
blockquote
|
||||
blockquote
|
||||
blockquote
|
||||
blockquote
|
||||
blockquote
|
||||
blockquote
|
||||
blockquote
|
||||
blockquote {
|
||||
@apply border-purple-400;
|
||||
}
|
||||
|
||||
.markdown
|
||||
blockquote
|
||||
blockquote
|
||||
blockquote
|
||||
blockquote
|
||||
blockquote
|
||||
blockquote
|
||||
blockquote
|
||||
blockquote
|
||||
blockquote
|
||||
blockquote
|
||||
blockquote {
|
||||
@apply border-fuchsia-400;
|
||||
}
|
||||
|
||||
.markdown
|
||||
blockquote
|
||||
blockquote
|
||||
blockquote
|
||||
blockquote
|
||||
blockquote
|
||||
blockquote
|
||||
blockquote
|
||||
blockquote
|
||||
blockquote
|
||||
blockquote
|
||||
blockquote
|
||||
blockquote {
|
||||
@apply border-neutral-300 dark:border-neutral-600;
|
||||
}
|
83
99_references/beacon-main/src/components/markdown.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* @file Markdown renderer component
|
||||
*/
|
||||
|
||||
import {Schema} from "hast-util-sanitize";
|
||||
import {FC} from "react";
|
||||
import ReactMarkdown, {Options} from "react-markdown";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import rehypeSanitize from "rehype-sanitize";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
import styles from "~/components/markdown.module.css";
|
||||
|
||||
/**
|
||||
* Sanitization schema
|
||||
* @see https://github.com/syntax-tree/hast-util-sanitize#schema
|
||||
*/
|
||||
const schema = {
|
||||
allowComments: false,
|
||||
allowDoctypes: false,
|
||||
ancestors: {},
|
||||
attributes: {},
|
||||
strip: ["script"],
|
||||
tagNames: [
|
||||
"b",
|
||||
"blockquote",
|
||||
"br",
|
||||
"code",
|
||||
"del",
|
||||
"details",
|
||||
"em",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"hr",
|
||||
"i",
|
||||
"ins",
|
||||
"li",
|
||||
"ol",
|
||||
"p",
|
||||
"pre",
|
||||
"s",
|
||||
"strike",
|
||||
"strong",
|
||||
"sub",
|
||||
"summary",
|
||||
"sup",
|
||||
"table",
|
||||
"td",
|
||||
"th",
|
||||
"u",
|
||||
"tr",
|
||||
"ul",
|
||||
],
|
||||
} as Schema;
|
||||
|
||||
/**
|
||||
* Markdown renderer component props
|
||||
*/
|
||||
interface MarkdownProps extends Options {
|
||||
/**
|
||||
* Raw Github-Flavored Markdown (GFM) content
|
||||
*/
|
||||
raw: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown renderer component
|
||||
* @returns JSX
|
||||
*/
|
||||
export const Markdown: FC<MarkdownProps> = ({raw, ...props}) => (
|
||||
<ReactMarkdown
|
||||
{...props}
|
||||
className={`${styles.markdown} ${props.className ?? ""}`}
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[[rehypeRaw], [rehypeSanitize, schema]]}
|
||||
>
|
||||
{raw}
|
||||
</ReactMarkdown>
|
||||
);
|
205
99_references/beacon-main/src/components/menu.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* @file Menu component
|
||||
*/
|
||||
|
||||
import {
|
||||
IonContent,
|
||||
IonIcon,
|
||||
IonImg,
|
||||
IonItem,
|
||||
IonLabel,
|
||||
IonList,
|
||||
IonListHeader,
|
||||
IonMenu,
|
||||
IonMenuToggle,
|
||||
} from "@ionic/react";
|
||||
import {
|
||||
createOutline,
|
||||
createSharp,
|
||||
homeOutline,
|
||||
homeSharp,
|
||||
lockClosedOutline,
|
||||
lockClosedSharp,
|
||||
navigateCircleOutline,
|
||||
navigateCircleSharp,
|
||||
settingsOutline,
|
||||
settingsSharp,
|
||||
} from "ionicons/icons";
|
||||
import {FC, useRef} from "react";
|
||||
import {useLocation} from "react-router-dom";
|
||||
|
||||
import logo from "~/assets/logo.png";
|
||||
import {useEphemeralStore} from "~/lib/stores/ephemeral";
|
||||
import {AuthState} from "~/lib/types";
|
||||
import {getAuthState} from "~/lib/utils";
|
||||
|
||||
/**
|
||||
* Menu navigation item position
|
||||
*/
|
||||
enum NavItemPosition {
|
||||
TOP = "top",
|
||||
BOTTOM = "bottom",
|
||||
}
|
||||
|
||||
/**
|
||||
* Menu navigation item
|
||||
*/
|
||||
interface NavItem {
|
||||
/**
|
||||
* Item URL
|
||||
*/
|
||||
url: string;
|
||||
|
||||
/**
|
||||
* Required authentication state to show this item or undefined if the item is always available
|
||||
*/
|
||||
requiredState: AuthState | undefined;
|
||||
|
||||
/**
|
||||
* Item position
|
||||
*/
|
||||
position: NavItemPosition;
|
||||
|
||||
/**
|
||||
* iOS icon
|
||||
*/
|
||||
iosIcon: string;
|
||||
|
||||
/**
|
||||
* Material Design icon
|
||||
*/
|
||||
mdIcon: string;
|
||||
|
||||
/**
|
||||
* Item title
|
||||
*/
|
||||
title: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Menu navigation items
|
||||
*/
|
||||
const navItems: NavItem[] = [
|
||||
{
|
||||
title: "Home",
|
||||
requiredState: AuthState.UNAUTHENTICATED,
|
||||
url: "/",
|
||||
position: NavItemPosition.TOP,
|
||||
iosIcon: homeOutline,
|
||||
mdIcon: homeSharp,
|
||||
},
|
||||
{
|
||||
title: "Authentication",
|
||||
requiredState: AuthState.UNAUTHENTICATED,
|
||||
url: "/auth/1",
|
||||
position: NavItemPosition.TOP,
|
||||
iosIcon: lockClosedOutline,
|
||||
mdIcon: lockClosedSharp,
|
||||
},
|
||||
{
|
||||
title: "Nearby",
|
||||
requiredState: AuthState.AUTHENTICATED_TERMS,
|
||||
url: "/nearby",
|
||||
position: NavItemPosition.TOP,
|
||||
iosIcon: navigateCircleOutline,
|
||||
mdIcon: navigateCircleSharp,
|
||||
},
|
||||
{
|
||||
title: "Create Post",
|
||||
requiredState: AuthState.AUTHENTICATED_TERMS,
|
||||
url: "/posts/create/1",
|
||||
position: NavItemPosition.TOP,
|
||||
iosIcon: createOutline,
|
||||
mdIcon: createSharp,
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
requiredState: AuthState.AUTHENTICATED_TERMS,
|
||||
url: "/settings",
|
||||
position: NavItemPosition.BOTTOM,
|
||||
iosIcon: settingsOutline,
|
||||
mdIcon: settingsSharp,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Navigation item component
|
||||
* @param item Navigation item
|
||||
* @returns JSX
|
||||
*/
|
||||
const NavItem: FC<NavItem> = item => {
|
||||
// Hooks
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<IonMenuToggle autoHide={false}>
|
||||
<IonItem
|
||||
className={location.pathname === item.url ? "selected" : ""}
|
||||
routerLink={item.url}
|
||||
routerDirection="none"
|
||||
lines="none"
|
||||
detail={false}
|
||||
>
|
||||
<IonIcon
|
||||
aria-hidden="true"
|
||||
slot="start"
|
||||
ios={item.iosIcon}
|
||||
md={item.mdIcon}
|
||||
/>
|
||||
|
||||
<IonLabel>{item.title}</IonLabel>
|
||||
</IonItem>
|
||||
</IonMenuToggle>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Menu component
|
||||
* @returns JSX
|
||||
*/
|
||||
export const Menu: FC = () => {
|
||||
// Hooks
|
||||
const user = useEphemeralStore(state => state.user);
|
||||
const menu = useRef<HTMLIonMenuElement>(null);
|
||||
|
||||
return (
|
||||
<IonMenu contentId="main" type="overlay" ref={menu}>
|
||||
<IonContent>
|
||||
<IonList className="flex flex-col h-full py-4">
|
||||
<IonListHeader className="p-0">
|
||||
<div className="flex flex-row items-center justify-center my-8 w-full">
|
||||
<IonImg alt="Beacon logo" className="h-14 w-14 mr-2" src={logo} />
|
||||
<span className="ml-2 text-3xl">Beacon</span>
|
||||
</div>
|
||||
</IonListHeader>
|
||||
|
||||
{navItems
|
||||
.filter(
|
||||
navItem =>
|
||||
user !== undefined &&
|
||||
navItem.position === NavItemPosition.TOP &&
|
||||
(navItem.requiredState === undefined ||
|
||||
navItem.requiredState === getAuthState(user)),
|
||||
)
|
||||
.map((navItem, index) => (
|
||||
<NavItem key={index} {...navItem} />
|
||||
))}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{navItems
|
||||
.filter(
|
||||
navItem =>
|
||||
user !== undefined &&
|
||||
navItem.position === NavItemPosition.BOTTOM &&
|
||||
(navItem.requiredState === undefined ||
|
||||
navItem.requiredState === getAuthState(user)),
|
||||
)
|
||||
.map((navItem, index) => (
|
||||
<NavItem key={index} {...navItem} />
|
||||
))}
|
||||
</IonList>
|
||||
</IonContent>
|
||||
</IonMenu>
|
||||
);
|
||||
};
|
@@ -0,0 +1,3 @@
|
||||
.iconButton:global(::part(native)) {
|
||||
@apply px-1 py-0;
|
||||
}
|
591
99_references/beacon-main/src/components/post-card.tsx
Normal file
@@ -0,0 +1,591 @@
|
||||
/**
|
||||
* @file Post card component
|
||||
*/
|
||||
|
||||
import {
|
||||
IonButton,
|
||||
IonCard,
|
||||
IonCardContent,
|
||||
IonIcon,
|
||||
IonItem,
|
||||
IonList,
|
||||
IonPopover,
|
||||
IonRouterLink,
|
||||
useIonActionSheet,
|
||||
} from "@ionic/react";
|
||||
import {
|
||||
arrowDownOutline,
|
||||
arrowDownSharp,
|
||||
arrowUpOutline,
|
||||
arrowUpSharp,
|
||||
chatbubblesOutline,
|
||||
chatbubblesSharp,
|
||||
ellipsisVerticalOutline,
|
||||
ellipsisVerticalSharp,
|
||||
eyeOutline,
|
||||
eyeSharp,
|
||||
locationOutline,
|
||||
locationSharp,
|
||||
shareSocialOutline,
|
||||
shareSocialSharp,
|
||||
timeOutline,
|
||||
timeSharp,
|
||||
trashBinOutline,
|
||||
trashBinSharp,
|
||||
warningOutline,
|
||||
warningSharp,
|
||||
} from "ionicons/icons";
|
||||
import {Duration} from "luxon";
|
||||
import {
|
||||
FC,
|
||||
HTMLAttributes,
|
||||
MouseEvent,
|
||||
useEffect,
|
||||
useId,
|
||||
useState,
|
||||
} from "react";
|
||||
import {useHistory} from "react-router-dom";
|
||||
|
||||
import {Avatar} from "~/components/avatar";
|
||||
import {Blurhash} from "~/components/blurhash";
|
||||
import {Markdown} from "~/components/markdown";
|
||||
import styles from "~/components/post-card.module.css";
|
||||
import {getCategory, MAX_MEDIA_DIMENSION} from "~/lib/media";
|
||||
import {useEphemeralStore} from "~/lib/stores/ephemeral";
|
||||
import {usePersistentStore} from "~/lib/stores/persistent";
|
||||
import {client} from "~/lib/supabase";
|
||||
import {GlobalMessageMetadata, MediaCategory, Post} from "~/lib/types";
|
||||
import {formatDistance, formatDuration, formatScalar} from "~/lib/utils";
|
||||
|
||||
/**
|
||||
* Copied link message metadata
|
||||
*/
|
||||
const COPIED_LINK_MESSAGE_METADATA: GlobalMessageMetadata = {
|
||||
symbol: Symbol("post-card.copied-link"),
|
||||
name: "Copied link",
|
||||
description: "The link to the post has been copied to your clipboard.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Already reported message metadata
|
||||
*/
|
||||
const ALREADY_REPORTED_MESSAGE_METADATA: GlobalMessageMetadata = {
|
||||
symbol: Symbol("post-card.already-reported"),
|
||||
name: "Already reported",
|
||||
description: "The post has already been reported.",
|
||||
};
|
||||
|
||||
/**
|
||||
* New report message metadata
|
||||
*/
|
||||
const NEW_REPORT_MESSAGE_METADATA: GlobalMessageMetadata = {
|
||||
symbol: Symbol("post-card.new-report"),
|
||||
name: "New report",
|
||||
description: "The post has been reported. Thank you for your feedback.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Post card component props
|
||||
*/
|
||||
interface PostCardProps extends HTMLAttributes<HTMLIonCardElement> {
|
||||
/**
|
||||
* Post
|
||||
*/
|
||||
post: Post;
|
||||
|
||||
/**
|
||||
* Whether or not the post will link to the post detail page
|
||||
*/
|
||||
postLinkDetail: boolean;
|
||||
|
||||
/**
|
||||
* Post card width
|
||||
*/
|
||||
width: number;
|
||||
|
||||
/**
|
||||
* Post load event handler
|
||||
*/
|
||||
onLoad?: () => void;
|
||||
|
||||
/**
|
||||
* Toggle a vote on the post
|
||||
* @param upvote Whether the vote is an upvote or a downvote
|
||||
*/
|
||||
toggleVote: (upvote: boolean) => void;
|
||||
|
||||
/**
|
||||
* Post deleted event handler
|
||||
*/
|
||||
onDeleted?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Post card component
|
||||
* @param props Props
|
||||
* @returns JSX
|
||||
*/
|
||||
export const PostCard: FC<PostCardProps> = ({
|
||||
post,
|
||||
postLinkDetail,
|
||||
width,
|
||||
onLoad,
|
||||
toggleVote,
|
||||
onDeleted,
|
||||
...props
|
||||
}) => {
|
||||
// Variables
|
||||
const height = post.has_media
|
||||
? Math.min(
|
||||
Math.floor(width / (post as Post<true>).aspect_ratio),
|
||||
MAX_MEDIA_DIMENSION,
|
||||
)
|
||||
: 0;
|
||||
|
||||
const AvatarContainer = post.poster_id === null ? "div" : IonRouterLink;
|
||||
|
||||
// Hooks
|
||||
const id = useId();
|
||||
const [time, setTime] = useState<string | undefined>();
|
||||
|
||||
const [media, setMedia] = useState<
|
||||
| {
|
||||
category: MediaCategory;
|
||||
url: string;
|
||||
}
|
||||
| undefined
|
||||
>();
|
||||
|
||||
const history = useHistory();
|
||||
|
||||
const [present] = useIonActionSheet();
|
||||
|
||||
const setMessage = useEphemeralStore(state => state.setMessage);
|
||||
|
||||
const showAmbientEffect = usePersistentStore(
|
||||
state => state.showAmbientEffect,
|
||||
);
|
||||
|
||||
const measurementSystem = usePersistentStore(
|
||||
state => state.measurementSystem,
|
||||
);
|
||||
|
||||
// Effects
|
||||
useEffect(() => {
|
||||
// Recompute ago every five seconds
|
||||
updateAgo();
|
||||
setInterval(updateAgo, 5000);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (!post.has_media) {
|
||||
setMedia(undefined);
|
||||
|
||||
if (!post.has_media) {
|
||||
onLoad?.();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Load media
|
||||
const urls = [
|
||||
client.storage.from("media").getPublicUrl(`posts/${post.id}`, {
|
||||
transform: {
|
||||
quality: 90,
|
||||
height,
|
||||
width: Math.floor(width),
|
||||
},
|
||||
}).data.publicUrl,
|
||||
client.storage.from("media").getPublicUrl(`posts/${post.id}`).data
|
||||
.publicUrl,
|
||||
];
|
||||
|
||||
// Fetch the media
|
||||
let res: Response | undefined;
|
||||
|
||||
for (const url of urls) {
|
||||
res = await fetch(url);
|
||||
|
||||
if (res.ok) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (res === undefined) {
|
||||
setMedia(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
|
||||
// Get the category
|
||||
const category = getCategory(blob.type);
|
||||
|
||||
if (category === undefined) {
|
||||
setMedia(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create an object URL for the blob
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
setMedia({
|
||||
category,
|
||||
url,
|
||||
});
|
||||
|
||||
// Emit the load event
|
||||
onLoad?.();
|
||||
})();
|
||||
}, [post]);
|
||||
|
||||
// Methods
|
||||
/**
|
||||
* Update the ago time
|
||||
*/
|
||||
const updateAgo = () => {
|
||||
const duration = Date.now() - new Date(post.created_at).getTime();
|
||||
|
||||
setTime(
|
||||
Duration.fromMillis(duration).as("days") < 1
|
||||
? `${formatDuration(duration)} ago`
|
||||
: new Date(post.created_at).toLocaleDateString(),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Card click event handler
|
||||
* @param event Event
|
||||
*/
|
||||
const onCardClick = (event: MouseEvent<HTMLIonCardElement>) => {
|
||||
if (event.defaultPrevented || !postLinkDetail) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Go to the post detail page
|
||||
history.push(`/posts/${post.id}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Share the post
|
||||
*/
|
||||
const sharePost = async () => {
|
||||
// Generate the URL
|
||||
const url = new URL(`/posts/${post.id}`, window.location.origin);
|
||||
const strUrl = url.toString();
|
||||
|
||||
// Share
|
||||
await (navigator.share === undefined
|
||||
? navigator.clipboard.writeText(strUrl)
|
||||
: navigator.share({
|
||||
url: strUrl,
|
||||
}));
|
||||
|
||||
// Display the message
|
||||
setMessage(COPIED_LINK_MESSAGE_METADATA);
|
||||
};
|
||||
|
||||
/**
|
||||
* Report the post
|
||||
* @returns Promise
|
||||
*/
|
||||
const reportPost = () =>
|
||||
present({
|
||||
header: "Report Post",
|
||||
subHeader:
|
||||
"Are you sure you want to report this post? This action cannot be undone.",
|
||||
buttons: [
|
||||
{
|
||||
text: "Cancel",
|
||||
role: "cancel",
|
||||
},
|
||||
{
|
||||
text: "Report",
|
||||
role: "destructive",
|
||||
/**
|
||||
* Post report handler
|
||||
*/
|
||||
handler: async () => {
|
||||
// Insert the report
|
||||
const {error} = await client.from("post_reports").insert({
|
||||
// eslint-disable-next-line camelcase
|
||||
post_id: post.id,
|
||||
});
|
||||
|
||||
// Handle error
|
||||
if (error !== null) {
|
||||
if (error.code === "23505") {
|
||||
// Display the message
|
||||
setMessage(ALREADY_REPORTED_MESSAGE_METADATA);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Display the message
|
||||
setMessage(NEW_REPORT_MESSAGE_METADATA);
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
/**
|
||||
* Delete the post
|
||||
* @returns Promise
|
||||
*/
|
||||
const deletePost = () =>
|
||||
present({
|
||||
header: "Delete Post",
|
||||
subHeader:
|
||||
"Are you sure you want to delete this post? This action cannot be undone.",
|
||||
buttons: [
|
||||
{
|
||||
text: "Cancel",
|
||||
role: "cancel",
|
||||
},
|
||||
{
|
||||
text: "Delete",
|
||||
role: "destructive",
|
||||
/**
|
||||
* Comment delete handler
|
||||
*/
|
||||
handler: onDeleted,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return (
|
||||
<IonCard
|
||||
{...props}
|
||||
onClick={onCardClick}
|
||||
className={`cursor-pointer dark:text-neutral-300 overflow-hidden rounded-xl text-neutral-700 ${
|
||||
props.className ?? ""
|
||||
}`}
|
||||
>
|
||||
{post.has_media && (
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
height,
|
||||
width,
|
||||
}}
|
||||
>
|
||||
<Blurhash
|
||||
className="absolute"
|
||||
ambient={showAmbientEffect}
|
||||
hash={(post as Post<true>).blur_hash}
|
||||
height={height}
|
||||
width={width}
|
||||
/>
|
||||
{media !== undefined && (
|
||||
<div className="absolute animate-duration-500 animate-fade-in h-full overflow-hidden w-full">
|
||||
{(() => {
|
||||
switch (media.category) {
|
||||
case MediaCategory.IMAGE:
|
||||
return (
|
||||
<img
|
||||
alt="Post media"
|
||||
height={height}
|
||||
src={media.url}
|
||||
width={width}
|
||||
/>
|
||||
);
|
||||
|
||||
case MediaCategory.VIDEO:
|
||||
return (
|
||||
<video
|
||||
autoPlay
|
||||
height={height}
|
||||
loop
|
||||
muted
|
||||
src={media.url}
|
||||
width={width}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<IonCardContent className="p-4">
|
||||
<div className="flex flex-row items-center justify-between w-full">
|
||||
<AvatarContainer
|
||||
className="h-10 w-10"
|
||||
{...(post.poster_id === null
|
||||
? {}
|
||||
: {
|
||||
routerLink: `/users/${post.poster_id}`,
|
||||
})}
|
||||
>
|
||||
<Avatar
|
||||
profile={{
|
||||
emoji: post.poster_emoji ?? undefined,
|
||||
color: post.poster_color ?? undefined,
|
||||
}}
|
||||
/>
|
||||
</AvatarContainer>
|
||||
|
||||
<div className="flex flex-row items-center justify-center h-full">
|
||||
<IonIcon className="text-[1.4rem]" ios={eyeOutline} md={eyeSharp} />
|
||||
<p className="!mb-0 !ml-1.5 !mr-4 !mt-0.5">
|
||||
{formatScalar(post.views)}
|
||||
</p>
|
||||
|
||||
<IonIcon
|
||||
className="text-[1.4rem]"
|
||||
ios={locationOutline}
|
||||
md={locationSharp}
|
||||
/>
|
||||
<p className="!mb-0 !ml-1.5 !mr-4 !mt-0.5">
|
||||
{formatDistance(post.distance, measurementSystem)}
|
||||
</p>
|
||||
|
||||
{time !== undefined && (
|
||||
<>
|
||||
<IonIcon
|
||||
className="text-[1.4rem]"
|
||||
ios={timeOutline}
|
||||
md={timeSharp}
|
||||
/>
|
||||
<p className="!mb-0 !ml-1.5 !mt-0.5">{time}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Markdown className="mt-4 mb-1" raw={post.content} />
|
||||
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<div className="flex flex-row items-center justify-center px-1">
|
||||
<IonIcon
|
||||
className="text-[1.4rem]"
|
||||
color="medium"
|
||||
slot="start"
|
||||
ios={chatbubblesOutline}
|
||||
md={chatbubblesSharp}
|
||||
/>
|
||||
<p className="!mb-0 !ml-1.5">{formatScalar(post.comments)}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center">
|
||||
<IonButton
|
||||
className={`m-0 ${styles.iconButton}`}
|
||||
color={post.upvote === true ? "success" : "medium"}
|
||||
fill="clear"
|
||||
onClick={event => {
|
||||
event.preventDefault();
|
||||
toggleVote(true);
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
slot="icon-only"
|
||||
ios={arrowUpOutline}
|
||||
md={arrowUpSharp}
|
||||
/>
|
||||
</IonButton>
|
||||
|
||||
<p className="!mb-0 !mx-1.5">
|
||||
{formatScalar(post.upvotes - post.downvotes)}
|
||||
</p>
|
||||
|
||||
<IonButton
|
||||
className={`m-0 ${styles.iconButton}`}
|
||||
color={post.upvote === false ? "danger" : "medium"}
|
||||
fill="clear"
|
||||
onClick={event => {
|
||||
event.preventDefault();
|
||||
toggleVote(false);
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
slot="icon-only"
|
||||
ios={arrowDownOutline}
|
||||
md={arrowDownSharp}
|
||||
/>
|
||||
</IonButton>
|
||||
|
||||
<IonButton
|
||||
className={`m-0 ml-1.5 ${styles.iconButton}`}
|
||||
color="medium"
|
||||
fill="clear"
|
||||
onClick={event => event.preventDefault()}
|
||||
id={`${id}-options`}
|
||||
>
|
||||
<IonIcon
|
||||
className="text-[1.4rem]"
|
||||
slot="icon-only"
|
||||
ios={ellipsisVerticalOutline}
|
||||
md={ellipsisVerticalSharp}
|
||||
/>
|
||||
</IonButton>
|
||||
<IonPopover trigger={`${id}-options`} triggerAction="click">
|
||||
<IonList>
|
||||
<IonItem>
|
||||
<IonButton
|
||||
className="w-full"
|
||||
fill="clear"
|
||||
onClick={event => {
|
||||
event.preventDefault();
|
||||
sharePost();
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
slot="start"
|
||||
ios={shareSocialOutline}
|
||||
md={shareSocialSharp}
|
||||
/>
|
||||
Share
|
||||
</IonButton>
|
||||
</IonItem>
|
||||
|
||||
<IonItem lines={onDeleted === undefined ? "none" : undefined}>
|
||||
<IonButton
|
||||
className="w-full"
|
||||
color="danger"
|
||||
fill="clear"
|
||||
onClick={event => {
|
||||
event.preventDefault();
|
||||
reportPost();
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
slot="start"
|
||||
ios={warningOutline}
|
||||
md={warningSharp}
|
||||
/>
|
||||
Report
|
||||
</IonButton>
|
||||
</IonItem>
|
||||
|
||||
{onDeleted !== undefined && (
|
||||
<IonItem lines="none">
|
||||
<IonButton
|
||||
className="w-full"
|
||||
color="danger"
|
||||
fill="clear"
|
||||
onClick={event => {
|
||||
event.preventDefault();
|
||||
deletePost();
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
slot="start"
|
||||
ios={trashBinOutline}
|
||||
md={trashBinSharp}
|
||||
/>
|
||||
Delete
|
||||
</IonButton>
|
||||
</IonItem>
|
||||
)}
|
||||
</IonList>
|
||||
</IonPopover>
|
||||
</div>
|
||||
</div>
|
||||
</IonCardContent>
|
||||
</IonCard>
|
||||
);
|
||||
};
|
448
99_references/beacon-main/src/components/scrollable-content.tsx
Normal file
@@ -0,0 +1,448 @@
|
||||
/**
|
||||
* @file Scrollable content component
|
||||
*/
|
||||
|
||||
import {
|
||||
IonContent,
|
||||
IonInfiniteScroll,
|
||||
IonInfiniteScrollContent,
|
||||
IonItem,
|
||||
IonRefresher,
|
||||
IonRefresherContent,
|
||||
IonSpinner,
|
||||
RefresherEventDetail,
|
||||
} from "@ionic/react";
|
||||
import {ReactNode, useEffect, useRef, useState} from "react";
|
||||
import {useMeasure} from "react-use";
|
||||
import {VList, VListHandle} from "virtua";
|
||||
|
||||
import {useEphemeralStore} from "~/lib/stores/ephemeral";
|
||||
import {KeysOfType} from "~/lib/types";
|
||||
|
||||
/**
|
||||
* Scrollable content component props
|
||||
* @param T Content item type
|
||||
*/
|
||||
interface ScrollableContentProps<T extends object> {
|
||||
/**
|
||||
* Singular content item name (e.g.: `comment`)
|
||||
*/
|
||||
contentItemName: string;
|
||||
|
||||
/**
|
||||
* Content items
|
||||
*/
|
||||
contentItems: T[];
|
||||
|
||||
/**
|
||||
* Set content items
|
||||
*/
|
||||
setContentItems: (contentItems: T[]) => void;
|
||||
|
||||
/**
|
||||
* Content item unique identifier key
|
||||
*/
|
||||
contentItemIDKey: KeysOfType<T, string>;
|
||||
|
||||
/**
|
||||
* Content item rank key
|
||||
*/
|
||||
contentItemRankKey: KeysOfType<T, number>;
|
||||
|
||||
/**
|
||||
* Content item viewed event handler
|
||||
* @param contentItem Content item
|
||||
*/
|
||||
onContentItemViewed?: (contentItem: T) => void;
|
||||
|
||||
/**
|
||||
* Content item renderer
|
||||
* @param item Content item
|
||||
* @param index Content item index
|
||||
* @param onLoad Content item load event handler
|
||||
* @returns JSX
|
||||
*/
|
||||
contentItemRenderer: (
|
||||
item: T,
|
||||
index: number,
|
||||
onLoad: () => void,
|
||||
) => ReactNode;
|
||||
|
||||
/**
|
||||
* Fetch content
|
||||
* @param limit Limit
|
||||
* @param cutoffRank Cutoff rank or undefined for no cutoff
|
||||
* @returns Content items
|
||||
*/
|
||||
fetchContent: (limit: number, cutoffRank?: number) => T[] | Promise<T[]>;
|
||||
|
||||
/**
|
||||
* Refresh event handler
|
||||
*/
|
||||
onRefresh?: () => void | Promise<void>;
|
||||
|
||||
/**
|
||||
* Header slot (Inline with content items)
|
||||
*/
|
||||
header?: ReactNode;
|
||||
|
||||
/**
|
||||
* Content range limit (Maximum number of content items to fetch at a time)
|
||||
*/
|
||||
contentRangeLimit?: number;
|
||||
|
||||
/**
|
||||
* Prefetch time coefficient (Multiplied by the estimated time to scroll to the bottom to determine when to prefetch more content)
|
||||
*/
|
||||
prefetchTimeCoefficient?: number;
|
||||
|
||||
/**
|
||||
* Maximum number of scroll metadatas to keep (Fewer metadatas means less accurate velocity calculations but less memory usage)
|
||||
*/
|
||||
maximumScrollMetadatas?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Content index range (start, end)
|
||||
*/
|
||||
type ContentRange = [number, number];
|
||||
|
||||
/**
|
||||
* Scroll metadata
|
||||
*/
|
||||
interface ScrollMetadata {
|
||||
/**
|
||||
* Scroll offset
|
||||
*/
|
||||
offset: number;
|
||||
|
||||
/**
|
||||
* Timestamp
|
||||
*/
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrollable content component
|
||||
* @param props Props
|
||||
* @returns JSX
|
||||
*/
|
||||
export const ScrollableContent = <T extends object>({
|
||||
contentItemName,
|
||||
contentItems,
|
||||
setContentItems,
|
||||
contentItemIDKey,
|
||||
contentItemRankKey,
|
||||
onContentItemViewed,
|
||||
contentItemRenderer,
|
||||
fetchContent: baseFetchContent,
|
||||
onRefresh,
|
||||
header,
|
||||
contentRangeLimit = 9,
|
||||
prefetchTimeCoefficient = 1.2,
|
||||
maximumScrollMetadatas = 10,
|
||||
}: ScrollableContentProps<T>) => {
|
||||
// Constants
|
||||
/**
|
||||
* Default content index range
|
||||
*/
|
||||
const defaultContentRange: ContentRange = [0, contentRangeLimit];
|
||||
|
||||
// Hooks
|
||||
const [outOfContent, setOutOfContent] = useState(false);
|
||||
const [fetching, setFetching] = useState(false);
|
||||
|
||||
const rankCutoff = useRef<number | undefined>(undefined);
|
||||
const visibleContentRange = useRef<ContentRange>([...defaultContentRange]);
|
||||
const fetchLatency = useRef(50);
|
||||
|
||||
const virtualScroller = useRef<VListHandle>(null);
|
||||
const previousScrollMetadatas = useRef<ScrollMetadata[]>([]);
|
||||
const loadedContentItems = useRef(new Set<string>());
|
||||
const viewedContentItems = useRef(new Set<string>());
|
||||
|
||||
const registerRefreshContent = useEphemeralStore(
|
||||
state => state.registerRefreshContent,
|
||||
);
|
||||
|
||||
const unregisterRefreshContent = useEphemeralStore(
|
||||
state => state.unregisterRefreshContent,
|
||||
);
|
||||
|
||||
const [contentRef, {height}] = useMeasure<HTMLIonContentElement>();
|
||||
|
||||
// Effects
|
||||
useEffect(() => {
|
||||
// Fetch initial content items (non-blocking)
|
||||
fetchContent(contentRangeLimit, true);
|
||||
|
||||
// Register the refresh content function
|
||||
registerRefreshContent(refreshContent);
|
||||
|
||||
return () => {
|
||||
// Unregister the refresh content function
|
||||
unregisterRefreshContent(refreshContent);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Methods
|
||||
/**
|
||||
* Fetch content
|
||||
* @param limit Limit
|
||||
* @param reset Whether or not to reset the content items
|
||||
*/
|
||||
const fetchContent = async (limit: number, reset: boolean) => {
|
||||
// Enter critical section
|
||||
if (fetching) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFetching(true);
|
||||
|
||||
// Record the rank cutoff
|
||||
if (reset) {
|
||||
rankCutoff.current = undefined;
|
||||
}
|
||||
|
||||
// Record the start time
|
||||
const startTime = Date.now();
|
||||
|
||||
// Fetch the content
|
||||
const items = await baseFetchContent(limit, rankCutoff.current);
|
||||
|
||||
// Record the end time
|
||||
const endTime = Date.now();
|
||||
|
||||
// Update the state
|
||||
setContentItems(reset ? items : contentItems.concat(items));
|
||||
setOutOfContent(items.length < contentRangeLimit);
|
||||
|
||||
if (items.length > 0) {
|
||||
rankCutoff.current = items.at(-1)![contentItemRankKey] as number;
|
||||
}
|
||||
|
||||
fetchLatency.current = endTime - startTime;
|
||||
|
||||
// Exit critical section
|
||||
setFetching(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* Refresh content, discarding stale content
|
||||
*/
|
||||
const refreshContent = async () => {
|
||||
// Fetch content
|
||||
await fetchContent(contentRangeLimit, true);
|
||||
|
||||
// Reset scroll position
|
||||
virtualScroller.current?.scrollTo(0);
|
||||
};
|
||||
|
||||
/**
|
||||
* Update the viewed content items in the visible range
|
||||
*/
|
||||
const updatedViewedContentItems = async () => {
|
||||
// Check if all content items in range have been loaded
|
||||
const allLoaded = contentItems
|
||||
.slice(visibleContentRange.current[0], visibleContentRange.current[1])
|
||||
.every(contentItem =>
|
||||
loadedContentItems.current.has(contentItem[contentItemIDKey] as string),
|
||||
);
|
||||
|
||||
// Mark all content items in range as viewed
|
||||
if (allLoaded) {
|
||||
const results = [];
|
||||
|
||||
for (
|
||||
let i = visibleContentRange.current[0];
|
||||
i < Math.min(visibleContentRange.current[1], contentItems.length);
|
||||
i++
|
||||
) {
|
||||
const contentItem = contentItems[i]!;
|
||||
|
||||
// Skip if the content item has already been viewed
|
||||
if (
|
||||
viewedContentItems.current.has(
|
||||
contentItem[contentItemIDKey] as string,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Update the viewed content items
|
||||
viewedContentItems.current.add(contentItem[contentItemIDKey] as string);
|
||||
|
||||
results.push(
|
||||
(async () => {
|
||||
// Call the content item viewed event handler
|
||||
await onContentItemViewed?.(contentItem);
|
||||
})(),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(results);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Refresher refresh event handler
|
||||
* @param event Refresher refresh event
|
||||
*/
|
||||
const onRefresherRefresh = async (
|
||||
event: CustomEvent<RefresherEventDetail>,
|
||||
) => {
|
||||
// Refresh content
|
||||
await refreshContent();
|
||||
|
||||
// Call the refresh event handler
|
||||
await onRefresh?.();
|
||||
|
||||
// Complete the refresher
|
||||
event.detail.complete();
|
||||
};
|
||||
|
||||
/**
|
||||
* Scroll event handler
|
||||
* @param offset Offset
|
||||
*/
|
||||
const onScroll = async (offset: number) => {
|
||||
if (virtualScroller.current === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the previous scroll metadata
|
||||
previousScrollMetadatas.current.push({
|
||||
offset,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
if (previousScrollMetadatas.current.length > maximumScrollMetadatas) {
|
||||
previousScrollMetadatas.current = previousScrollMetadatas.current.slice(
|
||||
-maximumScrollMetadatas,
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate the remaining scroll distance (In pixels)
|
||||
const remainingDistance =
|
||||
virtualScroller.current.scrollSize -
|
||||
virtualScroller.current.viewportSize -
|
||||
offset;
|
||||
|
||||
// Calculate the velocity (In pixels/millisecond)
|
||||
let velocity = 0;
|
||||
|
||||
for (let i = 1; i < previousScrollMetadatas.current.length; i++) {
|
||||
const a = previousScrollMetadatas.current[i - 1]!;
|
||||
const b = previousScrollMetadatas.current[i]!;
|
||||
|
||||
velocity +=
|
||||
(b.offset - a.offset) / (b.timestamp.getTime() - a.timestamp.getTime());
|
||||
}
|
||||
|
||||
// Calculate the estimated time to scroll to the bottom (In milliseconds)
|
||||
const remainingTime = remainingDistance / velocity;
|
||||
|
||||
if (
|
||||
!outOfContent &&
|
||||
remainingTime > 0 &&
|
||||
prefetchTimeCoefficient * remainingTime <= fetchLatency.current
|
||||
) {
|
||||
// Fetch content
|
||||
await fetchContent(contentRangeLimit, false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Range change event handler
|
||||
* @param start Start index
|
||||
* @param end End index
|
||||
*/
|
||||
const onRangeChange = async (start: number, end: number) => {
|
||||
// Update the visible content range
|
||||
visibleContentRange.current[0] = start;
|
||||
visibleContentRange.current[1] = end;
|
||||
|
||||
// Update the viewed content items in the visible range
|
||||
await updatedViewedContentItems();
|
||||
};
|
||||
|
||||
/**
|
||||
* Content item load event handler
|
||||
* @param contentItem Content item
|
||||
*/
|
||||
const onContentItemLoaded = async (contentItem: T) => {
|
||||
// Update the loaded content items
|
||||
loadedContentItems.current.add(contentItem[contentItemIDKey] as string);
|
||||
|
||||
// Update the viewed content items in the visible range
|
||||
await updatedViewedContentItems();
|
||||
};
|
||||
|
||||
return (
|
||||
<IonContent scrollY={false} ref={contentRef}>
|
||||
<IonRefresher onIonRefresh={onRefresherRefresh} slot="fixed">
|
||||
<IonRefresherContent />
|
||||
</IonRefresher>
|
||||
|
||||
<div className="flex flex-col h-full overflow-y-auto w-full">
|
||||
{contentItems.length > 0 ? (
|
||||
<VList
|
||||
className="ion-content-scroll-host overflow-auto"
|
||||
onScroll={onScroll}
|
||||
onRangeChange={onRangeChange}
|
||||
style={{
|
||||
height,
|
||||
}}
|
||||
ref={virtualScroller}
|
||||
>
|
||||
{header !== undefined && header}
|
||||
|
||||
{contentItems.map((contentItem, index) =>
|
||||
contentItemRenderer(contentItem, index, () =>
|
||||
onContentItemLoaded(contentItem),
|
||||
),
|
||||
)}
|
||||
|
||||
{contentItems.length > 0 && outOfContent && (
|
||||
<IonItem lines="none">
|
||||
<p className="mt-6 mb-8 text-center text-xl w-full">
|
||||
No more {contentItemName}s to see 😢
|
||||
<br />
|
||||
<button
|
||||
aria-label={`Refresh all ${contentItemName}s`}
|
||||
onClick={refreshContent}
|
||||
>
|
||||
<u>Refresh</u>
|
||||
</button>{" "}
|
||||
the page to see new {contentItemName}s!
|
||||
</p>
|
||||
</IonItem>
|
||||
)}
|
||||
|
||||
{contentItems.length > 0 && fetching && (
|
||||
<IonInfiniteScroll>
|
||||
<IonInfiniteScrollContent />
|
||||
</IonInfiniteScroll>
|
||||
)}
|
||||
</VList>
|
||||
) : (
|
||||
<>
|
||||
{header !== undefined && header}
|
||||
|
||||
<div className="flex flex-col flex-1 items-center justify-center">
|
||||
{fetching ? (
|
||||
<IonSpinner className="h-16 w-16" color="primary" />
|
||||
) : (
|
||||
<p className="my-4 text-center text-xl">
|
||||
No {contentItemName}s to see 😢
|
||||
<br />
|
||||
Make a new {contentItemName} to see it here!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</IonContent>
|
||||
);
|
||||
};
|
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* @file Supplemental error component (e.g.: for when a form field doesn't have native support for showing errors)
|
||||
*/
|
||||
|
||||
import {FC} from "react";
|
||||
|
||||
/**
|
||||
* Supplemental error component props
|
||||
*/
|
||||
interface SupplementalErrorProps {
|
||||
/**
|
||||
* Error message
|
||||
*/
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Supplemental error component (e.g.: for when a form field doesn't have native support for showing errors)
|
||||
* @returns JSX
|
||||
*/
|
||||
export const SupplementalError: FC<SupplementalErrorProps> = ({error}) =>
|
||||
error !== undefined && (
|
||||
<div className="border-[var(--ion-color-danger)] border-solid border-t-1 w-full z-10">
|
||||
<p className="!text-[12px] pt-1.25 text-[var(--ion-color-danger)]">
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
);
|
114
99_references/beacon-main/src/components/swipeable-item.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* @file Swipeable item component
|
||||
*/
|
||||
|
||||
import {
|
||||
IonItemOptions,
|
||||
IonItemSliding,
|
||||
ItemSlidingCustomEvent,
|
||||
} from "@ionic/react";
|
||||
import {FC, HTMLAttributes, ReactNode, useRef} from "react";
|
||||
|
||||
import {usePersistentStore} from "~/lib/stores/persistent";
|
||||
|
||||
/**
|
||||
* Swipeable item component props
|
||||
*/
|
||||
interface SwipeableItemProps extends HTMLAttributes<HTMLIonItemSlidingElement> {
|
||||
/**
|
||||
* Item content
|
||||
*/
|
||||
children: ReactNode;
|
||||
|
||||
/**
|
||||
* Start option (Left side)
|
||||
*/
|
||||
startOption?: ReactNode;
|
||||
|
||||
/**
|
||||
* End option (Right side)
|
||||
*/
|
||||
endOption?: ReactNode;
|
||||
|
||||
/**
|
||||
* Start action
|
||||
*/
|
||||
startAction?: () => void | Promise<void>;
|
||||
|
||||
/**
|
||||
* End action
|
||||
*/
|
||||
endAction?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Swipeable item component
|
||||
* @param props Swipeable item component props
|
||||
* @returns JSX
|
||||
*/
|
||||
export const SwipeableItem: FC<SwipeableItemProps> = ({
|
||||
children,
|
||||
startOption,
|
||||
endOption,
|
||||
startAction,
|
||||
endAction,
|
||||
...props
|
||||
}) => {
|
||||
// Hooks
|
||||
const previousRatio = useRef<number>();
|
||||
|
||||
const useSlidingActions = usePersistentStore(
|
||||
state => state.useSlidingActions,
|
||||
);
|
||||
|
||||
// Methods
|
||||
/**
|
||||
* Item sliding swipe event handler
|
||||
* @param event Item sliding custom event
|
||||
*/
|
||||
const onItemSlidingSwipe = async (event: ItemSlidingCustomEvent) => {
|
||||
if (!useSlidingActions) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cast the detail
|
||||
const detail = event.detail as {
|
||||
amount: number;
|
||||
ratio: number;
|
||||
};
|
||||
|
||||
// Upvote (Swiped left)
|
||||
if (
|
||||
previousRatio.current !== undefined &&
|
||||
detail.ratio <= -1 &&
|
||||
previousRatio.current < detail.ratio
|
||||
) {
|
||||
await startAction?.();
|
||||
await event.target.closeOpened();
|
||||
}
|
||||
// Downvote (Swiped right)
|
||||
else if (
|
||||
previousRatio.current !== undefined &&
|
||||
detail.ratio >= 1 &&
|
||||
previousRatio.current > detail.ratio
|
||||
) {
|
||||
await endAction?.();
|
||||
await event.target.closeOpened();
|
||||
}
|
||||
|
||||
// Store the ratio
|
||||
previousRatio.current = detail.ratio;
|
||||
};
|
||||
|
||||
return (
|
||||
<IonItemSliding {...props} onIonDrag={onItemSlidingSwipe}>
|
||||
{useSlidingActions && (
|
||||
<IonItemOptions side="start">{startOption}</IonItemOptions>
|
||||
)}
|
||||
{children}
|
||||
{useSlidingActions && (
|
||||
<IonItemOptions side="end">{endOption}</IonItemOptions>
|
||||
)}
|
||||
</IonItemSliding>
|
||||
);
|
||||
};
|
78
99_references/beacon-main/src/components/text-fill.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* @file Text fill component
|
||||
*/
|
||||
|
||||
import {FC, HTMLAttributes, ReactNode} from "react";
|
||||
import {useMeasure} from "react-use";
|
||||
|
||||
/**
|
||||
* Sizer font size
|
||||
*/
|
||||
const SIZER_FONT_SIZE = 10;
|
||||
|
||||
/**
|
||||
* Text fill component props
|
||||
*/
|
||||
interface TextFillProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* Children
|
||||
*/
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Text fill component
|
||||
* @param props Props
|
||||
* @returns JSX
|
||||
*/
|
||||
export const TextFill: FC<TextFillProps> = ({children, ...props}) => {
|
||||
// Hooks
|
||||
const [containerRef, {height: containerHeight, width: containerWidth}] =
|
||||
useMeasure<HTMLParagraphElement>();
|
||||
|
||||
const [sizerRef, {height: sizerHeight, width: sizerWidth}] =
|
||||
useMeasure<HTMLParagraphElement>();
|
||||
|
||||
// Variables
|
||||
let textFontSize = SIZER_FONT_SIZE;
|
||||
|
||||
if (
|
||||
!Number.isNaN(containerHeight) &&
|
||||
!Number.isNaN(containerWidth) &&
|
||||
!Number.isNaN(sizerHeight) &&
|
||||
!Number.isNaN(sizerWidth)
|
||||
) {
|
||||
textFontSize *= Math.min(
|
||||
containerHeight / sizerHeight,
|
||||
containerWidth / sizerWidth,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={`flex flex-row h-full items-center justify-center relative w-full ${
|
||||
props.className ?? ""
|
||||
}`}
|
||||
ref={containerRef}
|
||||
>
|
||||
<p
|
||||
className="!m-0 !p-0 absolute invisible text-nowrap whitespace-pre"
|
||||
style={{
|
||||
fontSize: `${SIZER_FONT_SIZE}px`,
|
||||
}}
|
||||
ref={sizerRef}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
<p
|
||||
className="!m-0 !p-0 text-nowrap whitespace-pre"
|
||||
style={{
|
||||
fontSize: `${textFontSize}px`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
58
99_references/beacon-main/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Import meta environment variables
|
||||
*/
|
||||
interface ImportMetaEnv {
|
||||
/**
|
||||
* hCaptcha site key
|
||||
*/
|
||||
readonly VITE_HCAPTCHA_SITE_KEY: string;
|
||||
|
||||
/**
|
||||
* The Supabase API URL
|
||||
*/
|
||||
readonly VITE_SUPABSE_URL: string;
|
||||
|
||||
/**
|
||||
* The Supabase API key
|
||||
*/
|
||||
readonly VITE_SUPABASE_ANON_KEY: string;
|
||||
|
||||
/**
|
||||
* The Sentry DSN
|
||||
*/
|
||||
readonly VITE_SENTRY_DSN?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import meta object
|
||||
*/
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
/**
|
||||
* PWA before install prompt event
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/BeforeInstallPromptEvent
|
||||
*/
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
/**
|
||||
* An array of string items containing the platforms on which the event was dispatched. This is provided for user agents that want to present a choice of versions to the user such as, for example, "web" or "play" which would allow the user to choose between a web version or an Android version.
|
||||
*/
|
||||
readonly platforms: string[];
|
||||
|
||||
/**
|
||||
* A Promise that resolves to an object describing the user's choice when they were prompted to install the app.
|
||||
*/
|
||||
readonly userChoice: Promise<{
|
||||
outcome: "accepted" | "dismissed";
|
||||
platform: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Show a prompt asking the user if they want to install the app. This method returns a Promise that resolves to an object describing the user's choice when they were prompted to install the app.
|
||||
*/
|
||||
prompt(): Promise<{
|
||||
outcome: "accepted" | "dismissed";
|
||||
platform: string;
|
||||
}>;
|
||||
}
|
90
99_references/beacon-main/src/main.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* @file App entrypoint
|
||||
*/
|
||||
|
||||
// Styles
|
||||
import "@ionic/react/css/core.css";
|
||||
import "@ionic/react/css/normalize.css";
|
||||
import "@ionic/react/css/structure.css";
|
||||
import "@ionic/react/css/typography.css";
|
||||
import "@ionic/react/css/padding.css";
|
||||
import "@ionic/react/css/float-elements.css";
|
||||
import "@ionic/react/css/text-alignment.css";
|
||||
import "@ionic/react/css/text-transformation.css";
|
||||
import "@ionic/react/css/flex-utils.css";
|
||||
import "@ionic/react/css/display.css";
|
||||
import "@unocss/reset/tailwind.css";
|
||||
import "virtual:uno.css";
|
||||
import "~/theme.css";
|
||||
|
||||
import {IonApp, isPlatform, setupIonicReact} from "@ionic/react";
|
||||
import {IonReactRouter} from "@ionic/react-router";
|
||||
import {
|
||||
browserTracingIntegration,
|
||||
init as sentryInit,
|
||||
setTags,
|
||||
} from "@sentry/browser";
|
||||
import {SupabaseIntegration} from "@supabase/sentry-js-integration";
|
||||
import {SupabaseClient} from "@supabase/supabase-js";
|
||||
import {StrictMode} from "react";
|
||||
import {createRoot} from "react-dom/client";
|
||||
|
||||
import {App} from "~/app";
|
||||
import {
|
||||
GIT_BRANCH,
|
||||
GIT_COMMIT,
|
||||
SENTRY_DSN,
|
||||
SUPABASE_URL,
|
||||
VERSION,
|
||||
} from "~/lib/vars";
|
||||
|
||||
// Setup Sentry
|
||||
if (SENTRY_DSN !== undefined) {
|
||||
sentryInit({
|
||||
dsn: SENTRY_DSN,
|
||||
environment: import.meta.env.MODE,
|
||||
tracesSampleRate: 1,
|
||||
integrations: [
|
||||
new SupabaseIntegration(SupabaseClient, {
|
||||
tracing: true,
|
||||
breadcrumbs: true,
|
||||
errors: true,
|
||||
}),
|
||||
browserTracingIntegration({
|
||||
/**
|
||||
* Callback to determine if a request should create a span
|
||||
* @param url Request URL
|
||||
* @returns Whether a span should be created
|
||||
*/
|
||||
shouldCreateSpanForRequest: url =>
|
||||
!url.startsWith(`${SUPABASE_URL}/rest`),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
setTags({
|
||||
GIT_BRANCH,
|
||||
GIT_COMMIT,
|
||||
VERSION,
|
||||
});
|
||||
}
|
||||
|
||||
// Setup React
|
||||
const container = document.getElementById("root");
|
||||
const root = createRoot(container!);
|
||||
|
||||
// Setup Ionic
|
||||
setupIonicReact({
|
||||
animated: true,
|
||||
mode: isPlatform("ios") ? "ios" : "md",
|
||||
});
|
||||
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<IonApp>
|
||||
<IonReactRouter>
|
||||
<App />
|
||||
</IonReactRouter>
|
||||
</IonApp>
|
||||
</StrictMode>,
|
||||
);
|
144
99_references/beacon-main/src/pages/auth/step1.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* @file Auth step 1 page
|
||||
*/
|
||||
|
||||
import HCaptcha from "@hcaptcha/react-hcaptcha";
|
||||
import {zodResolver} from "@hookform/resolvers/zod";
|
||||
import {IonButton, IonIcon, IonInput} from "@ionic/react";
|
||||
import {paperPlaneOutline, paperPlaneSharp} from "ionicons/icons";
|
||||
import {FC, useRef} from "react";
|
||||
import {Controller, useForm} from "react-hook-form";
|
||||
import {useHistory} from "react-router-dom";
|
||||
import {z} from "zod";
|
||||
|
||||
import {AuthContainer} from "~/components/auth-container";
|
||||
import {SupplementalError} from "~/components/supplemental-error";
|
||||
import {useEphemeralStore} from "~/lib/stores/ephemeral";
|
||||
import {usePersistentStore} from "~/lib/stores/persistent";
|
||||
import {client} from "~/lib/supabase";
|
||||
import {Theme, UserMetadata} from "~/lib/types";
|
||||
import {HCAPTCHA_SITE_KEY} from "~/lib/vars";
|
||||
|
||||
/**
|
||||
* Form schema
|
||||
*/
|
||||
const formSchema = z.object({
|
||||
email: z.string().email(),
|
||||
captchaToken: z
|
||||
.string({
|
||||
// eslint-disable-next-line camelcase
|
||||
required_error: "Please complete the challenge",
|
||||
})
|
||||
.min(1, "Please complete the challenge"),
|
||||
});
|
||||
|
||||
/**
|
||||
* Form schema type
|
||||
*/
|
||||
type FormSchema = z.infer<typeof formSchema>;
|
||||
|
||||
/**
|
||||
* Auth step 1 component
|
||||
* @returns JSX
|
||||
*/
|
||||
export const Step1: FC = () => {
|
||||
// Hooks
|
||||
const captcha = useRef<HCaptcha>(null);
|
||||
const setEmail = useEphemeralStore(state => state.setEmail);
|
||||
const theme = usePersistentStore(state => state.theme);
|
||||
const history = useHistory();
|
||||
|
||||
const {control, handleSubmit, reset} = useForm<FormSchema>({
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
|
||||
// Methods
|
||||
/**
|
||||
* Form submit handler
|
||||
* @param form Form data
|
||||
*/
|
||||
const onSubmit = async (form: FormSchema) => {
|
||||
// Store the email for later
|
||||
setEmail(form.email);
|
||||
|
||||
// Begin the log in process
|
||||
const {error} = await client.auth.signInWithOtp({
|
||||
email: form.email,
|
||||
options: {
|
||||
captchaToken: form.captchaToken,
|
||||
emailRedirectTo: new URL("/auth/2", window.location.origin).toString(),
|
||||
data: {
|
||||
acceptedTerms: false,
|
||||
} as UserMetadata,
|
||||
},
|
||||
});
|
||||
|
||||
// Handle the error
|
||||
if (error !== null) {
|
||||
// Partially reset the form
|
||||
reset({
|
||||
email: form.email,
|
||||
});
|
||||
|
||||
// Reset the captcha
|
||||
captcha.current?.resetCaptcha();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Go to the next step
|
||||
history.push("/auth/2");
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContainer back={true}>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="email"
|
||||
render={({
|
||||
field: {onChange, onBlur, value},
|
||||
fieldState: {error, isTouched, invalid},
|
||||
}) => (
|
||||
<IonInput
|
||||
className={`min-w-64 ${(invalid || isTouched) && "ion-touched"} ${
|
||||
invalid && "ion-invalid"
|
||||
} ${!invalid && isTouched && "ion-valid"}`}
|
||||
errorText={error?.message}
|
||||
fill="outline"
|
||||
label="Email"
|
||||
labelPlacement="floating"
|
||||
onIonBlur={onBlur}
|
||||
onIonInput={onChange}
|
||||
type="email"
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="captchaToken"
|
||||
render={({field: {onChange}, fieldState: {error}}) => (
|
||||
<div className="py-4">
|
||||
<HCaptcha
|
||||
onVerify={token => onChange(token)}
|
||||
ref={captcha}
|
||||
sitekey={HCAPTCHA_SITE_KEY}
|
||||
theme={theme === Theme.DARK ? "dark" : "light"}
|
||||
/>
|
||||
<SupplementalError error={error?.message} />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<IonButton
|
||||
className="mb-0 mt-4 mx-0 overflow-hidden rounded-lg w-full"
|
||||
expand="full"
|
||||
type="submit"
|
||||
>
|
||||
<IonIcon slot="start" ios={paperPlaneOutline} md={paperPlaneSharp} />
|
||||
Send Login Code
|
||||
</IonButton>
|
||||
</form>
|
||||
</AuthContainer>
|
||||
);
|
||||
};
|
138
99_references/beacon-main/src/pages/auth/step2.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* @file Auth step 2 page
|
||||
*/
|
||||
|
||||
import {zodResolver} from "@hookform/resolvers/zod";
|
||||
import {IonButton, IonIcon, IonInput} from "@ionic/react";
|
||||
import {checkmarkOutline, checkmarkSharp} from "ionicons/icons";
|
||||
import {FC} from "react";
|
||||
import {Controller, useForm} from "react-hook-form";
|
||||
import {useHistory} from "react-router-dom";
|
||||
import {z} from "zod";
|
||||
|
||||
import {AuthContainer} from "~/components/auth-container";
|
||||
import {useEphemeralStore} from "~/lib/stores/ephemeral";
|
||||
import {client} from "~/lib/supabase";
|
||||
import {UserMetadata} from "~/lib/types";
|
||||
|
||||
/**
|
||||
* Failed to login message metadata symbol
|
||||
*/
|
||||
const FAILED_TO_LOGIN_MESSAGE_METADATA_SYMBOL = Symbol("auth.failed-to-login");
|
||||
|
||||
/**
|
||||
* Form schema
|
||||
*/
|
||||
const formSchema = z.object({
|
||||
code: z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine(value => /^\d+$/.test(value), {
|
||||
message: "Invalid code",
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Form schema type
|
||||
*/
|
||||
type FormSchema = z.infer<typeof formSchema>;
|
||||
|
||||
/**
|
||||
* Auth step 2 component
|
||||
* @returns JSX
|
||||
*/
|
||||
export const Step2: FC = () => {
|
||||
// Hooks
|
||||
const email = useEphemeralStore(state => state.email);
|
||||
const setMessage = useEphemeralStore(state => state.setMessage);
|
||||
const history = useHistory();
|
||||
|
||||
const {control, handleSubmit, reset} = useForm<FormSchema>({
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
|
||||
// Methods
|
||||
/**
|
||||
* Verify the code
|
||||
* @param code Code
|
||||
*/
|
||||
const verify = async (code: string) => {
|
||||
// Log in
|
||||
const {data, error} = await client.auth.verifyOtp({
|
||||
email: email!,
|
||||
token: code,
|
||||
type: "email",
|
||||
});
|
||||
|
||||
// Handle the error
|
||||
if (error !== null) {
|
||||
// Reset the form
|
||||
reset();
|
||||
|
||||
// Display the message
|
||||
setMessage({
|
||||
symbol: FAILED_TO_LOGIN_MESSAGE_METADATA_SYMBOL,
|
||||
name: "Failed to log in",
|
||||
description: error.message,
|
||||
});
|
||||
|
||||
// Go back to the previous step
|
||||
history.goBack();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the user metadata
|
||||
const userMetadata = data!.user!.user_metadata as UserMetadata;
|
||||
|
||||
// Go to the terms and conditions if the user hasn't accepted them
|
||||
history.push(userMetadata.acceptedTerms ? "/nearby" : "/auth/3");
|
||||
};
|
||||
|
||||
/**
|
||||
* Form submit handler
|
||||
* @param data Form data
|
||||
* @returns Nothing
|
||||
*/
|
||||
const onSubmit = async (data: FormSchema) => await verify(data.code);
|
||||
|
||||
return (
|
||||
<AuthContainer back={true}>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="code"
|
||||
render={({
|
||||
field: {onChange, onBlur, value},
|
||||
fieldState: {error, isTouched, invalid},
|
||||
}) => (
|
||||
<IonInput
|
||||
className={`min-w-64 mb-4 ${
|
||||
(invalid || isTouched) && "ion-touched"
|
||||
} ${invalid && "ion-invalid"} ${
|
||||
!invalid && isTouched && "ion-valid"
|
||||
}`}
|
||||
errorText={error?.message}
|
||||
fill="outline"
|
||||
label="Code"
|
||||
labelPlacement="floating"
|
||||
onIonBlur={onBlur}
|
||||
onIonChange={onChange}
|
||||
type="text"
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<IonButton
|
||||
className="mb-0 mt-4 mx-0 overflow-hidden rounded-lg w-full"
|
||||
expand="full"
|
||||
type="submit"
|
||||
>
|
||||
<IonIcon slot="start" ios={checkmarkOutline} md={checkmarkSharp} />
|
||||
Verify Code
|
||||
</IonButton>
|
||||
</form>
|
||||
</AuthContainer>
|
||||
);
|
||||
};
|
115
99_references/beacon-main/src/pages/auth/step3.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* @file Auth step 3 page
|
||||
*/
|
||||
|
||||
import {IonButton, IonIcon, IonRouterLink} from "@ionic/react";
|
||||
import {
|
||||
checkmarkOutline,
|
||||
checkmarkSharp,
|
||||
closeOutline,
|
||||
closeSharp,
|
||||
} from "ionicons/icons";
|
||||
import {FC, FormEvent} from "react";
|
||||
import {useHistory} from "react-router-dom";
|
||||
|
||||
import {AuthContainer} from "~/components/auth-container";
|
||||
import {client} from "~/lib/supabase";
|
||||
import {UserMetadata} from "~/lib/types";
|
||||
|
||||
/**
|
||||
* Auth step 3 component
|
||||
* @returns JSX
|
||||
*/
|
||||
export const Step3: FC = () => {
|
||||
// Hooks
|
||||
const history = useHistory();
|
||||
|
||||
// Methods
|
||||
/**
|
||||
* Form submit handler
|
||||
* @param event Form event
|
||||
* @returns Nothing
|
||||
*/
|
||||
const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
// Update the user's terms and conditions agreement
|
||||
const {error} = await client.auth.updateUser({
|
||||
data: {
|
||||
acceptedTerms: true,
|
||||
} as UserMetadata,
|
||||
});
|
||||
|
||||
// Handle the error
|
||||
if (error !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Go to nearby
|
||||
history.push("/nearby");
|
||||
};
|
||||
|
||||
/**
|
||||
* Reject button click handler
|
||||
*/
|
||||
const reject = () => {
|
||||
// Log out
|
||||
client.auth.signOut();
|
||||
|
||||
// Go to home
|
||||
history.push("/");
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContainer back={true}>
|
||||
<form onSubmit={onSubmit}>
|
||||
<p>
|
||||
You agree to the{" "}
|
||||
<IonRouterLink
|
||||
className="font-bold underline"
|
||||
routerLink="/terms-and-conditions"
|
||||
>
|
||||
terms and conditions
|
||||
</IonRouterLink>{" "}
|
||||
and{" "}
|
||||
<IonRouterLink
|
||||
className="font-bold underline"
|
||||
routerLink="/privacy-policy"
|
||||
>
|
||||
privacy policy
|
||||
</IonRouterLink>{" "}
|
||||
of this app. This includes (but is not limited to):
|
||||
</p>
|
||||
|
||||
<ul className="list-disc ml-4 my-1">
|
||||
<li>
|
||||
We reserve the right to remove any content and ban any user at any
|
||||
time at our own discretion.
|
||||
</li>
|
||||
<li>Violating the terms and conditions will result in a ban.</li>
|
||||
<li>
|
||||
We collect your geolocation data to filter content to your location.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<IonButton
|
||||
className="mb-0 mt-4 mx-0 overflow-hidden rounded-lg w-full"
|
||||
expand="full"
|
||||
type="submit"
|
||||
>
|
||||
<IonIcon slot="start" ios={checkmarkOutline} md={checkmarkSharp} />
|
||||
Agreee
|
||||
</IonButton>
|
||||
<IonButton
|
||||
className="mb-0 mt-4 mx-0 overflow-hidden rounded-lg w-full"
|
||||
color="danger"
|
||||
expand="full"
|
||||
onClick={reject}
|
||||
>
|
||||
<IonIcon slot="start" ios={closeOutline} md={closeSharp} />
|
||||
Reject
|
||||
</IonButton>
|
||||
</form>
|
||||
</AuthContainer>
|
||||
);
|
||||
};
|
54
99_references/beacon-main/src/pages/error.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* @file Error page
|
||||
*/
|
||||
|
||||
import {IonButton, IonContent, IonIcon, IonPage} from "@ionic/react";
|
||||
import {homeOutline, homeSharp} from "ionicons/icons";
|
||||
import {FC} from "react";
|
||||
|
||||
import {Header} from "~/components/header";
|
||||
|
||||
/**
|
||||
* Error page props
|
||||
*/
|
||||
interface ErrorProps {
|
||||
/**
|
||||
* Error name
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* Error description
|
||||
*/
|
||||
description: string;
|
||||
|
||||
/**
|
||||
* Whether or not to show the home button
|
||||
*/
|
||||
homeButton: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error page
|
||||
* @returns JSX
|
||||
*/
|
||||
export const Error: FC<ErrorProps> = ({name, description, homeButton}) => {
|
||||
return (
|
||||
<IonPage>
|
||||
<Header />
|
||||
|
||||
<IonContent>
|
||||
<div className="flex flex-col h-full items-center justify-center text-center w-full">
|
||||
<h1 className="text-6xl">{name}</h1>
|
||||
<p className="my-4 text-xl">{description}</p>
|
||||
{homeButton && (
|
||||
<IonButton routerLink="/">
|
||||
<IonIcon slot="start" ios={homeOutline} md={homeSharp} />
|
||||
Take me home
|
||||
</IonButton>
|
||||
)}
|
||||
</div>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
3
99_references/beacon-main/src/pages/index.module.css
Normal file
@@ -0,0 +1,3 @@
|
||||
.snapContent::part(scroll) {
|
||||
@apply <md:snap-y <md:snap-mandatory;
|
||||
}
|
221
99_references/beacon-main/src/pages/index.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* @file Index page
|
||||
*/
|
||||
|
||||
import {IonButton, IonContent, IonIcon, IonPage} from "@ionic/react";
|
||||
import {
|
||||
documentTextOutline,
|
||||
documentTextSharp,
|
||||
helpCircleOutline,
|
||||
helpCircleSharp,
|
||||
navigateCircleOutline,
|
||||
navigateCircleSharp,
|
||||
shieldOutline,
|
||||
shieldSharp,
|
||||
} from "ionicons/icons";
|
||||
import {FC, useEffect, useRef} from "react";
|
||||
import {useLocation} from "react-router-dom";
|
||||
import {useMeasure} from "react-use";
|
||||
|
||||
import {Header} from "~/components/header";
|
||||
import {usePersistentStore} from "~/lib/stores/persistent";
|
||||
import {Theme} from "~/lib/types";
|
||||
import styles from "~/pages/index.module.css";
|
||||
|
||||
/**
|
||||
* Number of frames
|
||||
*/
|
||||
const FRAME_COUNT = 6;
|
||||
|
||||
/**
|
||||
* Index page
|
||||
* @returns JSX
|
||||
*/
|
||||
export const Index: FC = () => {
|
||||
// Hooks
|
||||
const theme = usePersistentStore(state => state.theme);
|
||||
const [containerRef, {height, width}] = useMeasure();
|
||||
const contentRef = useRef<HTMLIonContentElement>(null);
|
||||
const location = useLocation();
|
||||
|
||||
// Effects
|
||||
useEffect(() => {
|
||||
setTimeout(async () => {
|
||||
if (contentRef.current === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Scroll back to the top
|
||||
await contentRef.current.scrollToTop(0);
|
||||
}, 50);
|
||||
}, [location.pathname]);
|
||||
|
||||
return (
|
||||
<IonPage ref={containerRef}>
|
||||
<Header />
|
||||
|
||||
<IonContent
|
||||
className={styles.snapContent}
|
||||
style={{
|
||||
"--window-height": `${height}px`,
|
||||
}}
|
||||
ref={contentRef}
|
||||
>
|
||||
{/* Background */}
|
||||
<div
|
||||
className="-z-1 absolute left-0 top-0 w-full"
|
||||
style={{
|
||||
height: `calc(100vh * ${FRAME_COUNT})`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`absolute bg-gradient-to-b w-full h-full ${
|
||||
theme === Theme.DARK
|
||||
? "from-black to-primary-500"
|
||||
: "from-white to-primary-600"
|
||||
}`}
|
||||
/>
|
||||
|
||||
<svg
|
||||
className="absolute h-full w-full"
|
||||
viewBox={`0 0 ${width} ${FRAME_COUNT * height}`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<filter id="noiseFilter">
|
||||
<feTurbulence
|
||||
type="fractalNoise"
|
||||
baseFrequency="10"
|
||||
numOctaves="1"
|
||||
stitchTiles="stitch"
|
||||
/>
|
||||
</filter>
|
||||
|
||||
<rect
|
||||
opacity={theme === Theme.DARK ? "0.2" : "0.4"}
|
||||
width="100%"
|
||||
height="100%"
|
||||
filter="url(#noiseFilter)"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* First frame */}
|
||||
<div className="animate-fade-in animate-ease-in-out flex flex-col h-[var(--window-height)] items-center justify-center px-6 text-center w-full snap-center">
|
||||
<div className="my-2">
|
||||
<h1 className="mb-1 text-7xl font-bold tracking-[0.2em]">BEACON</h1>
|
||||
<h2 className="mt-1 text-xl">A location-based social network.</h2>
|
||||
</div>
|
||||
|
||||
<IonButton
|
||||
className="my-2"
|
||||
color="primary"
|
||||
fill="outline"
|
||||
routerLink="/auth/1"
|
||||
>
|
||||
<IonIcon
|
||||
slot="start"
|
||||
ios={navigateCircleOutline}
|
||||
md={navigateCircleSharp}
|
||||
/>
|
||||
Get Started
|
||||
</IonButton>
|
||||
</div>
|
||||
|
||||
{/* Second frame */}
|
||||
<div className="flex flex-col h-[var(--window-height)] items-center justify-center px-6 text-center w-full snap-center">
|
||||
<h2 className="mb-1 text-4xl">1. Create A Post</h2>
|
||||
<h3 className="mt-1 text-m">
|
||||
Every post can only be seen by other users nearby - you decide how
|
||||
close they need to be.
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Third frame */}
|
||||
<div className="flex flex-col h-[var(--window-height)] items-center justify-center px-6 text-center w-full snap-center">
|
||||
<h2 className="mb-1 text-4xl">2. View Other Posts</h2>
|
||||
<h3 className="mt-1 text-m">
|
||||
View nearby posts and interact with them by commenting, upvoting,
|
||||
and downvoting them.
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Fourth frame */}
|
||||
<div className="flex flex-col h-[var(--window-height)] items-center justify-center px-6 text-center w-full snap-center">
|
||||
<h2 className="mb-1 text-4xl">3. Remain Anonymous</h2>
|
||||
<h3 className="mt-1 text-m">
|
||||
You can choose to remain anonymous when creating posts and
|
||||
commenting on other posts.
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Fifth frame */}
|
||||
<div className="flex flex-col h-[var(--window-height)] items-center justify-center px-6 text-center w-full snap-center">
|
||||
<div className="my-2">
|
||||
<h2 className="text-4xl">
|
||||
So what are you waiting for? Get started now!
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<IonButton
|
||||
className="my-2"
|
||||
color="dark"
|
||||
fill="outline"
|
||||
routerLink="/auth/1"
|
||||
>
|
||||
<IonIcon
|
||||
slot="start"
|
||||
ios={navigateCircleOutline}
|
||||
md={navigateCircleSharp}
|
||||
/>
|
||||
Get Started
|
||||
</IonButton>
|
||||
</div>
|
||||
|
||||
{/* Sixth frame */}
|
||||
<div className="flex flex-col h-[var(--window-height)] items-center justify-center px-6 text-center w-full snap-center">
|
||||
<div className="my-2">
|
||||
<h2 className="text-4xl">Other Things</h2>
|
||||
</div>
|
||||
|
||||
<IonButton
|
||||
className="my-2"
|
||||
color="dark"
|
||||
fill="outline"
|
||||
routerLink="/faq"
|
||||
>
|
||||
<IonIcon
|
||||
slot="start"
|
||||
ios={helpCircleOutline}
|
||||
md={helpCircleSharp}
|
||||
/>
|
||||
Frequently Asked Questions
|
||||
</IonButton>
|
||||
|
||||
<IonButton
|
||||
className="my-2"
|
||||
color="dark"
|
||||
fill="outline"
|
||||
routerLink="/terms-and-conditions"
|
||||
>
|
||||
<IonIcon
|
||||
slot="start"
|
||||
ios={documentTextOutline}
|
||||
md={documentTextSharp}
|
||||
/>
|
||||
Terms and Conditions
|
||||
</IonButton>
|
||||
|
||||
<IonButton
|
||||
className="my-2"
|
||||
color="dark"
|
||||
fill="outline"
|
||||
routerLink="/privacy-policy"
|
||||
>
|
||||
<IonIcon slot="start" ios={shieldOutline} md={shieldSharp} />
|
||||
Privacy Policy
|
||||
</IonButton>
|
||||
</div>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
72
99_references/beacon-main/src/pages/markdown.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* @file Markdown page
|
||||
*/
|
||||
|
||||
import {
|
||||
IonBackButton,
|
||||
IonButtons,
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonPage,
|
||||
IonTitle,
|
||||
IonToolbar,
|
||||
} from "@ionic/react";
|
||||
import {FC} from "react";
|
||||
import {useAsync} from "react-use";
|
||||
|
||||
import {Markdown as MarkdownRenderer} from "~/components/markdown";
|
||||
|
||||
/**
|
||||
* Markdown page props
|
||||
*/
|
||||
interface MarkdownProps {
|
||||
/**
|
||||
* Page title
|
||||
*/
|
||||
title: string;
|
||||
|
||||
/**
|
||||
* Markdown URL (relative or absolute)
|
||||
*/
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown page
|
||||
* @returns JSX
|
||||
*/
|
||||
export const Markdown: FC<MarkdownProps> = ({title, url}) => {
|
||||
// Hooks
|
||||
const markdown = useAsync(async () => {
|
||||
// Fetch the markdown
|
||||
const response = await fetch(url);
|
||||
|
||||
// Convert the response to text
|
||||
return await response.text();
|
||||
});
|
||||
|
||||
return (
|
||||
<IonPage>
|
||||
<IonHeader className="ion-no-border">
|
||||
<IonToolbar>
|
||||
<IonButtons slot="start">
|
||||
<IonBackButton defaultHref="/" />
|
||||
</IonButtons>
|
||||
|
||||
<IonTitle>{title}</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<IonContent>
|
||||
<MarkdownRenderer
|
||||
className="break-anywhere h-full overflow-auto p-2 text-wrap w-full"
|
||||
raw={
|
||||
markdown.loading
|
||||
? "Loading..."
|
||||
: markdown.value ?? `Failed to load ${title}.`
|
||||
}
|
||||
/>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
207
99_references/beacon-main/src/pages/nearby.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* @file Nearby page
|
||||
*/
|
||||
/* eslint-disable unicorn/no-null */
|
||||
/* eslint-disable camelcase */
|
||||
|
||||
import {
|
||||
IonButtons,
|
||||
IonFab,
|
||||
IonFabButton,
|
||||
IonHeader,
|
||||
IonIcon,
|
||||
IonItem,
|
||||
IonItemOption,
|
||||
IonMenuButton,
|
||||
IonPage,
|
||||
IonTitle,
|
||||
IonToolbar,
|
||||
} from "@ionic/react";
|
||||
import {
|
||||
addOutline,
|
||||
addSharp,
|
||||
arrowDownOutline,
|
||||
arrowDownSharp,
|
||||
arrowUpOutline,
|
||||
arrowUpSharp,
|
||||
} from "ionicons/icons";
|
||||
import {FC, useState} from "react";
|
||||
import {useHistory} from "react-router-dom";
|
||||
import {useMeasure} from "react-use";
|
||||
|
||||
import {PostCard} from "~/components/post-card";
|
||||
import {ScrollableContent} from "~/components/scrollable-content";
|
||||
import {SwipeableItem} from "~/components/swipeable-item";
|
||||
import {insertView, toggleVote} from "~/lib/entities";
|
||||
import {useEphemeralStore} from "~/lib/stores/ephemeral";
|
||||
import {usePersistentStore} from "~/lib/stores/persistent";
|
||||
import {client} from "~/lib/supabase";
|
||||
import {Post} from "~/lib/types";
|
||||
|
||||
/**
|
||||
* Nearby page
|
||||
* @returns JSX
|
||||
*/
|
||||
export const Nearby: FC = () => {
|
||||
// Hooks
|
||||
const [posts, setPosts] = useState<Post[]>([]);
|
||||
const waitForLocation = useEphemeralStore(state => state.waitForLocation);
|
||||
const showFABs = usePersistentStore(state => state.showFABs);
|
||||
const [sizerRef, {width}] = useMeasure<HTMLDivElement>();
|
||||
const history = useHistory();
|
||||
|
||||
// Methods
|
||||
/**
|
||||
* Set the post
|
||||
* @param newPost New post
|
||||
* @returns Void
|
||||
*/
|
||||
const setPost = (newPost: Post) =>
|
||||
setPosts(posts.map(post => (post.id === newPost.id ? newPost : post)));
|
||||
|
||||
/**
|
||||
* Fetch posts
|
||||
* @param limit Posts limit
|
||||
* @param cutoffRank Cutoff rank or undefined for no cutoff
|
||||
* @returns Posts
|
||||
*/
|
||||
const fetchPosts = async (limit: number, cutoffRank?: number) => {
|
||||
// Wait for a location
|
||||
await waitForLocation();
|
||||
|
||||
// Build the query
|
||||
let query = client
|
||||
.from("personalized_posts")
|
||||
.select(
|
||||
"id, poster_id, created_at, content, has_media, blur_hash, aspect_ratio, views, upvotes, downvotes, comments, distance, rank, is_mine, poster_color, poster_emoji, upvote",
|
||||
);
|
||||
|
||||
if (cutoffRank !== undefined) {
|
||||
query = query.lt("rank", cutoffRank);
|
||||
}
|
||||
|
||||
query = query.order("rank", {ascending: false}).limit(limit);
|
||||
|
||||
// Fetch posts
|
||||
const {data, error} = await query;
|
||||
|
||||
// Handle error
|
||||
if (data === null || error !== null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return data as Post[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Post view event handler
|
||||
* @param post Post that was viewed
|
||||
*/
|
||||
const onPostViewed = async (post: Post) => {
|
||||
// Insert the view
|
||||
await insertView("post_views", "post_id", post.id);
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle a vote on a post
|
||||
* @param post Post to toggle the vote on
|
||||
* @param upvote Whether the vote is an upvote or a downvote
|
||||
*/
|
||||
const togglePostVote = async (post: Post, upvote: boolean) => {
|
||||
await toggleVote(post, setPost, upvote, "post_votes", "post_id");
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a post
|
||||
* @param post Post to delete
|
||||
*/
|
||||
const deletePost = async (post: Post) => {
|
||||
// Delete the post
|
||||
await client.from("posts").delete().eq("id", post.id);
|
||||
|
||||
// Remove the post from the state
|
||||
setPosts(posts.filter(p => p.id !== post.id));
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a post
|
||||
*/
|
||||
const createPost = () => {
|
||||
// Go to the create post page
|
||||
history.push("/posts/create/1");
|
||||
};
|
||||
|
||||
return (
|
||||
<IonPage>
|
||||
<IonHeader className="ion-no-border" translucent={true}>
|
||||
<IonToolbar>
|
||||
<IonButtons slot="start">
|
||||
<IonMenuButton />
|
||||
</IonButtons>
|
||||
|
||||
<IonTitle>Nearby</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<ScrollableContent
|
||||
contentItemName="post"
|
||||
contentItems={posts}
|
||||
setContentItems={setPosts}
|
||||
contentItemIDKey="id"
|
||||
contentItemRankKey="rank"
|
||||
onContentItemViewed={onPostViewed}
|
||||
contentItemRenderer={(post, index, onLoad) => (
|
||||
<SwipeableItem
|
||||
key={post.id}
|
||||
startOption={
|
||||
<IonItemOption color="success">
|
||||
<IonIcon
|
||||
slot="icon-only"
|
||||
ios={arrowUpOutline}
|
||||
md={arrowUpSharp}
|
||||
/>
|
||||
</IonItemOption>
|
||||
}
|
||||
endOption={
|
||||
<IonItemOption color="danger">
|
||||
<IonIcon
|
||||
slot="icon-only"
|
||||
ios={arrowDownOutline}
|
||||
md={arrowDownSharp}
|
||||
/>
|
||||
</IonItemOption>
|
||||
}
|
||||
startAction={() => togglePostVote(post, true)}
|
||||
endAction={() => togglePostVote(post, false)}
|
||||
>
|
||||
<IonItem lines="none">
|
||||
<PostCard
|
||||
className={`max-w-256 mb-2 mx-auto w-full ${index === 0 ? "mt-4" : "mt-2"}`}
|
||||
postLinkDetail={true}
|
||||
width={width}
|
||||
post={post}
|
||||
onLoad={onLoad}
|
||||
toggleVote={upvote => togglePostVote(post, upvote)}
|
||||
onDeleted={post.is_mine ? () => deletePost(post) : undefined}
|
||||
/>
|
||||
</IonItem>
|
||||
</SwipeableItem>
|
||||
)}
|
||||
fetchContent={fetchPosts}
|
||||
header={
|
||||
<IonItem className="h-0" lines="none">
|
||||
<div className="max-w-256 w-full" ref={sizerRef} />
|
||||
</IonItem>
|
||||
}
|
||||
/>
|
||||
|
||||
{showFABs && (
|
||||
<IonFab slot="fixed" horizontal="end" vertical="bottom">
|
||||
<IonFabButton onClick={createPost}>
|
||||
<IonIcon ios={addOutline} md={addSharp} />
|
||||
</IonFabButton>
|
||||
</IonFab>
|
||||
)}
|
||||
</IonPage>
|
||||
);
|
||||
};
|
@@ -0,0 +1,11 @@
|
||||
.textarea :global(.textarea-wrapper-inner) {
|
||||
@apply h-full;
|
||||
}
|
||||
|
||||
.textarea :global(.native-textarea) {
|
||||
@apply !pt-0;
|
||||
}
|
||||
|
||||
.collapsedItem:global(::part(native)) {
|
||||
@apply min-h-unset;
|
||||
}
|
@@ -0,0 +1,255 @@
|
||||
/* eslint-disable camelcase */
|
||||
/**
|
||||
* @file Create comment step 1 page
|
||||
*/
|
||||
|
||||
import {zodResolver} from "@hookform/resolvers/zod";
|
||||
import {
|
||||
IonButton,
|
||||
IonIcon,
|
||||
IonItem,
|
||||
IonLabel,
|
||||
IonList,
|
||||
IonNote,
|
||||
IonSegment,
|
||||
IonSegmentButton,
|
||||
IonTextarea,
|
||||
IonToggle,
|
||||
} from "@ionic/react";
|
||||
import {
|
||||
codeSlashOutline,
|
||||
codeSlashSharp,
|
||||
createOutline,
|
||||
createSharp,
|
||||
eyeOutline,
|
||||
eyeSharp,
|
||||
} from "ionicons/icons";
|
||||
import {FC, useEffect, useState} from "react";
|
||||
import {Controller, useForm} from "react-hook-form";
|
||||
import {useHistory, useParams} from "react-router-dom";
|
||||
import {z} from "zod";
|
||||
|
||||
import {CreateCommentContainer} from "~/components/create-comment-container";
|
||||
import {Markdown} from "~/components/markdown";
|
||||
import {SupplementalError} from "~/components/supplemental-error";
|
||||
import {useEphemeralStore} from "~/lib/stores/ephemeral";
|
||||
import {client} from "~/lib/supabase";
|
||||
import {GlobalMessageMetadata} from "~/lib/types";
|
||||
import styles from "~/pages/posts/[id]/comments/create/step1.module.css";
|
||||
|
||||
/**
|
||||
* Comment created message metadata
|
||||
*/
|
||||
const COMMENT_CREATED_MESSAGE_METADATA: GlobalMessageMetadata = {
|
||||
symbol: Symbol("comment.created"),
|
||||
name: "Success",
|
||||
description: "Your comment has been created.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Content mode
|
||||
*/
|
||||
enum ContentMode {
|
||||
/**
|
||||
* View the raw content
|
||||
*/
|
||||
RAW = "raw",
|
||||
|
||||
/**
|
||||
* Preview the rendered content
|
||||
*/
|
||||
PREVIEW = "preview",
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimum content length
|
||||
*/
|
||||
const MIN_CONTENT_LENGTH = 1;
|
||||
|
||||
/**
|
||||
* Maximum content length
|
||||
*/
|
||||
const MAX_CONTENT_LENGTH = 300;
|
||||
|
||||
/**
|
||||
* Form schema
|
||||
*/
|
||||
const formSchema = z.object({
|
||||
anonymous: z.boolean(),
|
||||
content: z.string().min(MIN_CONTENT_LENGTH).max(MAX_CONTENT_LENGTH),
|
||||
});
|
||||
|
||||
/**
|
||||
* Form schema type
|
||||
*/
|
||||
type FormSchema = z.infer<typeof formSchema>;
|
||||
|
||||
/**
|
||||
* Create comment step 1 page
|
||||
* @returns JSX
|
||||
*/
|
||||
export const Step1: FC = () => {
|
||||
// Hooks
|
||||
const [contentTextarea, setContentTextarea] =
|
||||
// eslint-disable-next-line unicorn/no-null
|
||||
useState<HTMLIonTextareaElement | null>(null);
|
||||
|
||||
const setMessage = useEphemeralStore(state => state.setMessage);
|
||||
const refreshContent = useEphemeralStore(state => state.refreshContent);
|
||||
|
||||
const [contentMode, setContentMode] = useState<ContentMode>(ContentMode.RAW);
|
||||
|
||||
const params = useParams<{id: string}>();
|
||||
const history = useHistory();
|
||||
|
||||
const {control, handleSubmit, reset} = useForm<FormSchema>({
|
||||
defaultValues: {
|
||||
anonymous: false,
|
||||
},
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
|
||||
// Effects
|
||||
useEffect(() => {
|
||||
if (contentTextarea === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Focus the content textarea
|
||||
if (contentMode === ContentMode.RAW) {
|
||||
// setFocus has a race condition
|
||||
setTimeout(() => contentTextarea.setFocus(), 50);
|
||||
}
|
||||
}, [contentMode, contentTextarea]);
|
||||
|
||||
// Methods
|
||||
/**
|
||||
* Form submit handler
|
||||
* @param form Form data
|
||||
*/
|
||||
const onSubmit = async (form: FormSchema) => {
|
||||
// Insert the post
|
||||
const {error} = await client.from("comments").insert({
|
||||
post_id: params.id,
|
||||
private_anonymous: form.anonymous,
|
||||
content: form.content,
|
||||
});
|
||||
|
||||
// Handle error
|
||||
if (error !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset the post forms
|
||||
reset();
|
||||
|
||||
// Display the message
|
||||
setMessage(COMMENT_CREATED_MESSAGE_METADATA);
|
||||
|
||||
// Refetch the content
|
||||
await refreshContent?.();
|
||||
|
||||
// Go back
|
||||
history.goBack();
|
||||
};
|
||||
|
||||
return (
|
||||
<CreateCommentContainer postID={params.id}>
|
||||
<form className="h-full" onSubmit={handleSubmit(onSubmit)}>
|
||||
<IonList className="flex flex-col h-full py-0">
|
||||
<Controller
|
||||
control={control}
|
||||
name="content"
|
||||
render={({
|
||||
field: {onChange, onBlur, value},
|
||||
fieldState: {error},
|
||||
}) => (
|
||||
<div className="flex flex-col flex-1 px-4 pt-4">
|
||||
<IonLabel className="pb-2">Content</IonLabel>
|
||||
|
||||
<div className="flex-1 relative">
|
||||
<div className="absolute flex flex-col left-0 right-0 bottom-0 top-0">
|
||||
{contentMode === ContentMode.RAW ? (
|
||||
<IonTextarea
|
||||
className={`h-full w-full ${styles.textarea}`}
|
||||
autocapitalize="on"
|
||||
counter={true}
|
||||
fill="outline"
|
||||
maxlength={MAX_CONTENT_LENGTH}
|
||||
minlength={MIN_CONTENT_LENGTH}
|
||||
onIonBlur={onBlur}
|
||||
onIonInput={onChange}
|
||||
ref={setContentTextarea}
|
||||
spellcheck={true}
|
||||
value={value}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Markdown
|
||||
className="break-anywhere h-full overflow-auto py-2 text-wrap w-full"
|
||||
raw={value}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SupplementalError error={error?.message} />
|
||||
|
||||
<IonSegment
|
||||
className="mt-7"
|
||||
value={contentMode}
|
||||
onIonChange={event =>
|
||||
setContentMode(event.detail.value as ContentMode)
|
||||
}
|
||||
>
|
||||
<IonSegmentButton layout="icon-start" value={ContentMode.RAW}>
|
||||
<IonLabel>Raw</IonLabel>
|
||||
<IonIcon ios={codeSlashOutline} md={codeSlashSharp} />
|
||||
</IonSegmentButton>
|
||||
<IonSegmentButton
|
||||
layout="icon-start"
|
||||
value={ContentMode.PREVIEW}
|
||||
>
|
||||
<IonLabel>Preview</IonLabel>
|
||||
<IonIcon ios={eyeOutline} md={eyeSharp} />
|
||||
</IonSegmentButton>
|
||||
</IonSegment>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<IonItem>
|
||||
<Controller
|
||||
control={control}
|
||||
name="anonymous"
|
||||
render={({field: {onChange, onBlur, value}}) => (
|
||||
<IonToggle
|
||||
checked={value}
|
||||
onIonBlur={onBlur}
|
||||
onIonChange={event => onChange(event.detail.checked)}
|
||||
>
|
||||
<IonLabel>Make this post anonymous</IonLabel>
|
||||
<IonNote className="whitespace-break-spaces">
|
||||
Your username will be hidden from other users.
|
||||
</IonNote>
|
||||
</IonToggle>
|
||||
)}
|
||||
/>
|
||||
</IonItem>
|
||||
|
||||
<div className="m-4">
|
||||
<IonButton
|
||||
className="m-0 overflow-hidden rounded-lg w-full"
|
||||
expand="full"
|
||||
type="submit"
|
||||
>
|
||||
Post
|
||||
<IonIcon slot="end" ios={createOutline} md={createSharp} />
|
||||
</IonButton>
|
||||
</div>
|
||||
</IonList>
|
||||
</form>
|
||||
</CreateCommentContainer>
|
||||
);
|
||||
};
|
313
99_references/beacon-main/src/pages/posts/[id]/index.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* @file Post index page
|
||||
*/
|
||||
/* eslint-disable unicorn/no-null */
|
||||
/* eslint-disable camelcase */
|
||||
|
||||
import {
|
||||
IonBackButton,
|
||||
IonButtons,
|
||||
IonFab,
|
||||
IonFabButton,
|
||||
IonHeader,
|
||||
IonIcon,
|
||||
IonItem,
|
||||
IonItemOption,
|
||||
IonPage,
|
||||
IonTitle,
|
||||
IonToolbar,
|
||||
} from "@ionic/react";
|
||||
import {
|
||||
addOutline,
|
||||
addSharp,
|
||||
arrowDownOutline,
|
||||
arrowDownSharp,
|
||||
arrowUpOutline,
|
||||
arrowUpSharp,
|
||||
} from "ionicons/icons";
|
||||
import {FC, useEffect, useState} from "react";
|
||||
import {useHistory, useParams} from "react-router-dom";
|
||||
import {useMeasure} from "react-use";
|
||||
|
||||
import {CommentCard} from "~/components/comment-card";
|
||||
import {PostCard} from "~/components/post-card";
|
||||
import {ScrollableContent} from "~/components/scrollable-content";
|
||||
import {SwipeableItem} from "~/components/swipeable-item";
|
||||
import {insertView, toggleVote} from "~/lib/entities";
|
||||
import {useEphemeralStore} from "~/lib/stores/ephemeral";
|
||||
import {usePersistentStore} from "~/lib/stores/persistent";
|
||||
import {client} from "~/lib/supabase";
|
||||
import {Comment, Post} from "~/lib/types";
|
||||
|
||||
/**
|
||||
* Post index page
|
||||
* @returns JSX
|
||||
*/
|
||||
export const PostIndex: FC = () => {
|
||||
// Hooks
|
||||
const [post, setPost] = useState<Post | undefined>();
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
const waitForLocation = useEphemeralStore(state => state.waitForLocation);
|
||||
const showFABs = usePersistentStore(state => state.showFABs);
|
||||
const [sizerRef, {width}] = useMeasure<HTMLDivElement>();
|
||||
const params = useParams<{id: string}>();
|
||||
const history = useHistory();
|
||||
|
||||
// Effects
|
||||
useEffect(() => {
|
||||
// Update the initial post
|
||||
updatePost();
|
||||
}, []);
|
||||
|
||||
// Methods
|
||||
/**
|
||||
* Set the comment
|
||||
* @param newComment New comment
|
||||
* @returns Void
|
||||
*/
|
||||
const setComment = (newComment: Comment) =>
|
||||
setComments(
|
||||
comments.map(comment =>
|
||||
comment.id === newComment.id ? newComment : comment,
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Update the post
|
||||
*/
|
||||
const updatePost = async () => {
|
||||
// Get the post
|
||||
const {data, error} = await client
|
||||
.from("personalized_posts")
|
||||
.select(
|
||||
"id, poster_id, created_at, content, has_media, blur_hash, aspect_ratio, views, upvotes, downvotes, comments, distance, rank, is_mine, poster_color, poster_emoji, upvote",
|
||||
)
|
||||
.eq("id", params.id)
|
||||
.single();
|
||||
|
||||
// Handle error
|
||||
if (data === null || error !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the state
|
||||
setPost(data as any);
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch comments
|
||||
* @param limit Comments limit
|
||||
* @param cutoffRank Cutoff rank or undefined for no cutoff
|
||||
* @returns Comments
|
||||
*/
|
||||
const fetchComments = async (limit: number, cutoffRank?: number) => {
|
||||
// Wait for a location
|
||||
await waitForLocation();
|
||||
|
||||
// Build the query
|
||||
let query = client
|
||||
.from("personalized_comments")
|
||||
.select(
|
||||
"id, commenter_id, post_id, parent_id, created_at, content, views, upvotes, downvotes, rank, is_mine, commenter_color, commenter_emoji, upvote",
|
||||
)
|
||||
.eq("post_id", params.id);
|
||||
|
||||
if (cutoffRank !== undefined) {
|
||||
query = query.lt("rank", cutoffRank);
|
||||
}
|
||||
|
||||
query = query.order("rank", {ascending: false}).limit(limit);
|
||||
|
||||
// Fetch comments
|
||||
const {data, error} = await query;
|
||||
|
||||
// Handle error
|
||||
if (data === null || error !== null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return data as any as Comment[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Comment view event handler
|
||||
* @param comment Comment that was viewed
|
||||
*/
|
||||
const onCommentViewed = async (comment: Comment) => {
|
||||
// Insert the view
|
||||
await insertView("comment_views", "comment_id", comment.id);
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle a vote on a post
|
||||
* @param post Post to toggle the vote on
|
||||
* @param upvote Whether the vote is an upvote or a downvote
|
||||
*/
|
||||
const togglePostVote = async (post: Post, upvote: boolean) => {
|
||||
await toggleVote(post, setPost, upvote, "post_votes", "post_id");
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a post
|
||||
* @param post Post to delete
|
||||
*/
|
||||
const deletePost = async (post: Post) => {
|
||||
// Delete the post
|
||||
await client.from("posts").delete().eq("id", post.id);
|
||||
|
||||
// Redirect to the nearby page
|
||||
history.push("/nearby");
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle a vote on a comment
|
||||
* @param comment Comment to toggle the vote on
|
||||
* @param upvote Whether the vote is an upvote or a downvote
|
||||
*/
|
||||
const toggleCommentVote = async (comment: Comment, upvote: boolean) => {
|
||||
await toggleVote(
|
||||
comment,
|
||||
setComment,
|
||||
upvote,
|
||||
"comment_votes",
|
||||
"comment_id",
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a comment
|
||||
* @param comment Comment to delete
|
||||
*/
|
||||
const deleteComments = async (comment: Comment) => {
|
||||
// Delete the comment
|
||||
await client.from("comments").delete().eq("id", comment.id);
|
||||
|
||||
// Remove the comment from the state
|
||||
setComments(comments.filter(c => c.id !== comment.id));
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a comment
|
||||
*/
|
||||
const createComment = () => {
|
||||
// Go to the create comment page
|
||||
history.push(`/posts/${params.id}/comments/create/1`);
|
||||
};
|
||||
|
||||
return (
|
||||
<IonPage>
|
||||
<IonHeader className="ion-no-border" translucent={true}>
|
||||
<IonToolbar>
|
||||
<IonButtons slot="start">
|
||||
<IonBackButton defaultHref="/nearby" />
|
||||
</IonButtons>
|
||||
|
||||
<IonTitle>Post</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<ScrollableContent
|
||||
contentItemName="comment"
|
||||
contentItems={comments}
|
||||
setContentItems={setComments}
|
||||
contentItemIDKey="id"
|
||||
contentItemRankKey="rank"
|
||||
onContentItemViewed={onCommentViewed}
|
||||
contentItemRenderer={(comment, _, onLoad) => (
|
||||
<SwipeableItem
|
||||
key={comment.id}
|
||||
startOption={
|
||||
<IonItemOption color="success">
|
||||
<IonIcon
|
||||
slot="icon-only"
|
||||
ios={arrowUpOutline}
|
||||
md={arrowUpSharp}
|
||||
/>
|
||||
</IonItemOption>
|
||||
}
|
||||
endOption={
|
||||
<IonItemOption color="danger">
|
||||
<IonIcon
|
||||
slot="icon-only"
|
||||
ios={arrowDownOutline}
|
||||
md={arrowDownSharp}
|
||||
/>
|
||||
</IonItemOption>
|
||||
}
|
||||
startAction={() => toggleCommentVote(comment, true)}
|
||||
endAction={() => toggleCommentVote(comment, false)}
|
||||
>
|
||||
<IonItem lines="none">
|
||||
<CommentCard
|
||||
className="max-w-256 mx-auto my-2 w-full"
|
||||
comment={comment}
|
||||
onLoad={onLoad}
|
||||
toggleVote={upvote => toggleCommentVote(comment, upvote)}
|
||||
onDeleted={
|
||||
comment.is_mine ? () => deleteComments(comment) : undefined
|
||||
}
|
||||
/>
|
||||
</IonItem>
|
||||
</SwipeableItem>
|
||||
)}
|
||||
fetchContent={fetchComments}
|
||||
onRefresh={updatePost}
|
||||
header={
|
||||
<>
|
||||
{post !== undefined && (
|
||||
<SwipeableItem
|
||||
className="overflow-initial"
|
||||
key={post.id}
|
||||
startOption={
|
||||
<IonItemOption color="success">
|
||||
<IonIcon
|
||||
slot="icon-only"
|
||||
ios={arrowUpOutline}
|
||||
md={arrowUpSharp}
|
||||
/>
|
||||
</IonItemOption>
|
||||
}
|
||||
endOption={
|
||||
<IonItemOption color="danger">
|
||||
<IonIcon
|
||||
slot="icon-only"
|
||||
ios={arrowDownOutline}
|
||||
md={arrowDownSharp}
|
||||
/>
|
||||
</IonItemOption>
|
||||
}
|
||||
startAction={() => togglePostVote(post, true)}
|
||||
endAction={() => togglePostVote(post, false)}
|
||||
>
|
||||
<IonItem>
|
||||
<PostCard
|
||||
className="max-w-256 mb-2 mt-4 mx-auto w-full"
|
||||
post={post}
|
||||
postLinkDetail={false}
|
||||
width={width}
|
||||
toggleVote={upvote => togglePostVote(post, upvote)}
|
||||
onDeleted={
|
||||
post.is_mine ? () => deletePost(post) : undefined
|
||||
}
|
||||
/>
|
||||
</IonItem>
|
||||
</SwipeableItem>
|
||||
)}
|
||||
|
||||
<IonItem className="h-0" lines="none">
|
||||
<div className="max-w-256 w-full" ref={sizerRef} />
|
||||
</IonItem>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{showFABs && (
|
||||
<IonFab slot="fixed" horizontal="end" vertical="bottom">
|
||||
<IonFabButton onClick={createComment}>
|
||||
<IonIcon ios={addOutline} md={addSharp} />
|
||||
</IonFabButton>
|
||||
</IonFab>
|
||||
)}
|
||||
</IonPage>
|
||||
);
|
||||
};
|
@@ -0,0 +1,11 @@
|
||||
.textarea :global(.textarea-wrapper-inner) {
|
||||
@apply h-full;
|
||||
}
|
||||
|
||||
.textarea :global(.native-textarea) {
|
||||
@apply !pt-0;
|
||||
}
|
||||
|
||||
.collapsedItem:global(::part(native)) {
|
||||
@apply min-h-unset;
|
||||
}
|
529
99_references/beacon-main/src/pages/posts/create/step1.tsx
Normal file
@@ -0,0 +1,529 @@
|
||||
/**
|
||||
* @file Create post step 1 page
|
||||
*/
|
||||
|
||||
import {zodResolver} from "@hookform/resolvers/zod";
|
||||
import {
|
||||
IonButton,
|
||||
IonIcon,
|
||||
IonItem,
|
||||
IonLabel,
|
||||
IonList,
|
||||
IonSegment,
|
||||
IonSegmentButton,
|
||||
IonTextarea,
|
||||
useIonActionSheet,
|
||||
useIonLoading,
|
||||
} from "@ionic/react";
|
||||
import {
|
||||
arrowForwardOutline,
|
||||
arrowForwardSharp,
|
||||
closeOutline,
|
||||
closeSharp,
|
||||
codeSlashOutline,
|
||||
codeSlashSharp,
|
||||
eyeOutline,
|
||||
eyeSharp,
|
||||
imageOutline,
|
||||
imageSharp,
|
||||
} from "ionicons/icons";
|
||||
import {FC, useEffect, useRef, useState} from "react";
|
||||
import {Controller, useForm} from "react-hook-form";
|
||||
import {useHistory} from "react-router-dom";
|
||||
import {z} from "zod";
|
||||
|
||||
import {CreatePostContainer} from "~/components/create-post-container";
|
||||
import {Markdown} from "~/components/markdown";
|
||||
import {SupplementalError} from "~/components/supplemental-error";
|
||||
import {
|
||||
BLURHASH_COMPONENT_X,
|
||||
BLURHASH_COMPONENT_Y,
|
||||
captureMedia,
|
||||
createBlurhash,
|
||||
createMediaCanvas,
|
||||
createMediaElement,
|
||||
exportMedia,
|
||||
getCategory,
|
||||
getMediaDimensions,
|
||||
MAX_MEDIA_DIMENSION,
|
||||
MAX_MEDIA_SIZE,
|
||||
MIN_MEDIA_DIMENSION,
|
||||
PREFERRED_IMAGE_MIME_TYPE,
|
||||
PREFERRED_IMAGE_QUALITY,
|
||||
scaleCanvas,
|
||||
} from "~/lib/media";
|
||||
import {useEphemeralStore} from "~/lib/stores/ephemeral";
|
||||
import {MediaCategory, MediaDimensions} from "~/lib/types";
|
||||
import styles from "~/pages/posts/create/step1.module.css";
|
||||
|
||||
/**
|
||||
* Content mode
|
||||
*/
|
||||
enum ContentMode {
|
||||
/**
|
||||
* View the raw content
|
||||
*/
|
||||
RAW = "raw",
|
||||
|
||||
/**
|
||||
* Preview the rendered content
|
||||
*/
|
||||
PREVIEW = "preview",
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimum content length
|
||||
*/
|
||||
const MIN_CONTENT_LENGTH = 1;
|
||||
|
||||
/**
|
||||
* Maximum content length
|
||||
*/
|
||||
const MAX_CONTENT_LENGTH = 300;
|
||||
|
||||
/**
|
||||
* Form schema
|
||||
*/
|
||||
const formSchema = z.object({
|
||||
content: z.string().min(MIN_CONTENT_LENGTH).max(MAX_CONTENT_LENGTH),
|
||||
media: z
|
||||
.object({
|
||||
aspectRatio: z.number(),
|
||||
blurHash: z.string(),
|
||||
blob: z.instanceof(Blob),
|
||||
category: z.nativeEnum(MediaCategory),
|
||||
objectURL: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Form schema input type
|
||||
*/
|
||||
type FormSchemaInput = z.input<typeof formSchema>;
|
||||
|
||||
/**
|
||||
* Form schema output type
|
||||
*/
|
||||
type FormSchemaOutput = z.output<typeof formSchema>;
|
||||
|
||||
/**
|
||||
* Create post step 1 page
|
||||
* @returns JSX
|
||||
*/
|
||||
export const Step1: FC = () => {
|
||||
// Hooks
|
||||
const [contentTextarea, setContentTextarea] =
|
||||
// eslint-disable-next-line unicorn/no-null
|
||||
useState<HTMLIonTextareaElement | null>(null);
|
||||
|
||||
const [contentMode, setContentMode] = useState<ContentMode>(ContentMode.RAW);
|
||||
const mediaInput = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const post = useEphemeralStore(state => state.postBeingCreated);
|
||||
const setPost = useEphemeralStore(state => state.setPostBeingCreated);
|
||||
|
||||
const [presentActionSheet] = useIonActionSheet();
|
||||
const [presentLoading, dismissLoading] = useIonLoading();
|
||||
|
||||
const history = useHistory();
|
||||
|
||||
const {control, handleSubmit, reset, setError, setValue, watch} = useForm<
|
||||
FormSchemaInput,
|
||||
z.ZodTypeDef,
|
||||
FormSchemaOutput
|
||||
>({
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
|
||||
// Variables
|
||||
const media = watch("media");
|
||||
|
||||
// Effects
|
||||
useEffect(() => {
|
||||
if (contentTextarea === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Focus the content textarea
|
||||
if (contentMode === ContentMode.RAW) {
|
||||
// setFocus has a race condition
|
||||
setTimeout(() => contentTextarea.setFocus(), 50);
|
||||
}
|
||||
}, [contentMode, contentTextarea]);
|
||||
|
||||
useEffect(() => {
|
||||
// Reset the form
|
||||
if (post === undefined) {
|
||||
reset();
|
||||
|
||||
if (mediaInput.current !== null) {
|
||||
mediaInput.current.value = "";
|
||||
}
|
||||
}
|
||||
}, [post]);
|
||||
|
||||
useEffect(() => {
|
||||
// Update the upload value
|
||||
if (media === undefined && mediaInput.current !== null) {
|
||||
mediaInput.current.value = "";
|
||||
}
|
||||
}, [media]);
|
||||
|
||||
// Methods
|
||||
/**
|
||||
* Capture media and update the form
|
||||
* @param newCapture Whether to capture new media
|
||||
* @param rawCategory Media category
|
||||
*/
|
||||
const captureMediaAndUpdateForm = async <T extends boolean>(
|
||||
newCapture: T,
|
||||
rawCategory: T extends true ? MediaCategory : MediaCategory | undefined,
|
||||
) => {
|
||||
// Capture the media
|
||||
const media = await captureMedia(newCapture, rawCategory);
|
||||
|
||||
// Start the loading indicator
|
||||
await presentLoading({
|
||||
message: "Processing media...",
|
||||
});
|
||||
|
||||
// Get the media category
|
||||
const category = rawCategory ?? getCategory(media.type);
|
||||
|
||||
if (category === undefined) {
|
||||
setError("media", {message: `Unsupported media type ${media.type}`});
|
||||
await dismissLoading();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate an object URL for the media
|
||||
const originalObjectURL = URL.createObjectURL(media);
|
||||
|
||||
// Create the media element and canvas
|
||||
const element = await createMediaElement(category, originalObjectURL);
|
||||
const dimensions = getMediaDimensions(category, element);
|
||||
|
||||
const aspectRatio = dimensions.width / dimensions.height;
|
||||
let canvas = createMediaCanvas(element, dimensions);
|
||||
|
||||
// Check the media dimensions
|
||||
if (
|
||||
dimensions.height > MAX_MEDIA_DIMENSION ||
|
||||
dimensions.width > MAX_MEDIA_DIMENSION
|
||||
) {
|
||||
switch (category) {
|
||||
case MediaCategory.IMAGE: {
|
||||
// Calculate scaled dimensions (while preserving aspect ratio)
|
||||
const scaledDimensions: MediaDimensions =
|
||||
aspectRatio > 1
|
||||
? {
|
||||
height: Math.floor(MAX_MEDIA_DIMENSION / aspectRatio),
|
||||
width: MAX_MEDIA_DIMENSION,
|
||||
}
|
||||
: {
|
||||
height: MAX_MEDIA_DIMENSION,
|
||||
width: Math.floor(MAX_MEDIA_DIMENSION * aspectRatio),
|
||||
};
|
||||
|
||||
// Scale the media
|
||||
canvas = scaleCanvas(canvas, scaledDimensions);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
setError("media", {
|
||||
message: `Media must be at most ${MAX_MEDIA_DIMENSION} x ${MAX_MEDIA_DIMENSION}`,
|
||||
});
|
||||
|
||||
await dismissLoading();
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
dimensions.height < MIN_MEDIA_DIMENSION ||
|
||||
dimensions.width < MIN_MEDIA_DIMENSION
|
||||
) {
|
||||
setError("media", {
|
||||
message: `Media must be at least ${MIN_MEDIA_DIMENSION} x ${MIN_MEDIA_DIMENSION}`,
|
||||
});
|
||||
|
||||
await dismissLoading();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate the blurhash
|
||||
const blurHash = await createBlurhash(
|
||||
canvas,
|
||||
BLURHASH_COMPONENT_X,
|
||||
BLURHASH_COMPONENT_Y,
|
||||
);
|
||||
|
||||
// Export the media if it is an image (to strip metadata)
|
||||
let blob: Blob;
|
||||
let objectURL: string;
|
||||
|
||||
switch (category) {
|
||||
case MediaCategory.IMAGE:
|
||||
blob = await exportMedia(
|
||||
canvas,
|
||||
PREFERRED_IMAGE_MIME_TYPE,
|
||||
PREFERRED_IMAGE_QUALITY,
|
||||
);
|
||||
|
||||
objectURL = URL.createObjectURL(blob);
|
||||
break;
|
||||
|
||||
default:
|
||||
blob = media;
|
||||
objectURL = originalObjectURL;
|
||||
break;
|
||||
}
|
||||
|
||||
// Check the media size
|
||||
if (blob.size > MAX_MEDIA_SIZE) {
|
||||
setError("media", {
|
||||
message: `Media must be at most ${MAX_MEDIA_SIZE / (1024 * 1024)} MiB`,
|
||||
});
|
||||
|
||||
await dismissLoading();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setValue("media", {
|
||||
aspectRatio,
|
||||
blurHash,
|
||||
blob,
|
||||
category,
|
||||
objectURL,
|
||||
});
|
||||
|
||||
await dismissLoading();
|
||||
};
|
||||
|
||||
/**
|
||||
* Prompt the user to add media to the post
|
||||
* @returns Promise
|
||||
*/
|
||||
const addMedia = () =>
|
||||
presentActionSheet({
|
||||
header: "Choose Photo/Video",
|
||||
subHeader: "Note: you can only add one photo or video per post.",
|
||||
buttons: [
|
||||
{
|
||||
text: "Cancel",
|
||||
role: "cancel",
|
||||
},
|
||||
{
|
||||
text: "New photo",
|
||||
role: "selected",
|
||||
/**
|
||||
* Capture a new photo
|
||||
*/
|
||||
handler: () => {
|
||||
captureMediaAndUpdateForm(true, MediaCategory.IMAGE);
|
||||
},
|
||||
},
|
||||
{
|
||||
text: "New video",
|
||||
role: "selected",
|
||||
/**
|
||||
* Capture a new video
|
||||
*/
|
||||
handler: () => {
|
||||
captureMediaAndUpdateForm(true, MediaCategory.VIDEO);
|
||||
},
|
||||
},
|
||||
{
|
||||
text: "Existing photo/video",
|
||||
role: "selected",
|
||||
/**
|
||||
* Capture an existing photo or video
|
||||
*/
|
||||
handler: () => {
|
||||
captureMediaAndUpdateForm(false, undefined);
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
/**
|
||||
* Form submit handler
|
||||
* @param form Form data
|
||||
*/
|
||||
const onSubmit = async (form: FormSchemaOutput) => {
|
||||
// Update the post
|
||||
setPost({
|
||||
content: form.content,
|
||||
media: form.media,
|
||||
});
|
||||
|
||||
// Go to the next step
|
||||
history.push("/posts/create/2");
|
||||
};
|
||||
|
||||
return (
|
||||
<CreatePostContainer>
|
||||
<form className="h-full" onSubmit={handleSubmit(onSubmit)}>
|
||||
<IonList className="flex flex-col h-full py-0">
|
||||
<Controller
|
||||
control={control}
|
||||
name="content"
|
||||
render={({
|
||||
field: {onChange, onBlur, value},
|
||||
fieldState: {error},
|
||||
}) => (
|
||||
<div className="flex flex-col flex-1 px-4 pt-4">
|
||||
<IonLabel className="pb-2">Content</IonLabel>
|
||||
|
||||
<div className="flex-1 relative">
|
||||
<div className="absolute flex flex-col left-0 right-0 bottom-0 top-0">
|
||||
{contentMode === ContentMode.RAW ? (
|
||||
<IonTextarea
|
||||
className={`h-full w-full ${styles.textarea}`}
|
||||
autocapitalize="on"
|
||||
counter={true}
|
||||
fill="outline"
|
||||
maxlength={MAX_CONTENT_LENGTH}
|
||||
minlength={MIN_CONTENT_LENGTH}
|
||||
onIonBlur={onBlur}
|
||||
onIonInput={onChange}
|
||||
ref={setContentTextarea}
|
||||
spellcheck={true}
|
||||
value={value}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Markdown
|
||||
className="break-anywhere h-full overflow-auto py-2 text-wrap w-full"
|
||||
raw={value}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SupplementalError error={error?.message} />
|
||||
|
||||
<IonSegment
|
||||
className="mt-7"
|
||||
value={contentMode}
|
||||
onIonChange={event =>
|
||||
setContentMode(event.detail.value as ContentMode)
|
||||
}
|
||||
>
|
||||
<IonSegmentButton layout="icon-start" value={ContentMode.RAW}>
|
||||
<IonLabel>Raw</IonLabel>
|
||||
<IonIcon ios={codeSlashOutline} md={codeSlashSharp} />
|
||||
</IonSegmentButton>
|
||||
<IonSegmentButton
|
||||
layout="icon-start"
|
||||
value={ContentMode.PREVIEW}
|
||||
>
|
||||
<IonLabel>Preview</IonLabel>
|
||||
<IonIcon ios={eyeOutline} md={eyeSharp} />
|
||||
</IonSegmentButton>
|
||||
</IonSegment>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<IonItem className={`mt-4 ${styles.collapsedItem}`} />
|
||||
|
||||
<IonItem>
|
||||
<Controller
|
||||
control={control}
|
||||
name="media"
|
||||
render={({fieldState: {error}}) => (
|
||||
<div className="flex flex-col w-full">
|
||||
<IonButton className="w-full" fill="clear" onClick={addMedia}>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-row items-center justify-center relative w-full my-2">
|
||||
<IonIcon
|
||||
className="text-2xl"
|
||||
ios={imageOutline}
|
||||
md={imageSharp}
|
||||
/>
|
||||
|
||||
<p className="ml-2 text-3.5 text-center">
|
||||
Add a photo or video
|
||||
</p>
|
||||
|
||||
{media !== undefined && (
|
||||
<IonButton
|
||||
fill="clear"
|
||||
onClick={event => {
|
||||
event.stopPropagation();
|
||||
setValue("media", undefined);
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
slot="icon-only"
|
||||
ios={closeOutline}
|
||||
md={closeSharp}
|
||||
/>
|
||||
</IonButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{media !== undefined && (
|
||||
<div className="h-[50vh] mb-4 overflow-hidden pointer-events-none rounded-lg w-full">
|
||||
{(() => {
|
||||
switch (media?.category) {
|
||||
case MediaCategory.IMAGE:
|
||||
return (
|
||||
<img
|
||||
alt="Media preview"
|
||||
className="h-full w-full"
|
||||
src={media.objectURL}
|
||||
/>
|
||||
);
|
||||
|
||||
case MediaCategory.VIDEO:
|
||||
return (
|
||||
<video
|
||||
autoPlay
|
||||
className="h-full w-full"
|
||||
loop
|
||||
muted
|
||||
src={media.objectURL}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</IonButton>
|
||||
|
||||
<SupplementalError error={error?.message} />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</IonItem>
|
||||
|
||||
<div className="m-4">
|
||||
<IonButton
|
||||
className="m-0 overflow-hidden rounded-lg w-full"
|
||||
expand="full"
|
||||
type="submit"
|
||||
>
|
||||
Next
|
||||
<IonIcon
|
||||
slot="end"
|
||||
ios={arrowForwardOutline}
|
||||
md={arrowForwardSharp}
|
||||
/>
|
||||
</IonButton>
|
||||
</div>
|
||||
</IonList>
|
||||
</form>
|
||||
</CreatePostContainer>
|
||||
);
|
||||
};
|
@@ -0,0 +1,3 @@
|
||||
.range {
|
||||
--knob-size: 1.5rem;
|
||||
}
|
345
99_references/beacon-main/src/pages/posts/create/step2.tsx
Normal file
@@ -0,0 +1,345 @@
|
||||
/* eslint-disable unicorn/no-null */
|
||||
/* eslint-disable camelcase */
|
||||
/**
|
||||
* @file Create post step 2 page
|
||||
*/
|
||||
|
||||
import {zodResolver} from "@hookform/resolvers/zod";
|
||||
import {
|
||||
IonButton,
|
||||
IonIcon,
|
||||
IonInput,
|
||||
IonItem,
|
||||
IonLabel,
|
||||
IonList,
|
||||
IonNote,
|
||||
IonRange,
|
||||
IonToggle,
|
||||
} from "@ionic/react";
|
||||
import {
|
||||
createOutline,
|
||||
createSharp,
|
||||
globeOutline,
|
||||
globeSharp,
|
||||
locationOutline,
|
||||
locationSharp,
|
||||
} from "ionicons/icons";
|
||||
import {round} from "lodash-es";
|
||||
import {FC, useEffect} from "react";
|
||||
import {Controller, useForm} from "react-hook-form";
|
||||
import {useHistory} from "react-router-dom";
|
||||
import {z} from "zod";
|
||||
|
||||
import {CreatePostContainer} from "~/components/create-post-container";
|
||||
import {Map} from "~/components/map";
|
||||
import {SupplementalError} from "~/components/supplemental-error";
|
||||
import {useEphemeralStore} from "~/lib/stores/ephemeral";
|
||||
import {usePersistentStore} from "~/lib/stores/persistent";
|
||||
import {client} from "~/lib/supabase";
|
||||
import {GlobalMessageMetadata, MeasurementSystem} from "~/lib/types";
|
||||
import {METERS_TO_KILOMETERS, METERS_TO_MILES} from "~/lib/utils";
|
||||
import styles from "~/pages/posts/create/step2.module.css";
|
||||
|
||||
/**
|
||||
* Geolocation not supported message metadata
|
||||
*/
|
||||
const GEOLOCATION_NOT_SUPPORTED_MESSAGE_METADATA: GlobalMessageMetadata = {
|
||||
symbol: Symbol("geolocation.not-supported"),
|
||||
name: "Geolocation not supported",
|
||||
description: "Geolocation is not supported on this device.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Post created message metadata
|
||||
*/
|
||||
const POST_CREATED_MESSAGE_METADATA: GlobalMessageMetadata = {
|
||||
symbol: Symbol("post.created"),
|
||||
name: "Success",
|
||||
description: "Your post has been created.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Minimum radius (In meters)
|
||||
*/
|
||||
const MIN_RADIUS = 500;
|
||||
|
||||
/**
|
||||
* Maximum radius (In meters)
|
||||
*/
|
||||
const MAX_RADIUS = 50000;
|
||||
|
||||
/**
|
||||
* Form schema
|
||||
*/
|
||||
const formSchema = z.object({
|
||||
anonymous: z.boolean(),
|
||||
radius: z
|
||||
.number()
|
||||
.min(MIN_RADIUS - 1e-4)
|
||||
.max(MAX_RADIUS + 1e-4),
|
||||
});
|
||||
|
||||
// Types
|
||||
/**
|
||||
* Form schema type
|
||||
*/
|
||||
type FormSchema = z.infer<typeof formSchema>;
|
||||
|
||||
/**
|
||||
* Create post step 2 page
|
||||
* @returns JSX
|
||||
*/
|
||||
export const Step2: FC = () => {
|
||||
// Hooks
|
||||
const location = useEphemeralStore(state => state.location);
|
||||
const setMessage = useEphemeralStore(state => state.setMessage);
|
||||
const refreshContent = useEphemeralStore(state => state.refreshContent);
|
||||
|
||||
const measurementSystem = usePersistentStore(
|
||||
state => state.measurementSystem,
|
||||
);
|
||||
|
||||
const post = useEphemeralStore(state => state.postBeingCreated);
|
||||
const setPost = useEphemeralStore(state => state.setPostBeingCreated);
|
||||
|
||||
const history = useHistory();
|
||||
|
||||
// Variables
|
||||
/**
|
||||
* Conversion factor
|
||||
*/
|
||||
const conversionFactor =
|
||||
measurementSystem === MeasurementSystem.METRIC
|
||||
? METERS_TO_KILOMETERS
|
||||
: METERS_TO_MILES;
|
||||
|
||||
let minRadius: number;
|
||||
let maxRadius: number;
|
||||
let defaultRadius: number;
|
||||
let radiusStep: number;
|
||||
|
||||
switch (measurementSystem) {
|
||||
case MeasurementSystem.METRIC:
|
||||
minRadius = 1;
|
||||
maxRadius = 50;
|
||||
defaultRadius = 5 / conversionFactor;
|
||||
radiusStep = 1;
|
||||
break;
|
||||
|
||||
case MeasurementSystem.IMPERIAL:
|
||||
minRadius = 0.5;
|
||||
maxRadius = 30;
|
||||
defaultRadius = 3 / conversionFactor;
|
||||
radiusStep = 0.5;
|
||||
break;
|
||||
}
|
||||
|
||||
// More hooks
|
||||
const {control, handleSubmit, watch, reset} = useForm<FormSchema>({
|
||||
defaultValues: {
|
||||
anonymous: false,
|
||||
radius: defaultRadius,
|
||||
},
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
|
||||
const radius = watch("radius");
|
||||
|
||||
// Effects
|
||||
useEffect(() => {
|
||||
// Reset the form
|
||||
if (post === undefined) {
|
||||
reset();
|
||||
}
|
||||
}, [post]);
|
||||
|
||||
// Methods
|
||||
/**
|
||||
* Form submit handler
|
||||
* @param form Form data
|
||||
*/
|
||||
const onSubmit = async (form: FormSchema) => {
|
||||
if (post === undefined) {
|
||||
throw new TypeError("Post is undefined");
|
||||
}
|
||||
|
||||
if (location === undefined) {
|
||||
setMessage(GEOLOCATION_NOT_SUPPORTED_MESSAGE_METADATA);
|
||||
}
|
||||
|
||||
// Insert the post
|
||||
const {data, error} = await client
|
||||
.from("posts")
|
||||
.insert({
|
||||
private_anonymous: form.anonymous,
|
||||
radius: form.radius,
|
||||
content: post.content!,
|
||||
has_media: post.media !== undefined,
|
||||
blur_hash: post.media?.blurHash,
|
||||
aspect_ratio: post.media?.aspectRatio,
|
||||
})
|
||||
.select("id")
|
||||
.single<{
|
||||
id: string;
|
||||
}>();
|
||||
|
||||
// Handle error
|
||||
if (data === null || error !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Upload the media
|
||||
if (post.media?.blob !== undefined) {
|
||||
const {error} = await client.storage
|
||||
.from("media")
|
||||
.upload(`posts/${data.id}`, post.media.blob);
|
||||
|
||||
// Handle error
|
||||
if (error !== null) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Reset the post forms
|
||||
setPost(undefined);
|
||||
|
||||
// Display the message
|
||||
setMessage(POST_CREATED_MESSAGE_METADATA);
|
||||
|
||||
// Refetch the content
|
||||
await refreshContent?.();
|
||||
|
||||
// Go back twice
|
||||
history.goBack();
|
||||
history.goBack();
|
||||
};
|
||||
|
||||
return (
|
||||
<CreatePostContainer>
|
||||
<form className="h-full" onSubmit={handleSubmit(onSubmit)}>
|
||||
<IonList className="flex flex-col h-full py-0">
|
||||
<IonItem>
|
||||
<Controller
|
||||
control={control}
|
||||
name="anonymous"
|
||||
render={({field: {onChange, onBlur, value}}) => (
|
||||
<IonToggle
|
||||
checked={value}
|
||||
onIonBlur={onBlur}
|
||||
onIonChange={event => onChange(event.detail.checked)}
|
||||
>
|
||||
<IonLabel>Make this post anonymous</IonLabel>
|
||||
<IonNote className="whitespace-break-spaces">
|
||||
Your username will be hidden from other users.
|
||||
</IonNote>
|
||||
</IonToggle>
|
||||
)}
|
||||
/>
|
||||
</IonItem>
|
||||
|
||||
<div className="flex flex-1 flex-col mt-4 mx-4">
|
||||
<IonLabel>Radius</IonLabel>
|
||||
<IonNote className="whitespace-break-spaces">
|
||||
Only people in the blue region will be able to see and comment on
|
||||
this post.
|
||||
</IonNote>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="radius"
|
||||
render={({
|
||||
field: {onChange, onBlur, value},
|
||||
fieldState: {error},
|
||||
}) => (
|
||||
<>
|
||||
<div className="flex flex-row items-center justify-center">
|
||||
<IonRange
|
||||
aria-label="Radius"
|
||||
className={`flex-1 ml-2 mr-2 ${styles.range}`}
|
||||
min={minRadius}
|
||||
max={maxRadius}
|
||||
step={radiusStep}
|
||||
onIonBlur={onBlur}
|
||||
onIonInput={event =>
|
||||
onChange(
|
||||
(event.detail.value as number) / conversionFactor,
|
||||
)
|
||||
}
|
||||
value={value * conversionFactor}
|
||||
>
|
||||
<IonIcon
|
||||
slot="start"
|
||||
ios={locationOutline}
|
||||
md={locationSharp}
|
||||
/>
|
||||
<IonIcon slot="end" ios={globeOutline} md={globeSharp} />
|
||||
</IonRange>
|
||||
<IonInput
|
||||
aria-label="Radius"
|
||||
className="ml-2 w-28"
|
||||
fill="outline"
|
||||
onIonBlur={onBlur}
|
||||
onIonChange={event =>
|
||||
onChange(
|
||||
Number.parseInt(event.detail.value ?? "0") /
|
||||
conversionFactor,
|
||||
)
|
||||
}
|
||||
type="number"
|
||||
min={minRadius}
|
||||
max={maxRadius}
|
||||
step={radiusStep.toString()}
|
||||
value={round(value * conversionFactor, 1)}
|
||||
>
|
||||
<IonLabel class="!ml-2" slot="end">
|
||||
{measurementSystem === MeasurementSystem.METRIC
|
||||
? "km"
|
||||
: "mi"}
|
||||
</IonLabel>
|
||||
</IonInput>
|
||||
</div>
|
||||
|
||||
<SupplementalError error={error?.message} />
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
{location !== undefined && (
|
||||
<Map
|
||||
className="flex-1 mt-4 overflow-hidden rounded-lg w-full"
|
||||
position={[location.coords.latitude, location.coords.longitude]}
|
||||
bounds={[
|
||||
[
|
||||
location.coords.latitude + 0.75,
|
||||
location.coords.longitude + 0.75,
|
||||
],
|
||||
[
|
||||
location.coords.latitude - 0.75,
|
||||
location.coords.longitude - 0.75,
|
||||
],
|
||||
]}
|
||||
zoom={11}
|
||||
minZoom={6}
|
||||
circle={{
|
||||
center: [location.coords.latitude, location.coords.longitude],
|
||||
radius: radius,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="m-4">
|
||||
<IonButton
|
||||
className="m-0 overflow-hidden rounded-lg w-full"
|
||||
expand="full"
|
||||
type="submit"
|
||||
>
|
||||
Post
|
||||
<IonIcon slot="end" ios={createOutline} md={createSharp} />
|
||||
</IonButton>
|
||||
</div>
|
||||
</IonList>
|
||||
</form>
|
||||
</CreatePostContainer>
|
||||
);
|
||||
};
|
3
99_references/beacon-main/src/pages/settings.module.css
Normal file
@@ -0,0 +1,3 @@
|
||||
.modal {
|
||||
--height: auto;
|
||||
}
|
511
99_references/beacon-main/src/pages/settings.tsx
Normal file
@@ -0,0 +1,511 @@
|
||||
/**
|
||||
* @file Setting page
|
||||
*/
|
||||
|
||||
import {
|
||||
IonButtons,
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonIcon,
|
||||
IonItem,
|
||||
IonItemDivider,
|
||||
IonItemGroup,
|
||||
IonLabel,
|
||||
IonList,
|
||||
IonMenuButton,
|
||||
IonModal,
|
||||
IonNote,
|
||||
IonPage,
|
||||
IonSelect,
|
||||
IonSelectOption,
|
||||
IonTitle,
|
||||
IonToggle,
|
||||
IonToolbar,
|
||||
isPlatform,
|
||||
useIonActionSheet,
|
||||
} from "@ionic/react";
|
||||
import {
|
||||
downloadOutline,
|
||||
downloadSharp,
|
||||
ellipsisVertical,
|
||||
logOutOutline,
|
||||
logOutSharp,
|
||||
menuSharp,
|
||||
refreshOutline,
|
||||
refreshSharp,
|
||||
warningOutline,
|
||||
warningSharp,
|
||||
} from "ionicons/icons";
|
||||
import {FC, useEffect, useRef, useState} from "react";
|
||||
import {useHistory} from "react-router-dom";
|
||||
|
||||
import AddToHomeScreen from "~/assets/icons/md-add-to-home-screen.svg?react";
|
||||
import PlusApp from "~/assets/icons/sf-symbols-plus.app.svg?react";
|
||||
import SquareAndArrowUp from "~/assets/icons/sf-symbols-square.and.arrow.up.svg?react";
|
||||
import {useEphemeralStore} from "~/lib/stores/ephemeral";
|
||||
import {usePersistentStore} from "~/lib/stores/persistent";
|
||||
import {client} from "~/lib/supabase";
|
||||
import {GlobalMessageMetadata, MeasurementSystem, Theme} from "~/lib/types";
|
||||
import {GIT_BRANCH, GIT_COMMIT, VERSION} from "~/lib/vars";
|
||||
import styles from "~/pages/settings.module.css";
|
||||
|
||||
/**
|
||||
* Account deleted message metadata
|
||||
*/
|
||||
const ACCOUNT_DELETED_MESSAGE_METADATA: GlobalMessageMetadata = {
|
||||
symbol: Symbol("settings.account-deleted"),
|
||||
name: "Account Deleted",
|
||||
description: "Your account has been successfully deleted",
|
||||
};
|
||||
|
||||
/**
|
||||
* Settings page
|
||||
* @returns JSX
|
||||
*/
|
||||
export const Settings: FC = () => {
|
||||
// Hooks
|
||||
const [beforeInstallPromptEvent, setBeforeInstallPromptEvent] =
|
||||
useState<BeforeInstallPromptEvent>();
|
||||
|
||||
const [appInstalled, setAppInstalled] = useState(
|
||||
window.matchMedia("(display-mode: standalone)").matches,
|
||||
);
|
||||
|
||||
const appInstallInstructionsModal = useRef<HTMLIonModalElement>(null);
|
||||
|
||||
const setMessage = useEphemeralStore(state => state.setMessage);
|
||||
const theme = usePersistentStore(state => state.theme);
|
||||
const setTheme = usePersistentStore(state => state.setTheme);
|
||||
const showFABs = usePersistentStore(state => state.showFABs);
|
||||
const setShowFABs = usePersistentStore(state => state.setShowFABs);
|
||||
const slidingActions = usePersistentStore(state => state.useSlidingActions);
|
||||
|
||||
const setSlidingActions = usePersistentStore(
|
||||
state => state.setUseSlidingActions,
|
||||
);
|
||||
|
||||
const showAmbientEffect = usePersistentStore(
|
||||
state => state.showAmbientEffect,
|
||||
);
|
||||
|
||||
const setShowAmbientEffect = usePersistentStore(
|
||||
state => state.setShowAmbientEffect,
|
||||
);
|
||||
|
||||
const measurementSystem = usePersistentStore(
|
||||
state => state.measurementSystem,
|
||||
);
|
||||
|
||||
const setMeasurementSystem = usePersistentStore(
|
||||
state => state.setMeasurementSystem,
|
||||
);
|
||||
|
||||
const reset = usePersistentStore(state => state.reset);
|
||||
|
||||
const history = useHistory();
|
||||
const [present] = useIonActionSheet();
|
||||
|
||||
// Methods
|
||||
/**
|
||||
* Begin the app installation process
|
||||
*/
|
||||
const installApp = async () => {
|
||||
await (beforeInstallPromptEvent === undefined
|
||||
? appInstallInstructionsModal.current?.present()
|
||||
: beforeInstallPromptEvent.prompt());
|
||||
};
|
||||
|
||||
/**
|
||||
* Reset all settings
|
||||
* @returns Promise
|
||||
*/
|
||||
const resetSettings = () =>
|
||||
present({
|
||||
header: "Reset all settings",
|
||||
subHeader:
|
||||
"Are you sure you want to reset all settings? You will be signed out.",
|
||||
buttons: [
|
||||
{
|
||||
text: "Cancel",
|
||||
role: "cancel",
|
||||
},
|
||||
{
|
||||
text: "Reset",
|
||||
role: "destructive",
|
||||
handler: reset,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
/**
|
||||
* Sign out
|
||||
* @returns Promise
|
||||
*/
|
||||
const signOut = () =>
|
||||
present({
|
||||
header: "Sign out",
|
||||
subHeader: "Are you sure you want to sign out?",
|
||||
buttons: [
|
||||
{
|
||||
text: "Cancel",
|
||||
role: "cancel",
|
||||
},
|
||||
{
|
||||
text: "Sign out",
|
||||
role: "destructive",
|
||||
/**
|
||||
* Sign out handler
|
||||
*/
|
||||
handler: async () => {
|
||||
// Sign out
|
||||
await client.auth.signOut();
|
||||
|
||||
// Redirect to the home
|
||||
history.push("/");
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
/**
|
||||
* Delete account
|
||||
* @returns Promise
|
||||
*/
|
||||
const deleteAccount = () =>
|
||||
present({
|
||||
header: "Delete Your Account",
|
||||
subHeader:
|
||||
"Are you sure you want to delete your account? This action cannot be undone.",
|
||||
buttons: [
|
||||
{
|
||||
text: "Cancel",
|
||||
role: "cancel",
|
||||
},
|
||||
{
|
||||
text: "Delete My Account",
|
||||
role: "destructive",
|
||||
/**
|
||||
* Delete account handler
|
||||
*/
|
||||
handler: async () => {
|
||||
// Delete the account
|
||||
const {error} = await client.rpc("delete_account");
|
||||
|
||||
// Handle error
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Sign out
|
||||
await client.auth.signOut();
|
||||
|
||||
// Redirect to the home
|
||||
history.push("/");
|
||||
|
||||
// Display the message
|
||||
setMessage(ACCOUNT_DELETED_MESSAGE_METADATA);
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Effects
|
||||
useEffect(() => {
|
||||
// Capture the installed event
|
||||
window.addEventListener("appinstalled", () => setAppInstalled(true));
|
||||
|
||||
// Capture the installable event
|
||||
window.addEventListener(
|
||||
"beforeinstallprompt",
|
||||
event => {
|
||||
event.preventDefault();
|
||||
setBeforeInstallPromptEvent(event as BeforeInstallPromptEvent);
|
||||
},
|
||||
{
|
||||
once: true,
|
||||
},
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<IonPage>
|
||||
<IonHeader className="ion-no-border">
|
||||
<IonToolbar>
|
||||
<IonButtons slot="start">
|
||||
<IonMenuButton />
|
||||
</IonButtons>
|
||||
|
||||
<IonTitle>Settings</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<IonModal
|
||||
className={styles.modal}
|
||||
ref={appInstallInstructionsModal}
|
||||
initialBreakpoint={1}
|
||||
breakpoints={[0, 1]}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center p-4 w-full">
|
||||
<h2 className="font-bold mb-2 text-center text-lg">
|
||||
App Install Instructions
|
||||
</h2>
|
||||
|
||||
<p className="mb-2">
|
||||
This website is a Progresive Web App (PWA), meaning it has app
|
||||
functionality. You can install it on your device by following the
|
||||
below instructions:
|
||||
</p>
|
||||
|
||||
<ol className="list-decimal ml-4">
|
||||
{(() => {
|
||||
if (isPlatform("ios")) {
|
||||
return (
|
||||
<>
|
||||
<li>
|
||||
Press the share button on the menu bar below.
|
||||
<div className="my-4 w-full">
|
||||
<SquareAndArrowUp className="dark:fill-[#4693ff] dark:stroke-[#4693ff] fill-[#007aff] h-16 mx-auto stroke-[#007aff] w-16" />
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
Select <q>Add to Home Screen</q>.
|
||||
<div className="my-4 w-full">
|
||||
<PlusApp className="dark:fill-white dark:stroke-white fill-black h-14 mx-auto stroke-black w-14" />
|
||||
</div>
|
||||
</li>
|
||||
</>
|
||||
);
|
||||
} else if (isPlatform("android")) {
|
||||
return (
|
||||
<>
|
||||
<li>
|
||||
Press the three dots on the menu bar above.
|
||||
<div className="my-4 w-full">
|
||||
<IonIcon
|
||||
className="block dark:fill-white dark:stroke-white fill-black h-14 mx-auto stroke-black w-14"
|
||||
icon={ellipsisVertical}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
Select <q>Add to Home screen</q>.
|
||||
<div className="my-4 w-full">
|
||||
<AddToHomeScreen className="dark:fill-white dark:stroke-white fill-black h-16 mx-auto stroke-black w-16" />
|
||||
</div>
|
||||
</li>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<li>
|
||||
Open your browser's menu.
|
||||
<div className="my-4 w-full">
|
||||
<IonIcon
|
||||
className="block dark:fill-white dark:stroke-white fill-black h-16 mx-auto stroke-black w-16"
|
||||
icon={menuSharp}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
Select <q>Add to Home Screen</q>, <q>Install Beacon</q>,
|
||||
or similar option.
|
||||
<div className="my-4 w-full">
|
||||
<AddToHomeScreen className="dark:fill-white dark:stroke-white fill-black h-16 mx-auto stroke-black w-16" />
|
||||
</div>
|
||||
</li>
|
||||
</>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
</ol>
|
||||
</div>
|
||||
</IonModal>
|
||||
|
||||
<IonContent color="light">
|
||||
<IonList className="py-0" inset={true}>
|
||||
<IonItemGroup>
|
||||
<IonItemDivider>
|
||||
<IonLabel>Look and Feel</IonLabel>
|
||||
</IonItemDivider>
|
||||
<IonItem>
|
||||
<IonSelect
|
||||
interface="action-sheet"
|
||||
interfaceOptions={{
|
||||
header: "Theme",
|
||||
subHeader: "Select your preferred theme",
|
||||
}}
|
||||
label="Theme"
|
||||
labelPlacement="floating"
|
||||
onIonChange={event => setTheme(event.detail.value)}
|
||||
value={theme}
|
||||
>
|
||||
<IonSelectOption value={Theme.LIGHT}>Light</IonSelectOption>
|
||||
<IonSelectOption value={Theme.DARK}>Dark</IonSelectOption>
|
||||
</IonSelect>
|
||||
</IonItem>
|
||||
|
||||
<IonItem>
|
||||
<IonToggle
|
||||
checked={showFABs}
|
||||
onIonChange={event => setShowFABs(event.detail.checked)}
|
||||
>
|
||||
Show floating action buttons
|
||||
</IonToggle>
|
||||
</IonItem>
|
||||
|
||||
<IonItem>
|
||||
<IonToggle
|
||||
checked={slidingActions}
|
||||
onIonChange={event => setSlidingActions(event.detail.checked)}
|
||||
>
|
||||
Use sliding actions on posts
|
||||
</IonToggle>
|
||||
</IonItem>
|
||||
|
||||
<IonItem>
|
||||
<IonToggle
|
||||
checked={showAmbientEffect}
|
||||
onIonChange={event =>
|
||||
setShowAmbientEffect(event.detail.checked)
|
||||
}
|
||||
>
|
||||
Show ambient effect below posts
|
||||
</IonToggle>
|
||||
</IonItem>
|
||||
|
||||
<IonItem>
|
||||
<IonSelect
|
||||
interface="action-sheet"
|
||||
interfaceOptions={{
|
||||
header: "Measurement system",
|
||||
subHeader: "Select your preferred measurement system",
|
||||
}}
|
||||
label="Measurement system"
|
||||
labelPlacement="floating"
|
||||
onIonChange={event => setMeasurementSystem(event.detail.value)}
|
||||
value={measurementSystem}
|
||||
>
|
||||
<IonSelectOption value={MeasurementSystem.METRIC}>
|
||||
Metric
|
||||
</IonSelectOption>
|
||||
<IonSelectOption value={MeasurementSystem.IMPERIAL}>
|
||||
Imperial
|
||||
</IonSelectOption>
|
||||
</IonSelect>
|
||||
</IonItem>
|
||||
</IonItemGroup>
|
||||
|
||||
<IonItemGroup>
|
||||
<IonItemDivider>
|
||||
<IonLabel>Miscellaneous</IonLabel>
|
||||
</IonItemDivider>
|
||||
|
||||
<IonItem button={true} disabled={appInstalled} onClick={installApp}>
|
||||
<IonLabel>
|
||||
Install app {appInstalled ? "(Already Installed)" : ""}
|
||||
</IonLabel>
|
||||
<IonIcon
|
||||
color="success"
|
||||
slot="end"
|
||||
ios={downloadOutline}
|
||||
md={downloadSharp}
|
||||
/>
|
||||
</IonItem>
|
||||
|
||||
<IonItem button={true} onClick={resetSettings}>
|
||||
<IonLabel>Reset all settings</IonLabel>
|
||||
<IonIcon
|
||||
color="danger"
|
||||
slot="end"
|
||||
ios={refreshOutline}
|
||||
md={refreshSharp}
|
||||
/>
|
||||
</IonItem>
|
||||
</IonItemGroup>
|
||||
|
||||
<IonItemGroup>
|
||||
<IonItemDivider>
|
||||
<IonLabel>Account</IonLabel>
|
||||
</IonItemDivider>
|
||||
|
||||
<IonItem button={true} onClick={signOut}>
|
||||
<IonLabel>Sign out</IonLabel>
|
||||
<IonIcon
|
||||
color="danger"
|
||||
slot="end"
|
||||
ios={logOutOutline}
|
||||
md={logOutSharp}
|
||||
/>
|
||||
</IonItem>
|
||||
|
||||
<IonItem button={true} onClick={deleteAccount}>
|
||||
<IonLabel>Delete Account</IonLabel>
|
||||
<IonIcon
|
||||
color="danger"
|
||||
slot="end"
|
||||
ios={warningOutline}
|
||||
md={warningSharp}
|
||||
/>
|
||||
</IonItem>
|
||||
</IonItemGroup>
|
||||
|
||||
<IonItemGroup>
|
||||
<IonItemDivider>
|
||||
<IonLabel>About</IonLabel>
|
||||
</IonItemDivider>
|
||||
|
||||
<IonItem>
|
||||
<IonLabel>Version</IonLabel>
|
||||
<IonNote slot="end">{VERSION}</IonNote>
|
||||
</IonItem>
|
||||
|
||||
<IonItem>
|
||||
<IonLabel>Branch</IonLabel>
|
||||
<IonNote slot="end">{GIT_BRANCH}</IonNote>
|
||||
</IonItem>
|
||||
|
||||
<IonItem>
|
||||
<IonLabel>Commit</IonLabel>
|
||||
<IonNote slot="end">{GIT_COMMIT}</IonNote>
|
||||
</IonItem>
|
||||
</IonItemGroup>
|
||||
|
||||
<IonItemGroup>
|
||||
<IonItemDivider>
|
||||
<IonLabel>Links</IonLabel>
|
||||
</IonItemDivider>
|
||||
|
||||
<IonItem routerLink="/faq">
|
||||
<IonLabel>Frequently Asked Questions</IonLabel>
|
||||
</IonItem>
|
||||
|
||||
<IonItem routerLink="/terms-and-conditions">
|
||||
<IonLabel>Terms and Conditions</IonLabel>
|
||||
</IonItem>
|
||||
|
||||
<IonItem routerLink="/privacy-policy">
|
||||
<IonLabel>Privacy Policy</IonLabel>
|
||||
</IonItem>
|
||||
|
||||
<IonItem
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
href="https://github.com/ColoradoSchoolOfMines/Beacon"
|
||||
>
|
||||
<IonLabel>Source code</IonLabel>
|
||||
</IonItem>
|
||||
|
||||
<IonItem
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
href="https://github.com/ColoradoSchoolOfMines/beacon/issues/new/choose"
|
||||
>
|
||||
<IonLabel>Bug report/feature request</IonLabel>
|
||||
</IonItem>
|
||||
</IonItemGroup>
|
||||
</IonList>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
189
99_references/beacon-main/src/theme.css
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* @file Ionic theme
|
||||
* @see http://ionicframework.com/docs/theming/
|
||||
*/
|
||||
|
||||
/* Default variables */
|
||||
:root {
|
||||
/* primary */
|
||||
--ion-color-primary: #51c5db;
|
||||
--ion-color-primary-rgb: 81, 197, 219;
|
||||
--ion-color-primary-contrast: #ffffff;
|
||||
--ion-color-primary-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-primary-shade: #419eaf;
|
||||
--ion-color-primary-tint: #74d1e2;
|
||||
|
||||
/* secondary */
|
||||
--ion-color-secondary: #f69876;
|
||||
--ion-color-secondary-rgb: 221, 137, 106;
|
||||
--ion-color-secondary-contrast: #ffffff;
|
||||
--ion-color-secondary-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-secondary-shade: #b16e55;
|
||||
--ion-color-secondary-tint: #e4a188;
|
||||
|
||||
/* tertiary */
|
||||
--ion-color-tertiary: #5260ff;
|
||||
--ion-color-tertiary-rgb: 82, 96, 255;
|
||||
--ion-color-tertiary-contrast: #ffffff;
|
||||
--ion-color-tertiary-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-tertiary-shade: #4854e0;
|
||||
--ion-color-tertiary-tint: #6370ff;
|
||||
|
||||
/* success */
|
||||
--ion-color-success: #2dd36f;
|
||||
--ion-color-success-rgb: 45, 211, 111;
|
||||
--ion-color-success-contrast: #ffffff;
|
||||
--ion-color-success-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-success-shade: #28ba62;
|
||||
--ion-color-success-tint: #42d77d;
|
||||
|
||||
/* warning */
|
||||
--ion-color-warning: #ffc409;
|
||||
--ion-color-warning-rgb: 255, 196, 9;
|
||||
--ion-color-warning-contrast: #000000;
|
||||
--ion-color-warning-contrast-rgb: 0, 0, 0;
|
||||
--ion-color-warning-shade: #e0ac08;
|
||||
--ion-color-warning-tint: #ffca22;
|
||||
|
||||
/* danger */
|
||||
--ion-color-danger: #eb445a;
|
||||
--ion-color-danger-rgb: 235, 68, 90;
|
||||
--ion-color-danger-contrast: #ffffff;
|
||||
--ion-color-danger-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-danger-shade: #cf3c4f;
|
||||
--ion-color-danger-tint: #ed576b;
|
||||
|
||||
/* dark */
|
||||
--ion-color-dark: #222428;
|
||||
--ion-color-dark-rgb: 34, 36, 40;
|
||||
--ion-color-dark-contrast: #ffffff;
|
||||
--ion-color-dark-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-dark-shade: #1e2023;
|
||||
--ion-color-dark-tint: #383a3e;
|
||||
|
||||
/* medium */
|
||||
--ion-color-medium: #92949c;
|
||||
--ion-color-medium-rgb: 146, 148, 156;
|
||||
--ion-color-medium-contrast: #ffffff;
|
||||
--ion-color-medium-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-medium-shade: #808289;
|
||||
--ion-color-medium-tint: #9d9fa6;
|
||||
|
||||
/* light */
|
||||
--ion-color-light: #f4f5f8;
|
||||
--ion-color-light-rgb: 244, 245, 248;
|
||||
--ion-color-light-contrast: #000000;
|
||||
--ion-color-light-contrast-rgb: 0, 0, 0;
|
||||
--ion-color-light-shade: #d7d8da;
|
||||
--ion-color-light-tint: #f5f6f9;
|
||||
}
|
||||
|
||||
/* Dark mode variables */
|
||||
:root.dark {
|
||||
--ion-color-tertiary: #6a64ff;
|
||||
--ion-color-tertiary-rgb: 106, 100, 255;
|
||||
--ion-color-tertiary-contrast: #ffffff;
|
||||
--ion-color-tertiary-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-tertiary-shade: #5d58e0;
|
||||
--ion-color-tertiary-tint: #7974ff;
|
||||
|
||||
--ion-color-success: #2fdf75;
|
||||
--ion-color-success-rgb: 47, 223, 117;
|
||||
--ion-color-success-contrast: #000000;
|
||||
--ion-color-success-contrast-rgb: 0, 0, 0;
|
||||
--ion-color-success-shade: #29c467;
|
||||
--ion-color-success-tint: #44e283;
|
||||
|
||||
--ion-color-warning: #ffd534;
|
||||
--ion-color-warning-rgb: 255, 213, 52;
|
||||
--ion-color-warning-contrast: #000000;
|
||||
--ion-color-warning-contrast-rgb: 0, 0, 0;
|
||||
--ion-color-warning-shade: #e0bb2e;
|
||||
--ion-color-warning-tint: #ffd948;
|
||||
|
||||
--ion-color-danger: #ff4961;
|
||||
--ion-color-danger-rgb: 255, 73, 97;
|
||||
--ion-color-danger-contrast: #ffffff;
|
||||
--ion-color-danger-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-danger-shade: #e04055;
|
||||
--ion-color-danger-tint: #ff5b71;
|
||||
|
||||
--ion-color-dark: #f4f5f8;
|
||||
--ion-color-dark-rgb: 244, 245, 248;
|
||||
--ion-color-dark-contrast: #000000;
|
||||
--ion-color-dark-contrast-rgb: 0, 0, 0;
|
||||
--ion-color-dark-shade: #d7d8da;
|
||||
--ion-color-dark-tint: #f5f6f9;
|
||||
|
||||
--ion-color-medium: #989aa2;
|
||||
--ion-color-medium-rgb: 152, 154, 162;
|
||||
--ion-color-medium-contrast: #000000;
|
||||
--ion-color-medium-contrast-rgb: 0, 0, 0;
|
||||
--ion-color-medium-shade: #86888f;
|
||||
--ion-color-medium-tint: #a2a4ab;
|
||||
|
||||
--ion-color-light: #222428;
|
||||
--ion-color-light-rgb: 34, 36, 40;
|
||||
--ion-color-light-contrast: #ffffff;
|
||||
--ion-color-light-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-light-shade: #1e2023;
|
||||
--ion-color-light-tint: #383a3e;
|
||||
|
||||
--ion-color-step-50: #0d0d0d;
|
||||
--ion-color-step-100: #1a1a1a;
|
||||
--ion-color-step-150: #262626;
|
||||
--ion-color-step-200: #333333;
|
||||
--ion-color-step-250: #404040;
|
||||
--ion-color-step-300: #4d4d4d;
|
||||
--ion-color-step-350: #595959;
|
||||
--ion-color-step-400: #666666;
|
||||
--ion-color-step-450: #737373;
|
||||
--ion-color-step-500: #808080;
|
||||
--ion-color-step-550: #8c8c8c;
|
||||
--ion-color-step-600: #999999;
|
||||
--ion-color-step-650: #a6a6a6;
|
||||
--ion-color-step-700: #b3b3b3;
|
||||
--ion-color-step-750: #bfbfbf;
|
||||
--ion-color-step-800: #cccccc;
|
||||
--ion-color-step-850: #d9d9d9;
|
||||
--ion-color-step-900: #e6e6e6;
|
||||
--ion-color-step-950: #f2f2f2;
|
||||
}
|
||||
|
||||
/* iOS dark mode variables */
|
||||
:root.dark.ios {
|
||||
--ion-background-color: #000000;
|
||||
--ion-background-color-rgb: 0, 0, 0;
|
||||
|
||||
--ion-text-color: #ffffff;
|
||||
--ion-text-color-rgb: 255, 255, 255;
|
||||
|
||||
--ion-item-background: #000000;
|
||||
|
||||
--ion-card-background: #1c1c1d;
|
||||
}
|
||||
|
||||
:root.dark.ios ion-modal {
|
||||
--ion-background-color: var(--ion-color-step-100);
|
||||
--ion-toolbar-background: var(--ion-color-step-150);
|
||||
--ion-toolbar-border-color: var(--ion-color-step-250);
|
||||
}
|
||||
|
||||
/* Material Design dark mode variables */
|
||||
:root.dark.md {
|
||||
--ion-background-color: #0c0c0c;
|
||||
--ion-background-color-rgb: 12, 12, 12;
|
||||
|
||||
--ion-text-color: #ffffff;
|
||||
--ion-text-color-rgb: 255, 255, 255;
|
||||
|
||||
--ion-border-color: #222222;
|
||||
|
||||
--ion-item-background: #141414;
|
||||
|
||||
--ion-toolbar-background: #1f1f1f;
|
||||
|
||||
--ion-tab-bar-background: #1f1f1f;
|
||||
|
||||
--ion-card-background: #141414;
|
||||
}
|
178
99_references/beacon-main/supabase/config.toml
Normal file
@@ -0,0 +1,178 @@
|
||||
# A string used to distinguish different Supabase projects on the same host. Defaults to the
|
||||
# working directory name when running `supabase init`.
|
||||
project_id = "beacon"
|
||||
|
||||
[api]
|
||||
enabled = true
|
||||
# Port to use for the API URL.
|
||||
port = 54321
|
||||
# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
|
||||
# endpoints. `public` is always included.
|
||||
schemas = ["public", "graphql_public"]
|
||||
# Extra schemas to add to the search_path of every request. `public` is always included.
|
||||
extra_search_path = ["public", "extensions"]
|
||||
# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
|
||||
# for accidental or malicious requests.
|
||||
max_rows = 1000
|
||||
|
||||
[db]
|
||||
# Port to use for the local database URL.
|
||||
port = 54322
|
||||
# Port used by db diff command to initialize the shadow database.
|
||||
shadow_port = 54320
|
||||
# The database major version to use. This has to be the same as your remote database's. Run `SHOW
|
||||
# server_version;` on the remote database to check.
|
||||
major_version = 15
|
||||
|
||||
[db.pooler]
|
||||
enabled = false
|
||||
# Port to use for the local connection pooler.
|
||||
port = 54329
|
||||
# Specifies when a server connection can be reused by other clients.
|
||||
# Configure one of the supported pooler modes: `transaction`, `session`.
|
||||
pool_mode = "transaction"
|
||||
# How many server connections to allow per user/database pair.
|
||||
default_pool_size = 20
|
||||
# Maximum number of client connections allowed.
|
||||
max_client_conn = 100
|
||||
|
||||
[realtime]
|
||||
enabled = true
|
||||
# Bind realtime via either IPv4 or IPv6. (default: IPv4)
|
||||
# ip_version = "IPv6"
|
||||
# The maximum length in bytes of HTTP request headers. (default: 4096)
|
||||
# max_header_length = 4096
|
||||
|
||||
[studio]
|
||||
enabled = true
|
||||
# Port to use for Supabase Studio.
|
||||
port = 54323
|
||||
# External URL of the API server that frontend connects to.
|
||||
api_url = "http://127.0.0.1"
|
||||
# OpenAI API Key to use for Supabase AI in the Supabase Studio.
|
||||
openai_api_key = "env(OPENAI_API_KEY)"
|
||||
|
||||
# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
|
||||
# are monitored, and you can view the emails that would have been sent from the web interface.
|
||||
[inbucket]
|
||||
enabled = true
|
||||
# Port to use for the email testing server web interface.
|
||||
port = 54324
|
||||
# Uncomment to expose additional ports for testing user applications that send emails.
|
||||
# smtp_port = 54325
|
||||
# pop3_port = 54326
|
||||
|
||||
[storage]
|
||||
enabled = true
|
||||
# The maximum file size allowed (e.g. "5MB", "500KB").
|
||||
file_size_limit = "50MiB"
|
||||
|
||||
[storage.image_transformation]
|
||||
enabled = true
|
||||
|
||||
[auth]
|
||||
enabled = true
|
||||
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
|
||||
# in emails.
|
||||
site_url = "http://127.0.0.1:3000"
|
||||
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
|
||||
additional_redirect_urls = ["https://127.0.0.1:3000"]
|
||||
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
|
||||
jwt_expiry = 3600
|
||||
# If disabled, the refresh token will never expire.
|
||||
enable_refresh_token_rotation = true
|
||||
# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.
|
||||
# Requires enable_refresh_token_rotation = true.
|
||||
refresh_token_reuse_interval = 10
|
||||
# Allow/disallow new user signups to your project.
|
||||
enable_signup = true
|
||||
# Allow/disallow anonymous sign-ins to your project.
|
||||
enable_anonymous_sign_ins = false
|
||||
# Allow/disallow testing manual linking of accounts
|
||||
enable_manual_linking = false
|
||||
|
||||
[auth.email]
|
||||
# Allow/disallow new user signups via email to your project.
|
||||
enable_signup = true
|
||||
# If enabled, a user will be required to confirm any email change on both the old, and new email
|
||||
# addresses. If disabled, only the new email is required to confirm.
|
||||
double_confirm_changes = true
|
||||
# If enabled, users need to confirm their email address before signing in.
|
||||
enable_confirmations = false
|
||||
# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email.
|
||||
max_frequency = "1s"
|
||||
|
||||
# Uncomment to customize email template
|
||||
# [auth.email.template.invite]
|
||||
# subject = "You have been invited"
|
||||
# content_path = "./supabase/templates/invite.html"
|
||||
|
||||
[auth.sms]
|
||||
# Allow/disallow new user signups via SMS to your project.
|
||||
enable_signup = true
|
||||
# If enabled, users need to confirm their phone number before signing in.
|
||||
enable_confirmations = false
|
||||
# Template for sending OTP to users
|
||||
template = "Your code is {{ `{{ .Code }}` }} ."
|
||||
# Controls the minimum amount of time that must pass before sending another sms otp.
|
||||
max_frequency = "5s"
|
||||
|
||||
# Use pre-defined map of phone number to OTP for testing.
|
||||
# [auth.sms.test_otp]
|
||||
# 4152127777 = "123456"
|
||||
|
||||
# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used.
|
||||
# [auth.hook.custom_access_token]
|
||||
# enabled = true
|
||||
# uri = "pg-functions://<database>/<schema>/<hook_name>"
|
||||
|
||||
# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`.
|
||||
[auth.sms.twilio]
|
||||
enabled = false
|
||||
account_sid = ""
|
||||
message_service_sid = ""
|
||||
# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead:
|
||||
auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)"
|
||||
|
||||
# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`,
|
||||
# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`,
|
||||
# `twitter`, `slack`, `spotify`, `workos`, `zoom`.
|
||||
[auth.external.apple]
|
||||
enabled = false
|
||||
client_id = ""
|
||||
# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead:
|
||||
secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)"
|
||||
# Overrides the default auth redirectUrl.
|
||||
redirect_uri = ""
|
||||
# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
|
||||
# or any other third-party OIDC providers.
|
||||
url = ""
|
||||
# If enabled, the nonce check will be skipped. Required for local sign in with Google auth.
|
||||
skip_nonce_check = false
|
||||
|
||||
[edge_runtime]
|
||||
enabled = true
|
||||
# Configure one of the supported request policies: `oneshot`, `per_worker`.
|
||||
# Use `oneshot` for hot reload, or `per_worker` for load testing.
|
||||
policy = "oneshot"
|
||||
inspector_port = 8083
|
||||
|
||||
[analytics]
|
||||
enabled = false
|
||||
port = 54327
|
||||
vector_port = 54328
|
||||
# Configure one of the supported backends: `postgres`, `bigquery`.
|
||||
backend = "postgres"
|
||||
|
||||
# # Experimental features may be deprecated any time
|
||||
# [experimental]
|
||||
# # Configures Postgres storage engine to use OrioleDB (S3)
|
||||
# orioledb_version = "{{if .UseOrioleDB}}15.1.0.150{{end}}"
|
||||
# # Configures S3 bucket URL, eg. <bucket_name>.s3-<region>.amazonaws.com
|
||||
# s3_host = "env(S3_HOST)"
|
||||
# # Configures S3 bucket region, eg. us-east-1
|
||||
# s3_region = "env(S3_REGION)"
|
||||
# # Configures AWS_ACCESS_KEY_ID for S3 bucket
|
||||
# s3_access_key = "env(S3_ACCESS_KEY)"
|
||||
# # Configures AWS_SECRET_ACCESS_KEY for S3 bucket
|
||||
# s3_secret_key = "env(S3_SECRET_KEY)"
|
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Setup miscellaneous things before the main setup
|
||||
*/
|
||||
|
||||
/* --------------------------------------- Setup schemas --------------------------------------- */
|
||||
|
||||
-- Utilities (Non-public helpers)
|
||||
CREATE SCHEMA IF NOT EXISTS utilities;
|
||||
|
||||
/* -------------------------------------- Setup extensions ------------------------------------- */
|
||||
|
||||
-- PostGIS
|
||||
CREATE EXTENSION
|
||||
IF NOT EXISTS postgis
|
||||
WITH SCHEMA extensions;
|
||||
|
||||
-- pg_cron
|
||||
CREATE EXTENSION
|
||||
IF NOT EXISTS pg_cron
|
||||
WITH SCHEMA extensions;
|
||||
|
||||
GRANT USAGE ON SCHEMA cron TO postgres;
|
||||
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA cron TO postgres;
|
||||
|
||||
/* --------------------------------------- Setup buckets --------------------------------------- */
|
||||
|
||||
-- Media
|
||||
INSERT INTO storage.buckets (
|
||||
id,
|
||||
name,
|
||||
public,
|
||||
file_size_limit,
|
||||
allowed_mime_types
|
||||
) VALUES (
|
||||
'media',
|
||||
'media',
|
||||
TRUE,
|
||||
4194304, -- 4 MiB
|
||||
ARRAY[
|
||||
-- Images
|
||||
'image/avif',
|
||||
'image/gif',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
|
||||
-- Videos
|
||||
'video/mp4',
|
||||
'video/mpeg',
|
||||
'video/webm'
|
||||
]
|
||||
);
|
@@ -0,0 +1,664 @@
|
||||
/**
|
||||
* Setup routines
|
||||
*/
|
||||
|
||||
/* ---------------------------------- Private utility routines --------------------------------- */
|
||||
|
||||
-- Generate a random double precision number between 0 (inclusive) and 1 (exclusive), using crypto-safe random data
|
||||
--
|
||||
-- This function has been verified to produce a uniform distribution of values using a one-sample Kolmogorov-Smirnov
|
||||
-- test with a null hypothesis of perfect uniform distribution with a p value of exactly 0.0 (Less round-off errors)
|
||||
-- for a sample size of 1 million.
|
||||
--
|
||||
-- Note that because this function uses rejection-sampling, timing attacks are hypothetically possible, especially
|
||||
-- if the RNG is predictable (Though that in itself represents a rather grave security concern).
|
||||
CREATE OR REPLACE FUNCTION utilities.safe_random()
|
||||
RETURNS DOUBLE PRECISION
|
||||
VOLATILE
|
||||
LANGUAGE plpgsql
|
||||
SET search_path = ''
|
||||
AS $$
|
||||
DECLARE
|
||||
-- Maximum value of a BIGINT as a double precision number
|
||||
_max CONSTANT DOUBLE PRECISION := (~(1::BIGINT << 63))::DOUBLE PRECISION;
|
||||
|
||||
-- Current random value
|
||||
_value DOUBLE PRECISION;
|
||||
BEGIN
|
||||
LOOP
|
||||
-- Generate 8 crypto-safe random bytes, convert them to a bit string, set the MSB to 0 (Make positive), convert to a double, and normalize
|
||||
_value = SET_BIT(RIGHT(extensions.gen_random_bytes(8)::TEXT, -1)::BIT(64), 0, 0)::BIGINT::DOUBLE PRECISION / _max;
|
||||
|
||||
-- Return if the value is not 1 (The probability of this happening is very close to 0, but this guarentees the returned value is never 1)
|
||||
IF _value < 1 THEN
|
||||
RETURN _value;
|
||||
END IF;
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Get a random color
|
||||
CREATE OR REPLACE FUNCTION utilities.get_random_color()
|
||||
RETURNS TEXT
|
||||
VOLATILE
|
||||
LANGUAGE plpgsql
|
||||
SET search_path = ''
|
||||
AS $$
|
||||
DECLARE
|
||||
-- Possible colors (Select Tailwind colors using the 300, 500, and 800 variants; similar colors have been removed)
|
||||
_colors CONSTANT TEXT[] := ARRAY[
|
||||
-- Reds
|
||||
'#fca5a5', '#ef4444', '#991b1b',
|
||||
|
||||
-- Oranges
|
||||
'#fdba74', '#f97316', '#9a3412',
|
||||
|
||||
-- Yellows
|
||||
'#fde047', '#eab308', '#854d0e',
|
||||
|
||||
-- Limes
|
||||
'#bef264', '#84cc16', '#3f6212',
|
||||
|
||||
-- Greens
|
||||
'#86efac', '#22c55e', '#166534',
|
||||
|
||||
-- Teals
|
||||
'#5eead4', '#14b8a6', '#115e59',
|
||||
|
||||
-- Skies
|
||||
'#7dd3fc', '#0ea5e9', '#075985',
|
||||
|
||||
-- Blues
|
||||
'#93c5fd', '#3b82f6', '#1e40af',
|
||||
|
||||
-- Indigos
|
||||
'#a5b4fc', '#6366f1', '#3730a3',
|
||||
|
||||
-- Purples
|
||||
'#d8b4fe', '#a855f7', '#6b21a8',
|
||||
|
||||
-- Fuchsias
|
||||
'#f0abfc', '#d946ef', '#86198f'
|
||||
];
|
||||
|
||||
-- Color length
|
||||
_color_length CONSTANT INTEGER := ARRAY_LENGTH(_colors, 1);
|
||||
BEGIN
|
||||
RETURN _colors[FLOOR(utilities.safe_random() * _color_length) + 1];
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Get a random emoji
|
||||
CREATE OR REPLACE FUNCTION utilities.get_random_emoji()
|
||||
RETURNS TEXT
|
||||
VOLATILE
|
||||
LANGUAGE plpgsql
|
||||
SET search_path = ''
|
||||
AS $$
|
||||
DECLARE
|
||||
-- Possible emojis (Similar, hard-to-identify, or any other emojis that would otherwise be strange to use for an avatar have been removed)
|
||||
_emojis CONSTANT TEXT[] := ARRAY[
|
||||
'⌚️', '⏰', '⏳', '☀️', '☁️', '☢️', '☣️', '♻️', '⚓️', '⚛️', '⚠️', '⚡️', '⚽️', '⚾️', '⛄️', '⛏', '⛔️', '⛩', '⛰', '⛱', '⛳️', '⛵️', '⛸', '✂️', '✅', '✈️', '❄️', '❌', '❎', '❓', '❗️', '❤️', '⭐️', '⭕️', '🌈', '🌊', '🌋', '🌎', '🌐', '🌑', '🌕', '🌗', '🌡', '🌪', '🌱', '🌲', '🌳', '🌴', '🌵', '🌶', '🌷', '🌻', '🌽', '🍀', '🍁', '🍂', '🍄', '🍅', '🍆', '🍇', '🍉', '🍊', '🍋', '🍌', '🍍', '🍎', '🍐', '🍑', '🍒', '🍓', '🍥', '🍦', '🍩', '🍪', '🍫', '🍬', '🍭', '🍰', '🍷', '🍸', '🍺', '🍿', '🎀', '🎁', '🎃', '🎈', '🎉', '🎗', '🎟', '🎡', '🎢', '🎤', '🎥', '🎧', '🎨', '🎩', '🎪', '🎬', '🎭', '🎮', '🎯', '🎰', '🎱', '🎲', '🎳', '🎵', '🎷', '🎸', '🎹', '🎺', '🎻', '🏀', '🏅', '🏆', '🏈', '🏉', '🏍', '🏐', '🏓', '🏕', '🏗', '🏝', '🏟', '🏠', '🏢', '🏭', '🏮', '🏯', '🏰', '🏹', '🐁', '🐅', '🐇', '🐊', '🐌', '🐍', '🐏', '🐐', '🐑', '🐓', '🐔', '🐗', '🐘', '🐙', '🐚', '🐛', '🐜', '🐝', '🐞', '🐟', '🐡', '🐢', '🐤', '🐦', '🐦⬛', '🐧', '🐨', '🐫', '🐬', '🐭', '🐮', '🐯', '🐰', '🐱', '🐳', '🐴', '🐵', '🐶', '🐷', '🐸', '🐹', '🐺', '🐻', '🐻❄️', '🐼', '🐿', '👑', '👽', '💀', '💈', '💎', '💙', '💚', '💜', '💡', '💢', '💣', '💥', '💧', '💯', '💰', '💵', '💸', '📈', '📉', '📌', '📎', '📜', '📡', '📣', '📦', '📫', '📸', '🔆', '🔊', '🔍', '🔑', '🔒', '🔔', '🔗', '🔥', '🔦', '🔩', '🔪', '🔫', '🔬', '🔭', '🔮', '🔱', '🕷', '🕹', '🖊', '🖌', '🖍', '🖤', '🗡', '🗺', '🗻', '🗼', '🗽', '🗿', '🚀', '🚂', '🚌', '🚑', '🚒', '🚓', '🚕', '🚗', '🚜', '🚦', '🚧', '🚨', '🚫', '🚲', '🛍️', '🛑', '🛟', '🛠', '🛡', '🛥', '🛰', '🛳', '🛴', '🛶', '🛷', '🛸', '🛹', '🛻', '🛼', '🤖', '🤿', '🥁', '🥊', '🥏', '🥐', '🥑', '🥕', '🥚', '🥝', '🥥', '🥧', '🥨', '🥭', '🥯', '🦀', '🦁', '🦂', '🦄', '🦅', '🦆', '🦇', '🦈', '🦉', '🦊', '🦋', '🦌', '🦍', '🦎', '🦏', '🦒', '🦓', '🦔', '🦕', '🦘', '🦙', '🦚', '🦛', '🦜', '🦝', '🦠', '🦣', '🦥', '🦦', '🦨', '🦩', '🦫', '🦬', '🧀', '🧁', '🧡', '🧨', '🧩', '🧬', '🧭', '🧯', '🧲', '🧸', '🩵', '🩷', '🪀', '🪁', '🪂', '🪄', '🪅', '🪇', '🪈', '🪐', '🪓', '🪗', '🪘', '🪚', '🪦', '🪩', '🪱', '🪴', '🪵', '🪸', '🪼', '🪽', '🪿', '🫏', '🫐', '🫑'
|
||||
];
|
||||
|
||||
-- Emoji length
|
||||
_emoji_length CONSTANT INTEGER := ARRAY_LENGTH(_emojis, 1);
|
||||
BEGIN
|
||||
RETURN _emojis[FLOOR(utilities.safe_random() * _emoji_length) + 1];
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Prune expired locations trigger function
|
||||
CREATE OR REPLACE FUNCTION utilities.prune_expired_locations()
|
||||
RETURNS VOID
|
||||
SECURITY DEFINER
|
||||
VOLATILE
|
||||
LANGUAGE plpgsql
|
||||
SET search_path = ''
|
||||
AS $$
|
||||
DECLARE
|
||||
-- Expiration interval
|
||||
_interval CONSTANT INTERVAL := INTERVAL '1 hour';
|
||||
BEGIN
|
||||
-- Delete expired locations
|
||||
DELETE FROM public.locations
|
||||
WHERE created_at < (NOW() - _interval);
|
||||
END;
|
||||
$$;
|
||||
|
||||
/* -------------------------------------- Trigger routines ------------------------------------- */
|
||||
|
||||
-- Prune locations trigger function
|
||||
CREATE OR REPLACE FUNCTION utilities.prune_locations_trigger()
|
||||
RETURNS TRIGGER
|
||||
SECURITY DEFINER
|
||||
VOLATILE
|
||||
LANGUAGE plpgsql
|
||||
SET search_path = ''
|
||||
AS $$
|
||||
DECLARE
|
||||
-- Expiration interval
|
||||
_interval CONSTANT INTERVAL := INTERVAL '1 hour';
|
||||
BEGIN
|
||||
-- Keep only 5 newest locations that haven't expired for the user
|
||||
DELETE FROM public.locations
|
||||
WHERE
|
||||
user_id = NEW.user_id AND
|
||||
created_at < (NOW() - _interval) AND
|
||||
id NOT IN (
|
||||
SELECT id
|
||||
FROM public.locations
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 5
|
||||
);
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Setup a profile for a new user trigger function
|
||||
CREATE OR REPLACE FUNCTION utilities.setup_profile_trigger()
|
||||
RETURNS TRIGGER
|
||||
SECURITY DEFINER
|
||||
VOLATILE
|
||||
LANGUAGE plpgsql
|
||||
SET search_path = ''
|
||||
AS $$
|
||||
BEGIN
|
||||
-- Insert a new profile for the user
|
||||
INSERT INTO public.profiles (
|
||||
id
|
||||
) VALUES (
|
||||
NEW.id
|
||||
);
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Validate a new location trigger function
|
||||
CREATE OR REPLACE FUNCTION utilities.validate_location_trigger()
|
||||
RETURNS TRIGGER
|
||||
SECURITY DEFINER
|
||||
LANGUAGE plpgsql
|
||||
SET search_path = ''
|
||||
AS $$
|
||||
DECLARE
|
||||
-- Previous created at
|
||||
_previous_created_at TIMESTAMPTZ;
|
||||
|
||||
-- Previous location
|
||||
_previous_location extensions.GEOGRAPHY(POINT, 4326);
|
||||
|
||||
-- The _elapsed time (in seconds) between the new location and the previous location
|
||||
_elapsed BIGINT;
|
||||
|
||||
-- The _distance (in meters) between the new location and the previous location
|
||||
_distance DOUBLE PRECISION;
|
||||
BEGIN
|
||||
-- Get the previous created at and location
|
||||
SELECT created_at, location INTO _previous_created_at, _previous_location
|
||||
FROM public.locations
|
||||
WHERE user_id = NEW.user_id
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1;
|
||||
|
||||
-- Return if there is no previous created at/location
|
||||
IF _previous_created_at IS NULL AND _previous_location IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Calculate the _elapsed time between the new location and the previous location
|
||||
SELECT EXTRACT(EPOCH FROM (NEW.created_at - _previous_created_at)) INTO _elapsed;
|
||||
|
||||
-- Skip inserting if the elapsed time is zero
|
||||
IF _elapsed = 0::BIGINT THEN
|
||||
RETURN NULL;
|
||||
END IF;
|
||||
|
||||
-- Calculate the distance between the new location and the previous location
|
||||
SELECT extensions.ST_Distance(NEW.location, _previous_location) INTO _distance;
|
||||
|
||||
-- Prevent the user from moving too fast (> 1200 km/h ~= 333.333 m/s)
|
||||
IF (_distance / _elapsed::DOUBLE PRECISION) > 333.333 THEN
|
||||
RAISE EXCEPTION 'You are moving too fast';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Anonymize the location of a new post trigger function
|
||||
CREATE OR REPLACE FUNCTION utilities.anonymize_location_trigger()
|
||||
RETURNS TRIGGER
|
||||
SECURITY DEFINER
|
||||
VOLATILE
|
||||
LANGUAGE plpgsql
|
||||
SET search_path = ''
|
||||
AS $$
|
||||
DECLARE
|
||||
_uncertainty DOUBLE PRECISION := 0.10;
|
||||
|
||||
-- Old location as a geometry point
|
||||
_old_location extensions.GEOMETRY;
|
||||
BEGIN
|
||||
-- Convert the old location to a geometry point
|
||||
_old_location = NEW.private_location::extensions.GEOMETRY;
|
||||
|
||||
-- Add some uncertainty relative to the post's radius (To increase resistance against static trilateration attacks)
|
||||
NEW.private_location = extensions.ST_Project(
|
||||
_old_location::extensions.GEOGRAPHY,
|
||||
(-(_uncertainty / 2) * NEW.radius) + (_uncertainty * NEW.radius * utilities.safe_random()),
|
||||
2 * PI() * utilities.safe_random()
|
||||
);
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Post deleted trigger function
|
||||
CREATE OR REPLACE FUNCTION utilities.post_deleted_trigger()
|
||||
RETURNS TRIGGER
|
||||
SECURITY DEFINER
|
||||
VOLATILE
|
||||
LANGUAGE plpgsql
|
||||
SET search_path = ''
|
||||
AS $$
|
||||
BEGIN
|
||||
-- Delete the media
|
||||
IF OLD.has_media THEN
|
||||
DELETE FROM storage.objects
|
||||
WHERE bucket_id = 'media'
|
||||
AND name = 'posts/' || OLD.id::TEXT;
|
||||
END IF;
|
||||
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Post view modified (i.e.: insert, update, or delete) trigger function
|
||||
CREATE OR REPLACE FUNCTION utilities.post_view_modified_trigger()
|
||||
RETURNS TRIGGER
|
||||
SECURITY DEFINER
|
||||
VOLATILE
|
||||
LANGUAGE plpgsql
|
||||
SET search_path = ''
|
||||
AS $$
|
||||
DECLARE
|
||||
-- Post ID
|
||||
_post_id UUID := CASE WHEN NEW IS NULL THEN OLD.post_id ELSE NEW.post_id END;
|
||||
BEGIN
|
||||
-- Recalculate post views
|
||||
UPDATE public.posts
|
||||
SET views = (
|
||||
SELECT COUNT(*)
|
||||
FROM public.post_views
|
||||
WHERE post_id = _post_id
|
||||
)
|
||||
WHERE id = _post_id;
|
||||
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Post vote modified (i.e.: insert, update, or delete) trigger function
|
||||
CREATE OR REPLACE FUNCTION utilities.post_vote_modified_trigger()
|
||||
RETURNS TRIGGER
|
||||
SECURITY DEFINER
|
||||
VOLATILE
|
||||
LANGUAGE plpgsql
|
||||
SET search_path = ''
|
||||
AS $$
|
||||
DECLARE
|
||||
-- Post ID
|
||||
_post_id UUID := CASE WHEN NEW IS NULL THEN OLD.post_id ELSE NEW.post_id END;
|
||||
|
||||
-- Post upvotes
|
||||
_upvotes BIGINT;
|
||||
|
||||
-- Post downvotes
|
||||
_downvotes BIGINT;
|
||||
BEGIN
|
||||
-- Recalculate post votes
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN upvote THEN 1 ELSE 0 END), 0),
|
||||
COALESCE(SUM(CASE WHEN NOT upvote THEN 1 ELSE 0 END), 0)
|
||||
INTO _upvotes, _downvotes
|
||||
FROM public.post_votes
|
||||
WHERE post_id = _post_id;
|
||||
|
||||
-- Update the post
|
||||
UPDATE public.posts
|
||||
SET
|
||||
upvotes = _upvotes,
|
||||
downvotes = _downvotes
|
||||
WHERE id = _post_id;
|
||||
|
||||
-- Delete the post if the net votes is less than or equal to -5
|
||||
IF (_upvotes - _downvotes) <= -5 THEN
|
||||
DELETE FROM public.posts
|
||||
WHERE id = _post_id;
|
||||
END IF;
|
||||
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Comment modified (i.e.: insert, update, or delete) trigger function
|
||||
CREATE OR REPLACE FUNCTION utilities.comment_modified_trigger()
|
||||
RETURNS TRIGGER
|
||||
SECURITY DEFINER
|
||||
VOLATILE
|
||||
LANGUAGE plpgsql
|
||||
SET search_path = ''
|
||||
AS $$
|
||||
DECLARE
|
||||
-- Comment ID
|
||||
_id UUID := CASE WHEN NEW IS NULL THEN OLD.id ELSE NEW.id END;
|
||||
|
||||
-- Comment post ID
|
||||
_post_id UUID := CASE WHEN NEW IS NULL THEN OLD.post_id ELSE NEW.post_id END;
|
||||
|
||||
-- Current ancestor comment ID
|
||||
_ancestor_id UUID := CASE WHEN NEW IS NULL THEN OLD.parent_id ELSE NEW.parent_id END;
|
||||
|
||||
-- Ancestor comment IDs
|
||||
_ancestor_ids UUID[];
|
||||
BEGIN
|
||||
-- Recalculate post comments
|
||||
UPDATE public.posts
|
||||
SET comments = (
|
||||
SELECT COUNT(*)
|
||||
FROM public.comments
|
||||
WHERE post_id = _post_id
|
||||
)
|
||||
WHERE id = _post_id;
|
||||
|
||||
-- Add the comment ID to the ancestor IDs
|
||||
_ancestor_ids = array_append(_ancestor_ids, _id);
|
||||
|
||||
-- Check for different posts, repeated comments, and calculate the depth
|
||||
FOR i IN 0..9 LOOP
|
||||
-- Reached the top-level comment
|
||||
IF _ancestor_id IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Ensure the ancestor comment's post matches the child comment's post
|
||||
IF (
|
||||
SELECT post_id
|
||||
FROM public.comments
|
||||
WHERE id = _ancestor_id
|
||||
) != _post_id THEN
|
||||
RAISE EXCEPTION 'The ancestor''s comment''s post does not match the child comment''s post';
|
||||
END IF;
|
||||
|
||||
-- Check for repeated comments
|
||||
IF _ancestor_id = ANY(_ancestor_ids) THEN
|
||||
RAISE EXCEPTION 'The ancestor comment is repeated';
|
||||
END IF;
|
||||
|
||||
-- Add the parent ID to the ancestor IDs
|
||||
_ancestor_ids = array_append(_ancestor_ids, _ancestor_id);
|
||||
|
||||
-- Get the next parent ID
|
||||
SELECT parent_id INTO _ancestor_id
|
||||
FROM public.comments
|
||||
WHERE id = _ancestor_id;
|
||||
END LOOP;
|
||||
|
||||
-- Comment depth too deep
|
||||
RAISE EXCEPTION 'This comment is too many levels deep';
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Comment view modified (i.e.: insert, update, or delete) trigger function
|
||||
CREATE OR REPLACE FUNCTION utilities.comment_view_modified_trigger()
|
||||
RETURNS TRIGGER
|
||||
SECURITY DEFINER
|
||||
VOLATILE
|
||||
LANGUAGE plpgsql
|
||||
SET search_path = ''
|
||||
AS $$
|
||||
DECLARE
|
||||
-- Comment ID
|
||||
_comment_id UUID := CASE WHEN NEW IS NULL THEN OLD.comment_id ELSE NEW.comment_id END;
|
||||
BEGIN
|
||||
-- Recalculate comment views
|
||||
UPDATE public.comments
|
||||
SET views = (
|
||||
SELECT COUNT(*)
|
||||
FROM public.comment_views
|
||||
WHERE comment_id = _comment_id
|
||||
)
|
||||
WHERE id = _comment_id;
|
||||
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Comment vote modified (i.e.: insert, update, or delete) trigger function
|
||||
CREATE OR REPLACE FUNCTION utilities.comment_vote_modified_trigger()
|
||||
RETURNS TRIGGER
|
||||
SECURITY DEFINER
|
||||
VOLATILE
|
||||
LANGUAGE plpgsql
|
||||
SET search_path = ''
|
||||
AS $$
|
||||
DECLARE
|
||||
-- Comment ID
|
||||
_comment_id UUID := CASE WHEN NEW IS NULL THEN OLD.comment_id ELSE NEW.comment_id END;
|
||||
|
||||
-- Comment upvotes
|
||||
_upvotes BIGINT;
|
||||
|
||||
-- Comment downvotes
|
||||
_downvotes BIGINT;
|
||||
BEGIN
|
||||
-- Recalculate comment votes
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN upvote THEN 1 ELSE 0 END), 0),
|
||||
COALESCE(SUM(CASE WHEN NOT upvote THEN 1 ELSE 0 END), 0)
|
||||
INTO _upvotes, _downvotes
|
||||
FROM public.comment_votes
|
||||
WHERE comment_id = _comment_id;
|
||||
|
||||
-- Update the comment
|
||||
UPDATE public.comments
|
||||
SET
|
||||
upvotes = _upvotes,
|
||||
downvotes = _downvotes
|
||||
WHERE id = _comment_id;
|
||||
|
||||
-- Delete the comment if the net votes is less than or equal to -5
|
||||
IF (_upvotes - _downvotes) <= -5 THEN
|
||||
DELETE FROM public.comments
|
||||
WHERE id = _comment_id;
|
||||
END IF;
|
||||
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$;
|
||||
|
||||
/* -------------------------------------- Public routines -------------------------------------- */
|
||||
|
||||
-- Validate access to a post
|
||||
CREATE OR REPLACE FUNCTION public.validate_post_access(
|
||||
_post_id UUID
|
||||
)
|
||||
RETURNS BOOLEAN
|
||||
SECURITY DEFINER
|
||||
STABLE
|
||||
LANGUAGE plpgsql
|
||||
SET search_path = ''
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN EXISTS (
|
||||
SELECT 1
|
||||
FROM public.posts post
|
||||
WHERE
|
||||
-- Only get the specified post
|
||||
post.id = _post_id
|
||||
|
||||
AND (
|
||||
-- Only get posts for which the current user is the poster
|
||||
post.private_poster_id = auth.uid()
|
||||
|
||||
-- Or only get posts for which the user is within the post's radius
|
||||
OR public.distance_to(post.private_location) <= post.radius
|
||||
)
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Validate a media object name
|
||||
CREATE OR REPLACE FUNCTION public.validate_media_object_name(
|
||||
_object_name TEXT
|
||||
)
|
||||
RETURNS BOOLEAN
|
||||
SECURITY DEFINER
|
||||
STABLE
|
||||
LANGUAGE plpgsql
|
||||
SET search_path = ''
|
||||
AS $$
|
||||
DECLARE
|
||||
-- Parsed name segments
|
||||
_segments TEXT[];
|
||||
BEGIN
|
||||
-- Parse the name
|
||||
_segments = STRING_TO_ARRAY(_object_name, '/');
|
||||
|
||||
-- Return false if the name has an incorrect number of segments
|
||||
IF ARRAY_LENGTH(_segments, 1) != 2 THEN
|
||||
RETURN FALSE;
|
||||
END IF;
|
||||
|
||||
-- Posts category
|
||||
IF _segments[1] = 'posts' THEN
|
||||
-- Check that the current user owns the corresponding post and that the post should have media
|
||||
RETURN EXISTS(
|
||||
SELECT 1
|
||||
FROM public.posts
|
||||
WHERE
|
||||
private_poster_id = auth.uid()
|
||||
AND has_media = TRUE
|
||||
AND id = _segments[2]::UUID
|
||||
);
|
||||
|
||||
-- Unknown media category
|
||||
ELSE
|
||||
RETURN FALSE;
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Delete a user's account
|
||||
CREATE OR REPLACE FUNCTION public.delete_account()
|
||||
RETURNS VOID
|
||||
SECURITY DEFINER
|
||||
VOLATILE
|
||||
LANGUAGE plpgsql
|
||||
SET search_path = ''
|
||||
AS $$
|
||||
BEGIN
|
||||
-- Delete the user's account
|
||||
DELETE FROM auth.users
|
||||
WHERE id = auth.uid();
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Calculate the distance from the current user's location to a specified location
|
||||
CREATE OR REPLACE FUNCTION public.distance_to(
|
||||
-- Other location to calculate the distance to
|
||||
_other_location extensions.GEOGRAPHY(POINT, 4326)
|
||||
)
|
||||
RETURNS DOUBLE PRECISION
|
||||
SECURITY DEFINER
|
||||
VOLATILE
|
||||
LANGUAGE plpgsql
|
||||
SET search_path = ''
|
||||
AS $$
|
||||
DECLARE
|
||||
-- Current user's location
|
||||
_user_location extensions.GEOGRAPHY(POINT, 4326) := public.get_latest_location();
|
||||
BEGIN
|
||||
RETURN extensions.ST_Distance(_user_location, _other_location);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Get the latest location for the current user, raising an exception if there's no previous location or if the previous location is too old
|
||||
CREATE OR REPLACE FUNCTION public.get_latest_location()
|
||||
RETURNS extensions.GEOGRAPHY(POINT, 4326)
|
||||
SECURITY DEFINER
|
||||
VOLATILE
|
||||
LANGUAGE plpgsql
|
||||
SET search_path = ''
|
||||
AS $$
|
||||
DECLARE
|
||||
-- Created at
|
||||
_created_at TIMESTAMPTZ;
|
||||
|
||||
-- Location
|
||||
_location extensions.GEOGRAPHY(POINT, 4326);
|
||||
BEGIN
|
||||
-- Get the latest location
|
||||
SELECT created_at, location INTO _created_at, _location
|
||||
FROM public.locations
|
||||
WHERE user_id = auth.uid()
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1;
|
||||
|
||||
-- No previous location
|
||||
IF _created_at IS NULL AND _location IS NULL THEN
|
||||
RAISE EXCEPTION 'You do not have a location set';
|
||||
END IF;
|
||||
|
||||
-- Previous location too old
|
||||
IF _created_at < (NOW() - INTERVAL '1 hour') THEN
|
||||
RAISE EXCEPTION 'Your location is too old';
|
||||
END IF;
|
||||
|
||||
RETURN _location;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Calculate the rank of a post
|
||||
CREATE OR REPLACE FUNCTION public.calculate_rank(
|
||||
-- Distance to the post (In meters)
|
||||
_distance DOUBLE PRECISION,
|
||||
|
||||
-- Post score (Upvotes - downvotes)
|
||||
_score BIGINT,
|
||||
|
||||
-- Post created at
|
||||
_created_at TIMESTAMPTZ
|
||||
)
|
||||
RETURNS BIGINT
|
||||
IMMUTABLE
|
||||
LANGUAGE plpgsql
|
||||
SET search_path = ''
|
||||
AS $$
|
||||
DECLARE
|
||||
-- Ranking scale factor
|
||||
_scale DOUBLE PRECISION := 10000;
|
||||
|
||||
-- Distance weight factor
|
||||
_distance_weight DOUBLE PRECISION := 5;
|
||||
|
||||
-- Maximum distance to be considered (In meters)
|
||||
_distance_range DOUBLE PRECISION := 5000;
|
||||
|
||||
-- Minimum score threshold
|
||||
_score_threshold BIGINT := -5;
|
||||
|
||||
-- Age weight factor
|
||||
_age_weight DOUBLE PRECISION := 1.075;
|
||||
BEGIN
|
||||
RETURN FLOOR(
|
||||
_scale *
|
||||
((_distance_weight - 1) * POWER(LEAST(1, _distance / _distance_range) - 1, 2) + 1) *
|
||||
LOG(GREATEST(1, _score - _score_threshold + 1)) *
|
||||
POWER(_age_weight, -EXTRACT(EPOCH FROM (NOW() - _created_at)) / 3600)
|
||||
)::BIGINT;
|
||||
END;
|
||||
$$;
|
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Setup tables
|
||||
*/
|
||||
|
||||
/* ---------------------------------------- Setup tables --------------------------------------- */
|
||||
|
||||
-- Profiles (Exposed to other users)
|
||||
CREATE TABLE public.profiles (
|
||||
-- Primary key (Foreign key to auth.users)
|
||||
id UUID NOT NULL PRIMARY KEY REFERENCES auth.users ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
|
||||
-- Random color
|
||||
color TEXT NOT NULL DEFAULT utilities.get_random_color(),
|
||||
|
||||
-- Random emoji
|
||||
emoji TEXT NOT NULL DEFAULT utilities.get_random_emoji()
|
||||
);
|
||||
|
||||
-- User locations
|
||||
CREATE TABLE public.locations (
|
||||
-- Primary key
|
||||
id UUID NOT NULL PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
|
||||
-- User ID (Foreign key to auth.users)
|
||||
user_id UUID NOT NULL REFERENCES auth.users ON UPDATE CASCADE ON DELETE CASCADE DEFAULT auth.uid(),
|
||||
|
||||
-- Creation timestamp
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() CHECK (created_at <= NOW()),
|
||||
|
||||
-- Location (EPSG4326 - used by the W3C geolocation API)
|
||||
location extensions.GEOGRAPHY(POINT, 4326) NOT NULL
|
||||
);
|
||||
|
||||
-- Posts
|
||||
CREATE TABLE public.posts (
|
||||
-- Primary key
|
||||
id UUID NOT NULL PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
|
||||
-- Private poster user ID (Foreign key to auth.users)
|
||||
private_poster_id UUID NOT NULL REFERENCES auth.users ON UPDATE CASCADE ON DELETE CASCADE DEFAULT auth.uid(),
|
||||
|
||||
-- Whether or not the post is anonymous
|
||||
private_anonymous BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
-- Public poster ID (Only show if the post is not anonymous)
|
||||
poster_id UUID NULL GENERATED ALWAYS AS (
|
||||
CASE WHEN private_anonymous THEN NULL ELSE private_poster_id END
|
||||
) STORED,
|
||||
|
||||
-- Private post filter location (EPSG4326 - used by the W3C geolocation API)
|
||||
private_location extensions.GEOGRAPHY(POINT, 4326) NOT NULL DEFAULT public.get_latest_location(),
|
||||
|
||||
-- Creation timestamp
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() CHECK (created_at <= NOW()),
|
||||
|
||||
-- Filter radius in meters (Clamped between 500 meters and 50 kilometers)
|
||||
radius DOUBLE PRECISION NOT NULL CHECK (500 <= radius AND radius <= 50000),
|
||||
|
||||
-- Plain-text content (Up to 300 characters)
|
||||
content VARCHAR(300) NOT NULL,
|
||||
|
||||
-- Whether or not the post has media (e.g.: an image or video)
|
||||
-- Note: media is stored in the `media` bucket with the name `posts/[Post ID]`, where `[Post ID]` refers to the `id` column of this table.
|
||||
-- Therefore, media can only be uploaded after a row is inserted into this table and its `id` column is retrieved.
|
||||
has_media BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
-- Media blur hash (Up to 6 x 8 components)
|
||||
blur_hash VARCHAR(100) NULL,
|
||||
|
||||
-- Media aspect ratio (Used to prevent layout shifts)
|
||||
aspect_ratio DOUBLE PRECISION NULL,
|
||||
|
||||
-- View count
|
||||
views BIGINT NOT NULL DEFAULT 0 CHECK (views >= 0),
|
||||
|
||||
-- Comment count
|
||||
comments BIGINT NOT NULL DEFAULT 0 CHECK (views >= 0),
|
||||
|
||||
-- Upvote count
|
||||
upvotes BIGINT NOT NULL DEFAULT 0 CHECK (views >= 0),
|
||||
|
||||
-- Downvote count
|
||||
downvotes BIGINT NOT NULL DEFAULT 0 CHECK (views >= 0),
|
||||
|
||||
CHECK (
|
||||
(has_media AND blur_hash IS NOT NULL AND aspect_ratio IS NOT NULL) OR
|
||||
(NOT has_media AND blur_hash IS NULL AND aspect_ratio IS NULL)
|
||||
)
|
||||
);
|
||||
|
||||
-- Post views
|
||||
CREATE TABLE public.post_views (
|
||||
-- Primary key
|
||||
id UUID NOT NULL PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
|
||||
-- Post ID (Foreign key to public.posts)
|
||||
post_id UUID NOT NULL REFERENCES public.posts ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
|
||||
-- Viewer user ID (Foreign key to auth.users)
|
||||
viewer_id UUID NOT NULL REFERENCES auth.users ON UPDATE CASCADE ON DELETE CASCADE DEFAULT auth.uid(),
|
||||
|
||||
-- Creation timestamp
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() CHECK (created_at <= NOW()),
|
||||
|
||||
-- Ensure the viewer can only view once per post
|
||||
UNIQUE (viewer_id, post_id)
|
||||
);
|
||||
|
||||
-- Post votes
|
||||
CREATE TABLE public.post_votes (
|
||||
-- Primary key
|
||||
id UUID NOT NULL PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
|
||||
-- Voter user ID (Foreign key to auth.users)
|
||||
voter_id UUID NOT NULL REFERENCES auth.users ON UPDATE CASCADE ON DELETE CASCADE DEFAULT auth.uid(),
|
||||
|
||||
-- Post ID (Foreign key to public.posts)
|
||||
post_id UUID NOT NULL REFERENCES public.posts ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
|
||||
-- Creation timestamp
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() CHECK (created_at <= NOW()),
|
||||
|
||||
-- Whether the vote is an upvote (true) or a downvote (false)
|
||||
upvote BOOLEAN NOT NULL,
|
||||
|
||||
-- Ensure the voter can only vote once per post
|
||||
UNIQUE (voter_id, post_id)
|
||||
);
|
||||
|
||||
-- Post reports
|
||||
CREATE TABLE public.post_reports (
|
||||
-- Primary key
|
||||
id UUID NOT NULL PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
|
||||
-- Reporter user ID (Foreign key to auth.users)
|
||||
reporter_id UUID NOT NULL REFERENCES auth.users ON UPDATE CASCADE ON DELETE CASCADE DEFAULT auth.uid(),
|
||||
|
||||
-- Post ID (Foreign key to public.posts)
|
||||
post_id UUID NOT NULL REFERENCES public.posts ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
|
||||
-- Creation timestamp
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() CHECK (created_at <= NOW()),
|
||||
|
||||
-- Ensure the reporter can only report once per post
|
||||
UNIQUE (reporter_id, post_id)
|
||||
);
|
||||
|
||||
-- Comments (Nestable)
|
||||
CREATE TABLE public.comments (
|
||||
-- Primary key
|
||||
id UUID NOT NULL PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
|
||||
-- Private commenter user ID (Foreign key to auth.users)
|
||||
private_commenter_id UUID NOT NULL REFERENCES auth.users ON UPDATE CASCADE ON DELETE CASCADE DEFAULT auth.uid(),
|
||||
|
||||
-- Whether or not the comment is anonymous
|
||||
private_anonymous BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
-- Public commenter ID (Only show if the comment is not anonymous)
|
||||
commenter_id UUID NULL GENERATED ALWAYS AS (
|
||||
CASE WHEN private_anonymous THEN NULL ELSE private_commenter_id END
|
||||
) STORED,
|
||||
|
||||
-- Parent post ID (Foreign key to public.posts)
|
||||
post_id UUID NOT NULL REFERENCES public.posts ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
|
||||
-- Parent comment ID (Foreign key to public.comments)
|
||||
parent_id UUID NULL REFERENCES public.comments ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
|
||||
-- Creation timestamp
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() CHECK (created_at <= NOW()),
|
||||
|
||||
-- Comment content (Up to 1000 characters)
|
||||
content VARCHAR(1000) NOT NULL,
|
||||
|
||||
-- View count
|
||||
views BIGINT NOT NULL DEFAULT 0 CHECK (views >= 0),
|
||||
|
||||
-- Upvote count
|
||||
upvotes BIGINT NOT NULL DEFAULT 0 CHECK (views >= 0),
|
||||
|
||||
-- Downvote count
|
||||
downvotes BIGINT NOT NULL DEFAULT 0 CHECK (views >= 0)
|
||||
);
|
||||
|
||||
-- Comment view
|
||||
CREATE TABLE public.comment_views (
|
||||
-- Primary key
|
||||
id UUID NOT NULL PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
|
||||
-- Comment ID (Foreign key to public.comments)
|
||||
comment_id UUID NOT NULL REFERENCES public.comments ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
|
||||
-- Viewer user ID (Foreign key to auth.users)
|
||||
viewer_id UUID NOT NULL REFERENCES auth.users ON UPDATE CASCADE ON DELETE CASCADE DEFAULT auth.uid(),
|
||||
|
||||
-- Creation timestamp
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() CHECK (created_at <= NOW()),
|
||||
|
||||
-- Ensure the viewer can only view once per comment
|
||||
UNIQUE (viewer_id, comment_id)
|
||||
);
|
||||
|
||||
-- Comment votes
|
||||
CREATE TABLE public.comment_votes (
|
||||
-- Primary key
|
||||
id UUID NOT NULL PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
|
||||
-- Voter user ID (Foreign key to auth.users)
|
||||
voter_id UUID NOT NULL REFERENCES auth.users ON UPDATE CASCADE ON DELETE CASCADE DEFAULT auth.uid(),
|
||||
|
||||
-- Comment ID (Foreign key to public.comments)
|
||||
comment_id UUID NOT NULL REFERENCES public.comments ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
|
||||
-- Creation timestamp
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() CHECK (created_at <= NOW()),
|
||||
|
||||
-- Whether the vote is an upvote (true) or a downvote (false)
|
||||
upvote BOOLEAN NOT NULL,
|
||||
|
||||
-- Ensure the voter can only vote once per comment
|
||||
UNIQUE (voter_id, comment_id)
|
||||
);
|
||||
|
||||
-- Comment reports
|
||||
CREATE TABLE public.comment_reports (
|
||||
-- Primary key
|
||||
id UUID NOT NULL PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
|
||||
-- Reporter user ID (Foreign key to auth.users)
|
||||
reporter_id UUID NOT NULL REFERENCES auth.users ON UPDATE CASCADE ON DELETE CASCADE DEFAULT auth.uid(),
|
||||
|
||||
-- Comment ID (Foreign key to public.comments)
|
||||
comment_id UUID NOT NULL REFERENCES public.comments ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
|
||||
-- Creation timestamp
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() CHECK (created_at <= NOW()),
|
||||
|
||||
-- Ensure the reporter can only report once per comment
|
||||
UNIQUE (reporter_id, comment_id)
|
||||
);
|
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Setup views
|
||||
*/
|
||||
|
||||
/* ---------------------------------------- Setup views ---------------------------------------- */
|
||||
|
||||
-- Posts with additional, user-specific information
|
||||
CREATE VIEW public.personalized_posts
|
||||
WITH (
|
||||
security_barrier = TRUE,
|
||||
security_invoker = FALSE
|
||||
)
|
||||
AS (
|
||||
WITH personalized_post AS (
|
||||
SELECT
|
||||
post.id,
|
||||
post.private_poster_id,
|
||||
post.poster_id,
|
||||
post.created_at,
|
||||
post.radius,
|
||||
post.content,
|
||||
post.has_media,
|
||||
post.blur_hash,
|
||||
post.aspect_ratio,
|
||||
post.views,
|
||||
post.upvotes,
|
||||
post.downvotes,
|
||||
post.comments,
|
||||
public.distance_to(post.private_location) AS distance,
|
||||
|
||||
profile.color AS poster_color,
|
||||
profile.emoji AS poster_emoji,
|
||||
|
||||
vote.upvote
|
||||
FROM public.posts post
|
||||
LEFT JOIN public.profiles profile ON profile.id = post.poster_id
|
||||
LEFT JOIN public.post_votes vote ON vote.post_id = post.id AND vote.voter_id = auth.uid()
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
poster_id,
|
||||
created_at,
|
||||
content,
|
||||
has_media,
|
||||
blur_hash,
|
||||
aspect_ratio,
|
||||
views,
|
||||
upvotes,
|
||||
downvotes,
|
||||
comments,
|
||||
distance,
|
||||
public.calculate_rank(distance, upvotes - downvotes, created_at) AS rank,
|
||||
|
||||
private_poster_id = auth.uid() AS is_mine,
|
||||
poster_color,
|
||||
poster_emoji,
|
||||
|
||||
upvote
|
||||
FROM personalized_post
|
||||
-- This view doesn't have RLS, so we need to filter out posts the user can't see
|
||||
WHERE (
|
||||
-- Only select posts for which the user is the poster
|
||||
personalized_post.private_poster_id = auth.uid()
|
||||
|
||||
-- Or only select posts for which the user is within the post's radius
|
||||
OR personalized_post.distance <= personalized_post.radius
|
||||
)
|
||||
);
|
||||
|
||||
-- Comments with additional, user-specific information
|
||||
CREATE VIEW public.personalized_comments
|
||||
WITH (
|
||||
security_barrier = TRUE,
|
||||
security_invoker = FALSE
|
||||
)
|
||||
AS (
|
||||
WITH personalized_comment AS (
|
||||
SELECT
|
||||
comment.id,
|
||||
comment.private_commenter_id,
|
||||
comment.commenter_id,
|
||||
comment.post_id,
|
||||
comment.parent_id,
|
||||
comment.created_at,
|
||||
comment.content,
|
||||
comment.views,
|
||||
comment.upvotes,
|
||||
comment.downvotes,
|
||||
|
||||
profile.color AS commenter_color,
|
||||
profile.emoji AS commenter_emoji,
|
||||
|
||||
vote.upvote
|
||||
FROM public.comments comment
|
||||
LEFT JOIN public.profiles profile ON profile.id = comment.commenter_id
|
||||
LEFT JOIN public.comment_votes vote ON vote.comment_id = comment.id AND vote.voter_id = auth.uid()
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
commenter_id,
|
||||
post_id,
|
||||
parent_id,
|
||||
created_at,
|
||||
content,
|
||||
views,
|
||||
upvotes,
|
||||
downvotes,
|
||||
public.calculate_rank(0, upvotes - downvotes, created_at) AS rank,
|
||||
|
||||
private_commenter_id = auth.uid() AS is_mine,
|
||||
commenter_color,
|
||||
commenter_emoji,
|
||||
|
||||
upvote
|
||||
FROM personalized_comment
|
||||
-- This view doesn't have RLS, so we need to filter out comments the user can't see
|
||||
WHERE (
|
||||
-- Only get comments for which the user is the commenter
|
||||
personalized_comment.private_commenter_id = auth.uid()
|
||||
|
||||
-- Or only show comments for posts the user has access to
|
||||
OR public.validate_post_access(post_id)
|
||||
)
|
||||
);
|
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Setup indexes
|
||||
*/
|
||||
|
||||
/* ---------------------------------------- Setup indexes --------------------------------------- */
|
||||
|
||||
-- User locations
|
||||
CREATE INDEX locations_user_id ON public.locations (user_id);
|
||||
|
||||
-- Posts
|
||||
CREATE INDEX posts_private_poster_id ON public.posts (private_poster_id);
|
||||
|
||||
-- Post views
|
||||
CREATE INDEX post_views_post_id ON public.post_views (post_id);
|
||||
CREATE INDEX post_views_viewer_id ON public.post_views (viewer_id);
|
||||
|
||||
-- Post votes
|
||||
CREATE INDEX post_votes_voter_id ON public.post_votes (voter_id);
|
||||
CREATE INDEX post_votes_post_id ON public.post_votes (post_id);
|
||||
|
||||
-- Post reports
|
||||
CREATE INDEX post_reports_reporter_id ON public.post_reports (reporter_id);
|
||||
CREATE INDEX post_reports_post_id ON public.post_reports (post_id);
|
||||
|
||||
-- Comments
|
||||
CREATE INDEX comments_private_commenter_id ON public.comments (private_commenter_id);
|
||||
CREATE INDEX comments_post_id ON public.comments (post_id);
|
||||
CREATE INDEX comments_parent_id ON public.comments (parent_id);
|
||||
|
||||
-- Comment views
|
||||
CREATE INDEX comment_views_comment_id ON public.comment_views (comment_id);
|
||||
CREATE INDEX comment_views_viewer_id ON public.comment_views (viewer_id);
|
||||
|
||||
-- Comment votes
|
||||
CREATE INDEX comment_votes_voter_id ON public.comment_votes (voter_id);
|
||||
CREATE INDEX comment_votes_comment_id ON public.comment_votes (comment_id);
|
||||
|
||||
-- Comment reports
|
||||
CREATE INDEX comment_reports_reporter_id ON public.comment_reports (reporter_id);
|
||||
CREATE INDEX comment_reports_comment_id ON public.comment_reports (comment_id);
|
@@ -0,0 +1,406 @@
|
||||
/**
|
||||
* Setup security policies
|
||||
*/
|
||||
|
||||
/* -------------------------------------- Reset privileges ------------------------------------- */
|
||||
|
||||
-- Alter default privileges
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA extensions REVOKE ALL ON SEQUENCES FROM anon, authenticated;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA utilities REVOKE ALL ON SEQUENCES FROM anon, authenticated;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public REVOKE ALL ON SEQUENCES FROM anon, authenticated;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA storage REVOKE ALL ON SEQUENCES FROM anon, authenticated;
|
||||
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA extensions REVOKE ALL ON TABLES FROM anon, authenticated;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA utilities REVOKE ALL ON TABLES FROM anon, authenticated;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public REVOKE ALL ON TABLES FROM anon, authenticated;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA storage REVOKE ALL ON TABLES FROM anon, authenticated;
|
||||
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA extensions REVOKE ALL ON ROUTINES FROM public, anon, authenticated;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA utilities REVOKE ALL ON ROUTINES FROM public, anon, authenticated;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public REVOKE ALL ON ROUTINES FROM public, anon, authenticated;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA storage REVOKE ALL ON ROUTINES FROM public, anon, authenticated;
|
||||
|
||||
-- Revoke existing privileges
|
||||
REVOKE ALL PRIVILEGES ON DATABASE postgres FROM anon, authenticated;
|
||||
|
||||
REVOKE ALL PRIVILEGES ON SCHEMA extensions FROM anon, authenticated;
|
||||
REVOKE ALL PRIVILEGES ON SCHEMA utilities FROM anon, authenticated;
|
||||
REVOKE ALL PRIVILEGES ON SCHEMA public FROM anon, authenticated;
|
||||
REVOKE ALL PRIVILEGES ON SCHEMA storage FROM anon, authenticated;
|
||||
|
||||
REVOKE ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA extensions FROM anon, authenticated;
|
||||
REVOKE ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA utilities FROM anon, authenticated;
|
||||
REVOKE ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public FROM anon, authenticated;
|
||||
REVOKE ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA storage FROM anon, authenticated;
|
||||
|
||||
REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA extensions FROM anon, authenticated;
|
||||
REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA utilities FROM anon, authenticated;
|
||||
REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM anon, authenticated;
|
||||
REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA storage FROM anon, authenticated;
|
||||
|
||||
REVOKE ALL PRIVILEGES ON ALL ROUTINES IN SCHEMA extensions FROM public, anon, authenticated;
|
||||
REVOKE ALL PRIVILEGES ON ALL ROUTINES IN SCHEMA utilities FROM public, anon, authenticated;
|
||||
REVOKE ALL PRIVILEGES ON ALL ROUTINES IN SCHEMA public FROM public, anon, authenticated;
|
||||
REVOKE ALL PRIVILEGES ON ALL ROUTINES IN SCHEMA storage FROM public, anon, authenticated;
|
||||
|
||||
/* ----------------------------------- Setup type privileges ----------------------------------- */
|
||||
|
||||
-- Extensions
|
||||
GRANT USAGE ON SCHEMA extensions TO authenticated;
|
||||
|
||||
/* ---------------------------------- Setup routine privileges --------------------------------- */
|
||||
|
||||
-- Public routines
|
||||
GRANT EXECUTE ON FUNCTION public.validate_post_access(UUID) TO authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.validate_media_object_name(TEXT) TO authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.delete_account() TO authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.distance_to(extensions.GEOGRAPHY(POINT, 4326)) TO authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.get_latest_location() TO authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.calculate_rank(DOUBLE PRECISION, BIGINT, TIMESTAMPTZ) TO authenticated;
|
||||
|
||||
/* ----------------------------------- Setup view privileges ----------------------------------- */
|
||||
|
||||
-- Posts
|
||||
GRANT SELECT ON public.personalized_posts TO authenticated;
|
||||
|
||||
-- Comments
|
||||
GRANT SELECT ON public.personalized_comments TO authenticated;
|
||||
|
||||
/* ----------------------------------- Setup table privileges ---------------------------------- */
|
||||
|
||||
-- Profiles
|
||||
GRANT SELECT ON public.profiles TO authenticated;
|
||||
|
||||
-- User locations
|
||||
GRANT SELECT, DELETE ON public.locations TO authenticated;
|
||||
GRANT INSERT (
|
||||
location
|
||||
)
|
||||
ON public.locations TO authenticated;
|
||||
|
||||
-- Posts
|
||||
GRANT SELECT (id) ON public.posts TO authenticated;
|
||||
GRANT DELETE ON public.posts TO authenticated;
|
||||
GRANT INSERT (
|
||||
private_anonymous,
|
||||
radius,
|
||||
content,
|
||||
has_media,
|
||||
blur_hash,
|
||||
aspect_ratio
|
||||
)
|
||||
ON public.posts TO authenticated;
|
||||
|
||||
-- Post views
|
||||
GRANT SELECT, DELETE ON public.post_views TO authenticated;
|
||||
GRANT INSERT, UPDATE (
|
||||
post_id
|
||||
)
|
||||
ON public.post_views TO authenticated;
|
||||
|
||||
-- Post votes
|
||||
GRANT SELECT, DELETE ON public.post_votes TO authenticated;
|
||||
GRANT INSERT, UPDATE (
|
||||
post_id,
|
||||
upvote
|
||||
)
|
||||
ON public.post_votes TO authenticated;
|
||||
|
||||
-- Post reports
|
||||
GRANT SELECT, DELETE ON public.post_reports TO authenticated;
|
||||
GRANT INSERT (
|
||||
post_id
|
||||
)
|
||||
ON public.post_reports TO authenticated;
|
||||
|
||||
-- Comments
|
||||
GRANT SELECT (id) ON public.comments TO authenticated;
|
||||
GRANT DELETE ON public.comments TO authenticated;
|
||||
GRANT INSERT (
|
||||
private_anonymous,
|
||||
post_id,
|
||||
parent_id,
|
||||
content
|
||||
)
|
||||
ON public.comments TO authenticated;
|
||||
|
||||
-- Comment views
|
||||
GRANT SELECT, DELETE ON public.comment_views TO authenticated;
|
||||
GRANT INSERT, UPDATE (
|
||||
comment_id
|
||||
)
|
||||
ON public.comment_views TO authenticated;
|
||||
|
||||
-- Comment votes
|
||||
GRANT SELECT, DELETE ON public.comment_votes TO authenticated;
|
||||
GRANT INSERT, UPDATE (
|
||||
comment_id,
|
||||
upvote
|
||||
)
|
||||
ON public.comment_votes TO authenticated;
|
||||
|
||||
-- Comment reports
|
||||
GRANT SELECT, DELETE ON public.comment_reports TO authenticated;
|
||||
GRANT INSERT (
|
||||
comment_id
|
||||
)
|
||||
ON public.comment_reports TO authenticated;
|
||||
|
||||
/* -------------------------- Setup Row-level security (RLS) policies -------------------------- */
|
||||
|
||||
-- Enable row-level security
|
||||
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.locations ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.posts ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.post_views ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.post_votes ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.post_reports ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.comments ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.comment_views ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.comment_votes ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.comment_reports ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Profiles
|
||||
CREATE POLICY select_profiles
|
||||
ON public.profiles
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (true);
|
||||
|
||||
-- User locations
|
||||
CREATE POLICY select_locations
|
||||
ON public.locations
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (user_id = (SELECT auth.uid()));
|
||||
|
||||
CREATE POLICY insert_locations
|
||||
ON public.locations
|
||||
FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (user_id = (SELECT auth.uid()));
|
||||
|
||||
CREATE POLICY delete_locations
|
||||
ON public.locations
|
||||
FOR DELETE
|
||||
TO authenticated
|
||||
USING (user_id = (SELECT auth.uid()));
|
||||
|
||||
-- Posts
|
||||
CREATE POLICY select_posts
|
||||
ON public.posts
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (
|
||||
-- Only get posts for which the user is the poster
|
||||
private_poster_id = (SELECT auth.uid())
|
||||
|
||||
-- Or only get posts for which the user is within the post's radius
|
||||
OR public.distance_to(private_location) <= radius
|
||||
);
|
||||
|
||||
CREATE POLICY insert_posts
|
||||
ON public.posts
|
||||
FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (private_poster_id = (SELECT auth.uid()));
|
||||
|
||||
CREATE POLICY delete_posts
|
||||
ON public.posts
|
||||
FOR DELETE
|
||||
TO authenticated
|
||||
USING (private_poster_id = (SELECT auth.uid()));
|
||||
|
||||
-- Post views
|
||||
CREATE POLICY select_post_views
|
||||
ON public.post_views
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (viewer_id = (SELECT auth.uid()));
|
||||
|
||||
CREATE POLICY insert_post_views
|
||||
ON public.post_views
|
||||
FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (viewer_id = (SELECT auth.uid()));
|
||||
|
||||
CREATE POLICY update_post_views
|
||||
ON public.post_views
|
||||
FOR UPDATE
|
||||
TO authenticated
|
||||
USING (viewer_id = (SELECT auth.uid()));
|
||||
|
||||
CREATE POLICY delete_post_views
|
||||
ON public.post_views
|
||||
FOR DELETE
|
||||
TO authenticated
|
||||
USING (viewer_id = (SELECT auth.uid()));
|
||||
|
||||
-- Post votes
|
||||
CREATE POLICY select_post_votes
|
||||
ON public.post_votes
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (voter_id = (SELECT auth.uid()));
|
||||
|
||||
CREATE POLICY insert_post_votes
|
||||
ON public.post_votes
|
||||
FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (voter_id = (SELECT auth.uid()));
|
||||
|
||||
CREATE POLICY update_post_votes
|
||||
ON public.post_votes
|
||||
FOR UPDATE
|
||||
TO authenticated
|
||||
USING (voter_id = (SELECT auth.uid()));
|
||||
|
||||
CREATE POLICY delete_post_votes
|
||||
ON public.post_votes
|
||||
FOR DELETE
|
||||
TO authenticated
|
||||
USING (voter_id = (SELECT auth.uid()));
|
||||
|
||||
-- Post reports
|
||||
CREATE POLICY select_post_reports
|
||||
ON public.post_reports
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (reporter_id = (SELECT auth.uid()));
|
||||
|
||||
CREATE POLICY insert_post_reports
|
||||
ON public.post_reports
|
||||
FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (reporter_id = (SELECT auth.uid()));
|
||||
|
||||
CREATE POLICY delete_post_reports
|
||||
ON public.post_reports
|
||||
FOR DELETE
|
||||
TO authenticated
|
||||
USING (reporter_id = (SELECT auth.uid()));
|
||||
|
||||
-- Comments
|
||||
CREATE POLICY select_comments
|
||||
ON public.comments
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (
|
||||
-- Only get comments for which the user is the commenter
|
||||
private_commenter_id = (SELECT auth.uid())
|
||||
|
||||
-- Or only show comments for posts the user has access to
|
||||
OR public.validate_post_access(post_id)
|
||||
);
|
||||
|
||||
CREATE POLICY insert_comments
|
||||
ON public.comments
|
||||
FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (private_commenter_id = (SELECT auth.uid()));
|
||||
|
||||
CREATE POLICY delete_comments
|
||||
ON public.comments
|
||||
FOR DELETE
|
||||
TO authenticated
|
||||
USING (private_commenter_id = (SELECT auth.uid()));
|
||||
|
||||
-- Comment views
|
||||
CREATE POLICY select_comment_views
|
||||
ON public.comment_views
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (viewer_id = (SELECT auth.uid()));
|
||||
|
||||
CREATE POLICY insert_comment_views
|
||||
ON public.comment_views
|
||||
FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (viewer_id = (SELECT auth.uid()));
|
||||
|
||||
CREATE POLICY update_comment_views
|
||||
ON public.comment_views
|
||||
FOR UPDATE
|
||||
TO authenticated
|
||||
USING (viewer_id = (SELECT auth.uid()));
|
||||
|
||||
CREATE POLICY delete_comment_views
|
||||
ON public.comment_views
|
||||
FOR DELETE
|
||||
TO authenticated
|
||||
USING (viewer_id = (SELECT auth.uid()));
|
||||
|
||||
-- Comment votes
|
||||
CREATE POLICY select_comment_votes
|
||||
ON public.comment_votes
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (voter_id = (SELECT auth.uid()));
|
||||
|
||||
CREATE POLICY insert_comment_votes
|
||||
ON public.comment_votes
|
||||
FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (voter_id = (SELECT auth.uid()));
|
||||
|
||||
CREATE POLICY update_comment_votes
|
||||
ON public.comment_votes
|
||||
FOR UPDATE
|
||||
TO authenticated
|
||||
USING (voter_id = (SELECT auth.uid()));
|
||||
|
||||
CREATE POLICY delete_comment_votes
|
||||
ON public.comment_votes
|
||||
FOR DELETE
|
||||
TO authenticated
|
||||
USING (voter_id = (SELECT auth.uid()));
|
||||
|
||||
-- Comment reports
|
||||
CREATE POLICY select_comment_reports
|
||||
ON public.comment_reports
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (reporter_id = (SELECT auth.uid()));
|
||||
|
||||
CREATE POLICY insert_comment_reports
|
||||
ON public.comment_reports
|
||||
FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (reporter_id = (SELECT auth.uid()));
|
||||
|
||||
CREATE POLICY delete_comment_reports
|
||||
ON public.comment_reports
|
||||
FOR DELETE
|
||||
TO authenticated
|
||||
USING (reporter_id = (SELECT auth.uid()));
|
||||
|
||||
-- Media
|
||||
CREATE POLICY select_media_objects
|
||||
ON storage.objects
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (bucket_id = 'media');
|
||||
|
||||
CREATE POLICY insert_media_objects
|
||||
ON storage.objects
|
||||
FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (
|
||||
-- Media bucket
|
||||
bucket_id = 'media'
|
||||
|
||||
-- Valiate the object name
|
||||
AND public.validate_media_object_name(name)
|
||||
);
|
||||
|
||||
CREATE POLICY delete_media_objects
|
||||
ON storage.objects
|
||||
FOR DELETE
|
||||
TO authenticated
|
||||
USING (
|
||||
-- Media bucket
|
||||
bucket_id = 'media'
|
||||
|
||||
-- Valiate the object name
|
||||
AND public.validate_media_object_name(name)
|
||||
);
|
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Setup triggers
|
||||
*/
|
||||
|
||||
/* --------------------------------------- Setup triggers -------------------------------------- */
|
||||
|
||||
-- Create a profile for a new user
|
||||
CREATE TRIGGER create_profile_after_insert
|
||||
AFTER INSERT ON auth.users
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION utilities.setup_profile_trigger();
|
||||
|
||||
-- Validate new locations
|
||||
CREATE TRIGGER validate_location_before_insert
|
||||
BEFORE INSERT ON public.locations
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION utilities.validate_location_trigger();
|
||||
|
||||
-- Prune old locations
|
||||
CREATE TRIGGER prune_locations_after_insert
|
||||
AFTER INSERT ON public.locations
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION utilities.prune_locations_trigger();
|
||||
|
||||
-- Anonymize the location of a new post
|
||||
CREATE TRIGGER anonymize_post_location_before_insert
|
||||
BEFORE INSERT ON public.posts
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION utilities.anonymize_location_trigger();
|
||||
|
||||
-- Post deleted
|
||||
CREATE TRIGGER post_deleted_after_delete
|
||||
AFTER DELETE ON public.posts
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION utilities.post_deleted_trigger();
|
||||
|
||||
-- Post view modified
|
||||
CREATE TRIGGER post_view_modified_after_insert
|
||||
AFTER INSERT ON public.post_views
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION utilities.post_view_modified_trigger();
|
||||
|
||||
CREATE TRIGGER post_view_modified_after_update
|
||||
AFTER UPDATE ON public.post_views
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION utilities.post_view_modified_trigger();
|
||||
|
||||
CREATE TRIGGER post_view_modified_after_delete
|
||||
AFTER DELETE ON public.post_views
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION utilities.post_view_modified_trigger();
|
||||
|
||||
-- Post vote modified
|
||||
CREATE TRIGGER post_vote_modified_after_insert
|
||||
AFTER INSERT ON public.post_votes
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION utilities.post_vote_modified_trigger();
|
||||
|
||||
CREATE TRIGGER post_vote_modified_after_update
|
||||
AFTER UPDATE ON public.post_votes
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION utilities.post_vote_modified_trigger();
|
||||
|
||||
CREATE TRIGGER post_vote_modified_after_delete
|
||||
AFTER DELETE ON public.post_votes
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION utilities.post_vote_modified_trigger();
|
||||
|
||||
-- Comment modified
|
||||
CREATE TRIGGER comment_modified_after_insert
|
||||
AFTER INSERT ON public.comments
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION utilities.comment_modified_trigger();
|
||||
|
||||
CREATE TRIGGER comment_modified_after_update
|
||||
AFTER UPDATE ON public.comments
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION utilities.comment_modified_trigger();
|
||||
|
||||
CREATE TRIGGER comment_modified_after_delete
|
||||
AFTER DELETE ON public.comments
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION utilities.comment_modified_trigger();
|
||||
|
||||
-- Comment view modified
|
||||
CREATE TRIGGER comment_view_modified_after_insert
|
||||
AFTER INSERT ON public.comment_views
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION utilities.comment_view_modified_trigger();
|
||||
|
||||
CREATE TRIGGER comment_view_modified_after_update
|
||||
AFTER UPDATE ON public.comment_views
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION utilities.comment_view_modified_trigger();
|
||||
|
||||
CREATE TRIGGER comment_view_modified_after_delete
|
||||
AFTER DELETE ON public.comment_views
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION utilities.comment_view_modified_trigger();
|
||||
|
||||
-- Comment vote modified
|
||||
CREATE TRIGGER comment_vote_modified_after_insert
|
||||
AFTER INSERT ON public.comment_votes
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION utilities.comment_vote_modified_trigger();
|
||||
|
||||
CREATE TRIGGER comment_vote_modified_after_update
|
||||
AFTER UPDATE ON public.comment_votes
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION utilities.comment_vote_modified_trigger();
|
||||
|
||||
CREATE TRIGGER comment_vote_modified_after_delete
|
||||
AFTER DELETE ON public.comment_votes
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION utilities.comment_vote_modified_trigger();
|
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Setup miscellaneous things after the main setup
|
||||
*/
|
||||
|
||||
/* -------------------------------------- Setup cron jobs -------------------------------------- */
|
||||
|
||||
-- Prune expired locations
|
||||
SELECT cron.schedule(
|
||||
'hourly-location-cleanup',
|
||||
'0 * * * *',
|
||||
'SELECT utilities.prune_expired_locations()'
|
||||
);
|
213
99_references/beacon-main/supabase/supabase.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
/* eslint-disable unicorn/no-process-exit */
|
||||
/**
|
||||
* @file Programmatic local Supabase instance management
|
||||
*/
|
||||
|
||||
import {dirname, join} from "node:path";
|
||||
import {fileURLToPath} from "node:url";
|
||||
|
||||
import {execa} from "execa";
|
||||
|
||||
/**
|
||||
* Instance status
|
||||
*/
|
||||
export interface InstanceStatus {
|
||||
/**
|
||||
* API URL
|
||||
*/
|
||||
apiUrl: string;
|
||||
|
||||
/**
|
||||
* GraphQL URL
|
||||
*/
|
||||
graphUrl: string;
|
||||
|
||||
/**
|
||||
* Database URL
|
||||
*/
|
||||
dbUrl: string;
|
||||
|
||||
/**
|
||||
* Studio GUI URL
|
||||
*/
|
||||
studioUrl: string;
|
||||
|
||||
/**
|
||||
* Inbucket API URL
|
||||
*/
|
||||
inbucketUrl: string;
|
||||
|
||||
/**
|
||||
* JWT secret
|
||||
*/
|
||||
jwtSecret: string;
|
||||
|
||||
/**
|
||||
* Anonymous API key
|
||||
*/
|
||||
anonKey: string;
|
||||
|
||||
/**
|
||||
* Service role API key
|
||||
*/
|
||||
serviceRoleKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Project root directory
|
||||
*/
|
||||
export const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
/**
|
||||
* Start local Supabase instance
|
||||
*/
|
||||
export const start = async () => {
|
||||
const {all, exitCode, failed} = await execa(
|
||||
"supabase",
|
||||
[
|
||||
"start",
|
||||
"--debug",
|
||||
],
|
||||
{
|
||||
all: true,
|
||||
cwd: root,
|
||||
preferLocal: true,
|
||||
reject: false,
|
||||
},
|
||||
);
|
||||
|
||||
if (failed) {
|
||||
console.error(`Starting Supabase failed (Exit code ${exitCode}): ${all}`);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Reset local Supabase instance
|
||||
*/
|
||||
export const reset = async () => {
|
||||
const {all, exitCode, failed} = await execa(
|
||||
"supabase",
|
||||
[
|
||||
"db",
|
||||
"reset",
|
||||
"--debug",
|
||||
],
|
||||
{
|
||||
all: true,
|
||||
cwd: root,
|
||||
preferLocal: true,
|
||||
reject: false,
|
||||
},
|
||||
);
|
||||
|
||||
console.log(`Output: ${all}`, failed);
|
||||
|
||||
if (failed) {
|
||||
console.error(`Resetting Supabase failed (Exit code ${exitCode}): ${all}`);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the status of the local Supabase instance
|
||||
* @returns Local Supabase instance status
|
||||
*/
|
||||
export const getStatus = async () => {
|
||||
// Get the raw status
|
||||
const {all, exitCode, failed, stdout} = await execa(
|
||||
"supabase",
|
||||
[
|
||||
"status",
|
||||
],
|
||||
{
|
||||
all: true,
|
||||
cwd: root,
|
||||
preferLocal: true,
|
||||
reject: false,
|
||||
},
|
||||
);
|
||||
|
||||
if (failed) {
|
||||
console.error(
|
||||
`Getting Supabase status failed (Exit code ${exitCode}): ${all}`,
|
||||
);
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Parse the status
|
||||
const apiUrl = /API URL: (\S+)/.exec(stdout)?.[1];
|
||||
const graphUrl = /GraphQL URL: (\S+)/.exec(stdout)?.[1];
|
||||
const dbUrl = /DB URL: (\S+)/.exec(stdout)?.[1];
|
||||
const studioUrl = /Studio URL: (\S+)/.exec(stdout)?.[1];
|
||||
const inbucketUrl = /Inbucket URL: (\S+)/.exec(stdout)?.[1];
|
||||
const jwtSecret = /JWT secret: (\S+)/.exec(stdout)?.[1];
|
||||
const anonKey = /anon key: (\S+)/.exec(stdout)?.[1];
|
||||
const serviceRoleKey = /service_role key: (\S+)/.exec(stdout)?.[1];
|
||||
|
||||
if (
|
||||
apiUrl === undefined ||
|
||||
graphUrl === undefined ||
|
||||
dbUrl === undefined ||
|
||||
studioUrl === undefined ||
|
||||
inbucketUrl === undefined ||
|
||||
jwtSecret === undefined ||
|
||||
anonKey === undefined ||
|
||||
serviceRoleKey === undefined
|
||||
) {
|
||||
console.error(
|
||||
`Failed to extract Supabase status (Exit code ${exitCode}): ${all}`,
|
||||
);
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const status: InstanceStatus = {
|
||||
apiUrl,
|
||||
graphUrl,
|
||||
dbUrl,
|
||||
studioUrl,
|
||||
inbucketUrl,
|
||||
jwtSecret,
|
||||
anonKey,
|
||||
serviceRoleKey,
|
||||
};
|
||||
|
||||
return status;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the Supabase schema
|
||||
* @returns Supabase schema
|
||||
*/
|
||||
export const getSchema = async () => {
|
||||
// Generate the arguments
|
||||
const args = [
|
||||
"gen",
|
||||
"types",
|
||||
"typescript",
|
||||
"--local",
|
||||
"--schema",
|
||||
"public",
|
||||
"--debug",
|
||||
];
|
||||
|
||||
// Generate the schema
|
||||
const {all, exitCode, failed, stdout} = await execa("supabase", args, {
|
||||
all: true,
|
||||
cwd: root,
|
||||
preferLocal: true,
|
||||
reject: false,
|
||||
});
|
||||
|
||||
if (failed) {
|
||||
console.error(
|
||||
`Getting Supabase schema failed (Exit code ${exitCode}): ${all}`,
|
||||
);
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return stdout;
|
||||
};
|
234
99_references/beacon-main/supabase/tests/security.test.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
/* eslint-disable camelcase */
|
||||
/**
|
||||
* @file Database security tests
|
||||
*/
|
||||
|
||||
import {beforeAll, describe, expect, test} from "vitest";
|
||||
|
||||
import {reset} from "#/supabase/supabase";
|
||||
import {client} from "~/lib/supabase";
|
||||
|
||||
describe(
|
||||
"database security",
|
||||
{
|
||||
sequential: true,
|
||||
},
|
||||
() => {
|
||||
beforeAll(
|
||||
async () => {
|
||||
// Reset Supabase
|
||||
await reset();
|
||||
},
|
||||
1000 * 60 * 5,
|
||||
);
|
||||
|
||||
describe("unauthenticated user", () => {
|
||||
test.each([
|
||||
[
|
||||
"utilities",
|
||||
"safe_random",
|
||||
{},
|
||||
"The schema must be one of the following: public, storage, graphql_public",
|
||||
],
|
||||
[
|
||||
"utilities",
|
||||
"get_random_color",
|
||||
{},
|
||||
"The schema must be one of the following: public, storage, graphql_public",
|
||||
],
|
||||
[
|
||||
"utilities",
|
||||
"get_random_emoji",
|
||||
{},
|
||||
"The schema must be one of the following: public, storage, graphql_public",
|
||||
],
|
||||
[
|
||||
"utilities",
|
||||
"prune_expired_locations",
|
||||
{},
|
||||
"The schema must be one of the following: public, storage, graphql_public",
|
||||
],
|
||||
[
|
||||
"utilities",
|
||||
"prune_locations_trigger",
|
||||
{},
|
||||
"The schema must be one of the following: public, storage, graphql_public",
|
||||
],
|
||||
[
|
||||
"utilities",
|
||||
"setup_profile_trigger",
|
||||
{},
|
||||
"The schema must be one of the following: public, storage, graphql_public",
|
||||
],
|
||||
[
|
||||
"utilities",
|
||||
"validate_location_trigger",
|
||||
{},
|
||||
"The schema must be one of the following: public, storage, graphql_public",
|
||||
],
|
||||
[
|
||||
"utilities",
|
||||
"anonymize_location_trigger",
|
||||
{},
|
||||
"The schema must be one of the following: public, storage, graphql_public",
|
||||
],
|
||||
[
|
||||
"utilities",
|
||||
"post_deleted_trigger",
|
||||
{},
|
||||
"The schema must be one of the following: public, storage, graphql_public",
|
||||
],
|
||||
[
|
||||
"utilities",
|
||||
"post_view_modified_trigger",
|
||||
{},
|
||||
"The schema must be one of the following: public, storage, graphql_public",
|
||||
],
|
||||
[
|
||||
"utilities",
|
||||
"post_vote_modified_trigger",
|
||||
{},
|
||||
"The schema must be one of the following: public, storage, graphql_public",
|
||||
],
|
||||
[
|
||||
"utilities",
|
||||
"comment_modified_trigger",
|
||||
{},
|
||||
"The schema must be one of the following: public, storage, graphql_public",
|
||||
],
|
||||
[
|
||||
"utilities",
|
||||
"comment_view_modified_trigger",
|
||||
{},
|
||||
"The schema must be one of the following: public, storage, graphql_public",
|
||||
],
|
||||
[
|
||||
"utilities",
|
||||
"comment_vote_modified_trigger",
|
||||
{},
|
||||
"The schema must be one of the following: public, storage, graphql_public",
|
||||
],
|
||||
[
|
||||
"public",
|
||||
"validate_post_access",
|
||||
{
|
||||
_post_id: "'00000000-0000-0000-0000-000000000000'::UUID",
|
||||
},
|
||||
"permission denied for function validate_post_access",
|
||||
],
|
||||
[
|
||||
"public",
|
||||
"validate_media_object_name",
|
||||
{
|
||||
_object_name: "",
|
||||
},
|
||||
"permission denied for function validate_media_object_name",
|
||||
],
|
||||
[
|
||||
"public",
|
||||
"delete_account",
|
||||
{},
|
||||
"permission denied for function delete_account",
|
||||
],
|
||||
[
|
||||
"public",
|
||||
"distance_to",
|
||||
{
|
||||
_other_location: "'POINT(0 0)'::extensions.GEOMETRY",
|
||||
},
|
||||
"permission denied for schema extensions",
|
||||
],
|
||||
[
|
||||
"public",
|
||||
"get_latest_location",
|
||||
{},
|
||||
"permission denied for function get_latest_location",
|
||||
],
|
||||
[
|
||||
"public",
|
||||
"calculate_rank",
|
||||
{
|
||||
_distance: 0,
|
||||
_score: 0,
|
||||
_created_at: "'1970-01-01T00:00:00Z'::TIMESTAMPTZ",
|
||||
},
|
||||
"permission denied for function calculate_rank",
|
||||
],
|
||||
])("can't call routine %s.%s", async (schema, fn, args, message) => {
|
||||
await expect(
|
||||
client.schema(schema as any).rpc(fn as any, args),
|
||||
).resolves.toHaveProperty("error.message", message);
|
||||
});
|
||||
|
||||
test.each([
|
||||
[
|
||||
"public",
|
||||
"profiles",
|
||||
"permission denied for table profiles",
|
||||
],
|
||||
[
|
||||
"public",
|
||||
"locations",
|
||||
"permission denied for table locations",
|
||||
],
|
||||
[
|
||||
"public",
|
||||
"posts",
|
||||
"permission denied for table posts",
|
||||
],
|
||||
[
|
||||
"public",
|
||||
"personalized_posts",
|
||||
"permission denied for view personalized_posts",
|
||||
],
|
||||
[
|
||||
"public",
|
||||
"post_views",
|
||||
"permission denied for table post_views",
|
||||
],
|
||||
[
|
||||
"public",
|
||||
"post_votes",
|
||||
"permission denied for table post_votes",
|
||||
],
|
||||
[
|
||||
"public",
|
||||
"post_reports",
|
||||
"permission denied for table post_reports",
|
||||
],
|
||||
[
|
||||
"public",
|
||||
"comments",
|
||||
"permission denied for table comments",
|
||||
],
|
||||
[
|
||||
"public",
|
||||
"personalized_comments",
|
||||
"permission denied for view personalized_comments",
|
||||
],
|
||||
[
|
||||
"public",
|
||||
"comment_views",
|
||||
"permission denied for table comment_views",
|
||||
],
|
||||
[
|
||||
"public",
|
||||
"comment_votes",
|
||||
"permission denied for table comment_votes",
|
||||
],
|
||||
[
|
||||
"public",
|
||||
"comment_reports",
|
||||
"permission denied for table comment_reports",
|
||||
],
|
||||
])("can't select relation %s.%s", async (schema, relation, message) => {
|
||||
await expect(
|
||||
client
|
||||
.schema(schema as any)
|
||||
.from(relation as any)
|
||||
.select("*"),
|
||||
).resolves.toHaveProperty("error.message", message);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
65
99_references/beacon-main/tsconfig.json
Normal file
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
/* Language and Environment */
|
||||
"target": "ESNext",
|
||||
/* Modules */
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"resolveJsonModule": true,
|
||||
"paths": {
|
||||
"#/*": [
|
||||
"./*"
|
||||
],
|
||||
"~/*": [
|
||||
"./src/*"
|
||||
]
|
||||
},
|
||||
"baseUrl": ".",
|
||||
"types": [
|
||||
"vite/client",
|
||||
"vite-plugin-svgr/client"
|
||||
],
|
||||
/* JavaScript Support */
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
/* Emit */
|
||||
"noEmit": true,
|
||||
/* Language and Environment */
|
||||
"lib": [
|
||||
"ESNext",
|
||||
"DOM"
|
||||
],
|
||||
"jsx": "react-jsx",
|
||||
/* Interop Constraints */
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"isolatedModules": true,
|
||||
/* Type Checking */
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"strictFunctionTypes": true,
|
||||
"strictBindCallApply": true,
|
||||
"strictPropertyInitialization": true,
|
||||
"noImplicitThis": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
/* Language and Environment */
|
||||
"useDefineForClassFields": true,
|
||||
/* Completeness */
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": [
|
||||
"**/*.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx"
|
||||
],
|
||||
"exclude": [
|
||||
"dist/*"
|
||||
]
|
||||
}
|
54
99_references/beacon-main/uno.config.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* @file UnoCSS config
|
||||
* @see https://unocss.dev/guide/config-file
|
||||
*/
|
||||
|
||||
import presetWind from "@unocss/preset-wind";
|
||||
import transformDirectives from "@unocss/transformer-directives";
|
||||
import {Colord, extend} from "colord";
|
||||
import mixPlugin from "colord/plugins/mix";
|
||||
import {defineConfig} from "unocss";
|
||||
|
||||
// Extend Colord
|
||||
extend([mixPlugin]);
|
||||
|
||||
/**
|
||||
* Primary color
|
||||
*/
|
||||
const primary = new Colord("#51c5db");
|
||||
|
||||
/**
|
||||
* Secondary color
|
||||
*/
|
||||
const secondary = new Colord("#f69876");
|
||||
|
||||
/**
|
||||
* Generate tones of a color
|
||||
* @param color Color to generate tones for
|
||||
* @returns Tones
|
||||
*/
|
||||
const generateTones = (color: Colord) =>
|
||||
Object.fromEntries(
|
||||
[
|
||||
...color.tints(22).reverse(),
|
||||
...color.shades(22).slice(1),
|
||||
]
|
||||
.map((tone, index) => [25 * index, tone.toHex()])
|
||||
.filter((_, index) => index === 1 || index % 2 === 0)
|
||||
.slice(1, -1),
|
||||
);
|
||||
|
||||
export default defineConfig({
|
||||
presets: [
|
||||
presetWind({
|
||||
dark: "class",
|
||||
}),
|
||||
],
|
||||
transformers: [transformDirectives()],
|
||||
theme: {
|
||||
colors: {
|
||||
primary: generateTones(primary),
|
||||
secondary: generateTones(secondary),
|
||||
},
|
||||
},
|
||||
});
|
95
99_references/beacon-main/vite.config.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/* eslint-disable @limegrass/import-alias/import-alias */
|
||||
/**
|
||||
* @file Vite config
|
||||
* @see https://vitejs.dev/config/
|
||||
*/
|
||||
/* eslint-disable camelcase */
|
||||
|
||||
import {dirname, join} from "node:path";
|
||||
import {fileURLToPath} from "node:url";
|
||||
|
||||
import Legacy from "@vitejs/plugin-legacy";
|
||||
import React from "@vitejs/plugin-react";
|
||||
import {execa} from "execa";
|
||||
import UnoCSS from "unocss/vite";
|
||||
import {defineConfig} from "vite";
|
||||
import {ViteEjsPlugin} from "vite-plugin-ejs";
|
||||
import {VitePWA} from "vite-plugin-pwa";
|
||||
import Svgr from "vite-plugin-svgr";
|
||||
import Paths from "vite-tsconfig-paths";
|
||||
|
||||
import {version} from "./package.json";
|
||||
|
||||
// Get the root directory
|
||||
const root = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig(async () => {
|
||||
// Get git information
|
||||
const {stdout: branch} = await execa("git", [
|
||||
"rev-parse",
|
||||
"--abbrev-ref",
|
||||
"HEAD",
|
||||
]);
|
||||
|
||||
const {stdout: commit} = await execa("git", [
|
||||
"rev-parse",
|
||||
"--short",
|
||||
"HEAD",
|
||||
]);
|
||||
|
||||
return {
|
||||
build: {
|
||||
rollupOptions: {
|
||||
external: ["/runtime-vars.js"],
|
||||
},
|
||||
sourcemap: true,
|
||||
},
|
||||
define: {
|
||||
"import.meta.env.VERSION": JSON.stringify(version),
|
||||
"import.meta.env.GIT_BRANCH": JSON.stringify(branch),
|
||||
"import.meta.env.GIT_COMMIT": JSON.stringify(commit),
|
||||
},
|
||||
plugins: [
|
||||
ViteEjsPlugin(),
|
||||
React(),
|
||||
Paths({
|
||||
projects: [join(root, "tsconfig.json")],
|
||||
}),
|
||||
Svgr(),
|
||||
UnoCSS(),
|
||||
Legacy(),
|
||||
VitePWA({
|
||||
registerType: "autoUpdate",
|
||||
manifest: {
|
||||
name: "Beacon",
|
||||
short_name: "Beacon",
|
||||
description: "Location-based social media",
|
||||
start_url: "/nearby",
|
||||
theme_color: "#51c5db",
|
||||
background_color: "#0c1922",
|
||||
icons: [
|
||||
{
|
||||
src: "/logo-192x192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
purpose: "any maskable",
|
||||
},
|
||||
{
|
||||
src: "/logo-512x512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "any maskable",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
server: {
|
||||
port: 3000,
|
||||
},
|
||||
test: {
|
||||
outputFile: join(root, "test/vitest/index.html"),
|
||||
reporters: ["default", "html"],
|
||||
},
|
||||
};
|
||||
});
|