init commit,

This commit is contained in:
louiscklaw
2025-05-28 09:55:51 +08:00
commit efe70ceb69
8042 changed files with 951668 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
Chrome >=79
ChromeAndroid >=79
Firefox >=70
Edge >=79
Safari >=14
iOS >=14

View 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.*

View 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

View 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.*

View 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"
}
}

View 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.

View 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.

View 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

View 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
View 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.*

View File

@@ -0,0 +1,10 @@
{
"arrowParens": "avoid",
"bracketSameLine": false,
"bracketSpacing": false,
"multilineArraysWrapThreshold": 2,
"htmlWhitespaceSensitivity": "strict",
"plugins": [
"prettier-plugin-multiline-arrays"
]
}

View File

@@ -0,0 +1,10 @@
{
"arrowParens": "avoid",
"bracketSameLine": false,
"bracketSpacing": false,
"multilineArraysWrapThreshold": 2,
"htmlWhitespaceSensitivity": "strict",
"plugins": [
"prettier-plugin-multiline-arrays"
]
}

View 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"
]
}

View 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"
}
}

View 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
}
}

View 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

View 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.

View 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.
[![Release Status](https://img.shields.io/github/actions/workflow/status/ColoradoSchoolOfMines/beacon/release.yml?label=Release&style=flat-square)](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$ | - |

View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

View 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 |

View File

@@ -0,0 +1,7 @@
<!--
Suggested privacy policy generator: https://termly.io/resources/templates/privacy-policy-template/
-->
Add your privacy policy here.

View File

@@ -0,0 +1,7 @@
<!--
Suggested privacy policy generator: https://termly.io/resources/templates/terms-of-service-template/
-->
Add your terms and conditions here.

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View 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}},
};

View 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);
}
};

View 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();

View 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();

View 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();

View 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 />
</>
);
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

View 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 &lt;b&gt;fill and stroke&lt;/b&gt; 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 &lt;b&gt;fill and stroke&lt;/b&gt; 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 &lt;b&gt;fill and stroke&lt;/b&gt; 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 &lt;b&gt;fill and stroke&lt;/b&gt; 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 &lt;b&gt;fill and stroke&lt;/b&gt; 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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

View 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 &lt;b&gt;fill and stroke&lt;/b&gt; 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 &lt;b&gt;fill and stroke&lt;/b&gt; 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 &lt;b&gt;fill and stroke&lt;/b&gt; 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 &lt;b&gt;fill and stroke&lt;/b&gt; 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 &lt;b&gt;fill and stroke&lt;/b&gt; 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

View 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>
);

View 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>
);
};

View 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}
/>
);
};

View File

@@ -0,0 +1,3 @@
.iconButton:global(::part(native)) {
@apply px-1 py-0;
}

View 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>
);
};

View File

@@ -0,0 +1,3 @@
.content:global(::part(scroll)) {
@apply p-0;
}

View File

@@ -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>
);

View File

@@ -0,0 +1,3 @@
.content:global(::part(scroll)) {
@apply p-0;
}

View File

@@ -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>
);

View 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()}
/>
);
};

View 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>
);

View 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;
}

View 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> | &copy; <a rel="noreferrer" target="_blank" href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors, &copy; <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>
);
};

View 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;
}

View 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>
);

View 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>
);
};

View File

@@ -0,0 +1,3 @@
.iconButton:global(::part(native)) {
@apply px-1 py-0;
}

View 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>
);
};

View 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>
);
};

View File

@@ -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>
);

View 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>
);
};

View 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
View 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;
}>;
}

View 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>,
);

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View File

@@ -0,0 +1,3 @@
.snapContent::part(scroll) {
@apply <md:snap-y <md:snap-mandatory;
}

View 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>
);
};

View 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>
);
};

View 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>
);
};

View File

@@ -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;
}

View File

@@ -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>
);
};

View 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>
);
};

View File

@@ -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;
}

View 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>
);
};

View File

@@ -0,0 +1,3 @@
.range {
--knob-size: 1.5rem;
}

View 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>
);
};

View File

@@ -0,0 +1,3 @@
.modal {
--height: auto;
}

View 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&apos;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>
);
};

View 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;
}

View 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)"

View File

@@ -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'
]
);

View File

@@ -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;
$$;

View File

@@ -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)
);

View File

@@ -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)
)
);

View File

@@ -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);

View File

@@ -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)
);

View File

@@ -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();

View File

@@ -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()'
);

View 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;
};

View 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);
});
});
},
);

View 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/*"
]
}

View 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),
},
},
});

View 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"],
},
};
});