issue 77
This commit is contained in:
parent
7c11ee50da
commit
95cc222302
|
|
@ -23,19 +23,34 @@ jobs:
|
|||
include:
|
||||
- name: api-rate-limiter
|
||||
type: python
|
||||
path: PROJECTS/api-rate-limiter
|
||||
path: PROJECTS/advanced/api-rate-limiter
|
||||
- name: dns-lookup
|
||||
type: python
|
||||
path: PROJECTS/dns-lookup
|
||||
path: PROJECTS/beginner/dns-lookup
|
||||
- name: keylogger
|
||||
type: python
|
||||
path: PROJECTS/keylogger
|
||||
- name: api-security-scanner
|
||||
type: typescript
|
||||
path: PROJECTS/api-security-scanner/frontend
|
||||
path: PROJECTS/beginner/keylogger
|
||||
- name: docker-security-audit
|
||||
type: go
|
||||
path: PROJECTS/docker-security-audit
|
||||
path: PROJECTS/beginner/docker-security-audit
|
||||
- name: bug-bounty-platform-frontend
|
||||
type: biome
|
||||
path: PROJECTS/advanced/bug-bounty-platform/frontend
|
||||
- name: c2-beacon-frontend
|
||||
type: biome
|
||||
path: PROJECTS/beginner/c2-beacon/frontend
|
||||
- name: api-security-scanner-frontend
|
||||
type: biome
|
||||
path: PROJECTS/intermediate/api-security-scanner/frontend
|
||||
- name: siem-dashboard-frontend
|
||||
type: biome
|
||||
path: PROJECTS/intermediate/siem-dashboard/frontend
|
||||
- name: encrypted-p2p-chat-frontend
|
||||
type: biome
|
||||
path: PROJECTS/advanced/encrypted-p2p-chat/frontend
|
||||
- name: fullstack-template-frontend
|
||||
type: biome
|
||||
path: TEMPLATES/fullstack-template/frontend
|
||||
|
||||
defaults:
|
||||
run:
|
||||
|
|
@ -68,18 +83,32 @@ jobs:
|
|||
python -m pip install --upgrade pip
|
||||
pip install -e ".[dev]"
|
||||
|
||||
# TypeScript Setup
|
||||
# Biome Setup
|
||||
- name: Setup Node.js
|
||||
if: matrix.type == 'typescript'
|
||||
if: matrix.type == 'biome'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: ${{ matrix.path }}/package-lock.json
|
||||
|
||||
- name: Install TypeScript dependencies
|
||||
if: matrix.type == 'typescript'
|
||||
run: npm install
|
||||
- name: Setup pnpm
|
||||
if: matrix.type == 'biome'
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- name: Cache pnpm store
|
||||
if: matrix.type == 'biome'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.local/share/pnpm/store/v10
|
||||
key: ${{ runner.os }}-pnpm-${{ matrix.name }}-${{ hashFiles(format('{0}/pnpm-lock.yaml', matrix.path)) }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-${{ matrix.name }}-
|
||||
${{ runner.os }}-pnpm-
|
||||
|
||||
- name: Install frontend dependencies
|
||||
if: matrix.type == 'biome'
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# Go Setup
|
||||
- name: Setup Go
|
||||
|
|
@ -135,35 +164,20 @@ jobs:
|
|||
cat mypy-output.txt
|
||||
continue-on-error: true
|
||||
|
||||
# TypeScript Linting
|
||||
- name: Run ESLint
|
||||
if: matrix.type == 'typescript'
|
||||
id: eslint
|
||||
# Biome Linting
|
||||
- name: Run Biome
|
||||
if: matrix.type == 'biome'
|
||||
id: biome
|
||||
run: |
|
||||
echo "Running ESLint..."
|
||||
if npm run lint:eslint > eslint-output.txt 2>&1; then
|
||||
echo "ESLINT_PASSED=true" >> $GITHUB_ENV
|
||||
echo "No ESLint errors found!"
|
||||
echo "Running Biome check..."
|
||||
if npx @biomejs/biome check . > biome-output.txt 2>&1; then
|
||||
echo "BIOME_PASSED=true" >> $GITHUB_ENV
|
||||
echo "No Biome errors found!"
|
||||
else
|
||||
echo "ESLINT_PASSED=false" >> $GITHUB_ENV
|
||||
echo "ESLint found issues!"
|
||||
echo "BIOME_PASSED=false" >> $GITHUB_ENV
|
||||
echo "Biome found issues!"
|
||||
fi
|
||||
cat eslint-output.txt
|
||||
continue-on-error: true
|
||||
|
||||
- name: Run TypeScript Check
|
||||
if: matrix.type == 'typescript'
|
||||
id: tsc
|
||||
run: |
|
||||
echo "Running TypeScript type checking..."
|
||||
if npx tsc --noEmit > tsc-output.txt 2>&1; then
|
||||
echo "TSC_PASSED=true" >> $GITHUB_ENV
|
||||
echo "No TypeScript errors found!"
|
||||
else
|
||||
echo "TSC_PASSED=false" >> $GITHUB_ENV
|
||||
echo "TypeScript found issues!"
|
||||
fi
|
||||
cat tsc-output.txt
|
||||
cat biome-output.txt
|
||||
continue-on-error: true
|
||||
|
||||
# Go Linting
|
||||
|
|
@ -247,46 +261,31 @@ jobs:
|
|||
echo "<!-- lint-check-${{ matrix.name }}-marker -->"
|
||||
} > lint-report.md
|
||||
|
||||
# Create Summary for TypeScript
|
||||
- name: Create TypeScript Lint Summary
|
||||
if: matrix.type == 'typescript' && github.event_name == 'pull_request'
|
||||
# Create Summary for Biome
|
||||
- name: Create Biome Lint Summary
|
||||
if: matrix.type == 'biome' && github.event_name == 'pull_request'
|
||||
run: |
|
||||
{
|
||||
echo "## Lint Results: ${{ matrix.name }}"
|
||||
echo ''
|
||||
|
||||
# ESLint Status
|
||||
if [[ "${{ env.ESLINT_PASSED }}" == "true" ]]; then
|
||||
echo '### ESLint: **Passed**'
|
||||
echo 'No ESLint issues found.'
|
||||
# Biome Status
|
||||
if [[ "${{ env.BIOME_PASSED }}" == "true" ]]; then
|
||||
echo '### Biome: **Passed**'
|
||||
echo 'No Biome issues found.'
|
||||
else
|
||||
echo '### ESLint: **Issues Found**'
|
||||
echo '<details><summary>View ESLint output</summary>'
|
||||
echo '### Biome: **Issues Found**'
|
||||
echo '<details><summary>View Biome output</summary>'
|
||||
echo ''
|
||||
echo '```'
|
||||
head -100 eslint-output.txt
|
||||
echo '```'
|
||||
echo '</details>'
|
||||
fi
|
||||
echo ''
|
||||
|
||||
# TypeScript Status
|
||||
if [[ "${{ env.TSC_PASSED }}" == "true" ]]; then
|
||||
echo '### TypeScript: **Passed**'
|
||||
echo 'No type errors found.'
|
||||
else
|
||||
echo '### TypeScript: **Issues Found**'
|
||||
echo '<details><summary>View TypeScript output</summary>'
|
||||
echo ''
|
||||
echo '```'
|
||||
head -100 tsc-output.txt
|
||||
head -100 biome-output.txt
|
||||
echo '```'
|
||||
echo '</details>'
|
||||
fi
|
||||
echo ''
|
||||
|
||||
# Overall Summary
|
||||
if [[ "${{ env.ESLINT_PASSED }}" == "true" ]] && [[ "${{ env.TSC_PASSED }}" == "true" ]]; then
|
||||
if [[ "${{ env.BIOME_PASSED }}" == "true" ]]; then
|
||||
echo '---'
|
||||
echo '### All checks passed!'
|
||||
else
|
||||
|
|
@ -350,9 +349,9 @@ jobs:
|
|||
echo "Python lint checks failed"
|
||||
exit 1
|
||||
fi
|
||||
elif [[ "${{ matrix.type }}" == "typescript" ]]; then
|
||||
if [[ "${{ env.ESLINT_PASSED }}" == "false" ]] || [[ "${{ env.TSC_PASSED }}" == "false" ]]; then
|
||||
echo "TypeScript lint checks failed"
|
||||
elif [[ "${{ matrix.type }}" == "biome" ]]; then
|
||||
if [[ "${{ env.BIOME_PASSED }}" == "false" ]]; then
|
||||
echo "Biome lint checks failed"
|
||||
exit 1
|
||||
fi
|
||||
elif [[ "${{ matrix.type }}" == "go" ]]; then
|
||||
|
|
|
|||
|
|
@ -16,8 +16,50 @@ repos:
|
|||
# TODO: add golangci checks for GO projects
|
||||
|
||||
|
||||
# TODO: replace eslint in any full-stack (React) projects with biome then add biome checks
|
||||
# Biome Frontend Checks
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: biome-bug-bounty-platform
|
||||
name: biome check (bug-bounty-platform frontend)
|
||||
entry: bash -c 'cd PROJECTS/advanced/bug-bounty-platform/frontend && npx @biomejs/biome check .'
|
||||
language: system
|
||||
files: ^PROJECTS/advanced/bug-bounty-platform/frontend/src/
|
||||
pass_filenames: false
|
||||
|
||||
- id: biome-c2-beacon
|
||||
name: biome check (c2-beacon frontend)
|
||||
entry: bash -c 'cd PROJECTS/beginner/c2-beacon/frontend && npx @biomejs/biome check .'
|
||||
language: system
|
||||
files: ^PROJECTS/beginner/c2-beacon/frontend/src/
|
||||
pass_filenames: false
|
||||
|
||||
- id: biome-api-security-scanner
|
||||
name: biome check (api-security-scanner frontend)
|
||||
entry: bash -c 'cd PROJECTS/intermediate/api-security-scanner/frontend && npx @biomejs/biome check .'
|
||||
language: system
|
||||
files: ^PROJECTS/intermediate/api-security-scanner/frontend/src/
|
||||
pass_filenames: false
|
||||
|
||||
- id: biome-siem-dashboard
|
||||
name: biome check (siem-dashboard frontend)
|
||||
entry: bash -c 'cd PROJECTS/intermediate/siem-dashboard/frontend && npx @biomejs/biome check .'
|
||||
language: system
|
||||
files: ^PROJECTS/intermediate/siem-dashboard/frontend/src/
|
||||
pass_filenames: false
|
||||
|
||||
- id: biome-encrypted-p2p-chat
|
||||
name: biome check (encrypted-p2p-chat frontend)
|
||||
entry: bash -c 'cd PROJECTS/advanced/encrypted-p2p-chat/frontend && npx @biomejs/biome check .'
|
||||
language: system
|
||||
files: ^PROJECTS/advanced/encrypted-p2p-chat/frontend/src/
|
||||
pass_filenames: false
|
||||
|
||||
- id: biome-fullstack-template
|
||||
name: biome check (fullstack-template frontend)
|
||||
entry: bash -c 'cd TEMPLATES/fullstack-template/frontend && npx @biomejs/biome check .'
|
||||
language: system
|
||||
files: ^TEMPLATES/fullstack-template/frontend/src/
|
||||
pass_filenames: false
|
||||
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v6.0.0
|
||||
|
|
|
|||
|
|
@ -23,10 +23,10 @@ classifiers = [
|
|||
]
|
||||
|
||||
dependencies = [
|
||||
"fastapi[standard]>=0.128.7",
|
||||
"fastapi[standard]>=0.129.0",
|
||||
"pydantic>=2.12.5,<3.0.0",
|
||||
"pydantic-settings>=2.12.0,<3.0.0",
|
||||
"redis>=7.1.1",
|
||||
"pydantic-settings>=2.13.0",
|
||||
"redis>=7.2.0",
|
||||
"pyjwt>=2.11.0",
|
||||
]
|
||||
|
||||
|
|
@ -36,12 +36,12 @@ dev = [
|
|||
"pytest-asyncio>=1.3.0",
|
||||
"pytest-cov>=7.0.0",
|
||||
"httpx>=0.28.1",
|
||||
"fakeredis>=2.33.0",
|
||||
"fakeredis>=2.34.0",
|
||||
"time-machine>=3.2.0",
|
||||
"asgi-lifespan>=2.1.0",
|
||||
"mypy>=1.19.1",
|
||||
"types-redis>=4.6.0.20241004",
|
||||
"ruff>=0.15.0",
|
||||
"ruff>=0.15.1",
|
||||
"pylint>=4.0.4",
|
||||
"pylint-pydantic>=0.4.1",
|
||||
"pylint-per-file-ignores>=3.2.0",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.2/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
"lint": "biome check .",
|
||||
"lint:fix": "biome check --write .",
|
||||
"format": "biome format --write .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck": "tsc -b",
|
||||
"lint:scss": "stylelint '**/*.scss'",
|
||||
"lint:scss:fix": "stylelint '**/*.scss' --fix"
|
||||
},
|
||||
|
|
@ -28,7 +28,7 @@
|
|||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.3.8",
|
||||
"@biomejs/biome": "^2.4.2",
|
||||
"@tanstack/react-query-devtools": "^5.91.1",
|
||||
"@types/node": "^24.10.2",
|
||||
"@types/react": "^19.2.7",
|
||||
|
|
|
|||
|
|
@ -46,8 +46,8 @@ importers:
|
|||
version: 5.0.9(@types/react@19.2.7)(react@19.2.3)
|
||||
devDependencies:
|
||||
'@biomejs/biome':
|
||||
specifier: ^2.3.8
|
||||
version: 2.3.11
|
||||
specifier: ^2.4.2
|
||||
version: 2.4.2
|
||||
'@tanstack/react-query-devtools':
|
||||
specifier: ^5.91.1
|
||||
version: 5.91.2(@tanstack/react-query@5.90.16(react@19.2.3))(react@19.2.3)
|
||||
|
|
@ -170,55 +170,59 @@ packages:
|
|||
resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@biomejs/biome@2.3.11':
|
||||
resolution: {integrity: sha512-/zt+6qazBWguPG6+eWmiELqO+9jRsMZ/DBU3lfuU2ngtIQYzymocHhKiZRyrbra4aCOoyTg/BmY+6WH5mv9xmQ==}
|
||||
'@biomejs/biome@2.4.2':
|
||||
resolution: {integrity: sha512-vVE/FqLxNLbvYnFDYg3Xfrh1UdFhmPT5i+yPT9GE2nTUgI4rkqo5krw5wK19YHBd7aE7J6r91RRmb8RWwkjy6w==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
hasBin: true
|
||||
|
||||
'@biomejs/cli-darwin-arm64@2.3.11':
|
||||
resolution: {integrity: sha512-/uXXkBcPKVQY7rc9Ys2CrlirBJYbpESEDme7RKiBD6MmqR2w3j0+ZZXRIL2xiaNPsIMMNhP1YnA+jRRxoOAFrA==}
|
||||
'@biomejs/cli-darwin-arm64@2.4.2':
|
||||
resolution: {integrity: sha512-3pEcKCP/1POKyaZZhXcxFl3+d9njmeAihZ17k8lL/1vk+6e0Cbf0yPzKItFiT+5Yh6TQA4uKvnlqe0oVZwRxCA==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@biomejs/cli-darwin-x64@2.3.11':
|
||||
resolution: {integrity: sha512-fh7nnvbweDPm2xEmFjfmq7zSUiox88plgdHF9OIW4i99WnXrAC3o2P3ag9judoUMv8FCSUnlwJCM1B64nO5Fbg==}
|
||||
'@biomejs/cli-darwin-x64@2.4.2':
|
||||
resolution: {integrity: sha512-P7hK1jLVny+0R9UwyGcECxO6sjETxfPyBm/1dmFjnDOHgdDPjPqozByunrwh4xPKld8sxOr5eAsSqal5uKgeBg==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@biomejs/cli-linux-arm64-musl@2.3.11':
|
||||
resolution: {integrity: sha512-XPSQ+XIPZMLaZ6zveQdwNjbX+QdROEd1zPgMwD47zvHV+tCGB88VH+aynyGxAHdzL+Tm/+DtKST5SECs4iwCLg==}
|
||||
'@biomejs/cli-linux-arm64-musl@2.4.2':
|
||||
resolution: {integrity: sha512-/x04YK9+7erw6tYEcJv9WXoBHcULI/wMOvNdAyE9S3JStZZ9yJyV67sWAI+90UHuDo/BDhq0d96LDqGlSVv7WA==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@biomejs/cli-linux-arm64@2.3.11':
|
||||
resolution: {integrity: sha512-l4xkGa9E7Uc0/05qU2lMYfN1H+fzzkHgaJoy98wO+b/7Gl78srbCRRgwYSW+BTLixTBrM6Ede5NSBwt7rd/i6g==}
|
||||
'@biomejs/cli-linux-arm64@2.4.2':
|
||||
resolution: {integrity: sha512-DI3Mi7GT2zYNgUTDEbSjl3e1KhoP76OjQdm8JpvZYZWtVDRyLd3w8llSr2TWk1z+U3P44kUBWY3X7H9MD1/DGQ==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@biomejs/cli-linux-x64-musl@2.3.11':
|
||||
resolution: {integrity: sha512-vU7a8wLs5C9yJ4CB8a44r12aXYb8yYgBn+WeyzbMjaCMklzCv1oXr8x+VEyWodgJt9bDmhiaW/I0RHbn7rsNmw==}
|
||||
'@biomejs/cli-linux-x64-musl@2.4.2':
|
||||
resolution: {integrity: sha512-wbBmTkeAoAYbOQ33f6sfKG7pcRSydQiF+dTYOBjJsnXO2mWEOQHllKlC2YVnedqZFERp2WZhFUoO7TNRwnwEHQ==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@biomejs/cli-linux-x64@2.3.11':
|
||||
resolution: {integrity: sha512-/1s9V/H3cSe0r0Mv/Z8JryF5x9ywRxywomqZVLHAoa/uN0eY7F8gEngWKNS5vbbN/BsfpCG5yeBT5ENh50Frxg==}
|
||||
'@biomejs/cli-linux-x64@2.4.2':
|
||||
resolution: {integrity: sha512-GK2ErnrKpWFigYP68cXiCHK4RTL4IUWhK92AFS3U28X/nuAL5+hTuy6hyobc8JZRSt+upXt1nXChK+tuHHx4mA==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@biomejs/cli-win32-arm64@2.3.11':
|
||||
resolution: {integrity: sha512-PZQ6ElCOnkYapSsysiTy0+fYX+agXPlWugh6+eQ6uPKI3vKAqNp6TnMhoM3oY2NltSB89hz59o8xIfOdyhi9Iw==}
|
||||
'@biomejs/cli-win32-arm64@2.4.2':
|
||||
resolution: {integrity: sha512-k2uqwLYrNNxnaoiW3RJxoMGnbKda8FuCmtYG3cOtVljs3CzWxaTR+AoXwKGHscC9thax9R4kOrtWqWN0+KdPTw==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@biomejs/cli-win32-x64@2.3.11':
|
||||
resolution: {integrity: sha512-43VrG813EW+b5+YbDbz31uUsheX+qFKCpXeY9kfdAx+ww3naKxeVkTD9zLIWxUPfJquANMHrmW3wbe/037G0Qg==}
|
||||
'@biomejs/cli-win32-x64@2.4.2':
|
||||
resolution: {integrity: sha512-9ma7C4g8Sq3cBlRJD2yrsHXB1mnnEBdpy7PhvFrylQWQb4PoyCmPucdX7frvsSBQuFtIiKCrolPl/8tCZrKvgQ==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
|
@ -344,36 +348,42 @@ packages:
|
|||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@parcel/watcher-linux-arm-musl@2.5.1':
|
||||
resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@parcel/watcher-linux-arm64-glibc@2.5.1':
|
||||
resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@parcel/watcher-linux-arm64-musl@2.5.1':
|
||||
resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@parcel/watcher-linux-x64-glibc@2.5.1':
|
||||
resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@parcel/watcher-linux-x64-musl@2.5.1':
|
||||
resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@parcel/watcher-win32-arm64@2.5.1':
|
||||
resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==}
|
||||
|
|
@ -432,24 +442,28 @@ packages:
|
|||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-arm64-musl@1.0.0-beta.50':
|
||||
resolution: {integrity: sha512-L0zRdH2oDPkmB+wvuTl+dJbXCsx62SkqcEqdM+79LOcB+PxbAxxjzHU14BuZIQdXcAVDzfpMfaHWzZuwhhBTcw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/binding-linux-x64-gnu@1.0.0-beta.50':
|
||||
resolution: {integrity: sha512-gyoI8o/TGpQd3OzkJnh1M2kxy1Bisg8qJ5Gci0sXm9yLFzEXIFdtc4EAzepxGvrT2ri99ar5rdsmNG0zP0SbIg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-x64-musl@1.0.0-beta.50':
|
||||
resolution: {integrity: sha512-zti8A7M+xFDpKlghpcCAzyOi+e5nfUl3QhU023ce5NCgUxRG5zGP2GR9LTydQ1rnIPwZUVBWd4o7NjZDaQxaXA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/binding-openharmony-arm64@1.0.0-beta.50':
|
||||
resolution: {integrity: sha512-eZUssog7qljrrRU9Mi0eqYEPm3Ch0UwB+qlWPMKSUXHNqhm3TvDZarJQdTevGEfu3EHAXJvBIe0YFYr0TPVaMA==}
|
||||
|
|
@ -954,24 +968,28 @@ packages:
|
|||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-arm64-musl@1.30.2:
|
||||
resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-linux-x64-gnu@1.30.2:
|
||||
resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-x64-musl@1.30.2:
|
||||
resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-win32-arm64-msvc@1.30.2:
|
||||
resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
|
||||
|
|
@ -1528,39 +1546,39 @@ snapshots:
|
|||
'@babel/helper-string-parser': 7.27.1
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
|
||||
'@biomejs/biome@2.3.11':
|
||||
'@biomejs/biome@2.4.2':
|
||||
optionalDependencies:
|
||||
'@biomejs/cli-darwin-arm64': 2.3.11
|
||||
'@biomejs/cli-darwin-x64': 2.3.11
|
||||
'@biomejs/cli-linux-arm64': 2.3.11
|
||||
'@biomejs/cli-linux-arm64-musl': 2.3.11
|
||||
'@biomejs/cli-linux-x64': 2.3.11
|
||||
'@biomejs/cli-linux-x64-musl': 2.3.11
|
||||
'@biomejs/cli-win32-arm64': 2.3.11
|
||||
'@biomejs/cli-win32-x64': 2.3.11
|
||||
'@biomejs/cli-darwin-arm64': 2.4.2
|
||||
'@biomejs/cli-darwin-x64': 2.4.2
|
||||
'@biomejs/cli-linux-arm64': 2.4.2
|
||||
'@biomejs/cli-linux-arm64-musl': 2.4.2
|
||||
'@biomejs/cli-linux-x64': 2.4.2
|
||||
'@biomejs/cli-linux-x64-musl': 2.4.2
|
||||
'@biomejs/cli-win32-arm64': 2.4.2
|
||||
'@biomejs/cli-win32-x64': 2.4.2
|
||||
|
||||
'@biomejs/cli-darwin-arm64@2.3.11':
|
||||
'@biomejs/cli-darwin-arm64@2.4.2':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-darwin-x64@2.3.11':
|
||||
'@biomejs/cli-darwin-x64@2.4.2':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-linux-arm64-musl@2.3.11':
|
||||
'@biomejs/cli-linux-arm64-musl@2.4.2':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-linux-arm64@2.3.11':
|
||||
'@biomejs/cli-linux-arm64@2.4.2':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-linux-x64-musl@2.3.11':
|
||||
'@biomejs/cli-linux-x64-musl@2.4.2':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-linux-x64@2.3.11':
|
||||
'@biomejs/cli-linux-x64@2.4.2':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-win32-arm64@2.3.11':
|
||||
'@biomejs/cli-win32-arm64@2.4.2':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-win32-x64@2.3.11':
|
||||
'@biomejs/cli-win32-x64@2.4.2':
|
||||
optional: true
|
||||
|
||||
'@cacheable/memory@2.0.7':
|
||||
|
|
|
|||
|
|
@ -165,11 +165,13 @@ export const REPORT_STATUS_COLORS: Record<ReportStatus, string> = {
|
|||
}
|
||||
|
||||
export const isOpenStatus = (status: ReportStatus): boolean => {
|
||||
return ([
|
||||
ReportStatus.NEW,
|
||||
ReportStatus.TRIAGING,
|
||||
ReportStatus.NEEDS_MORE_INFO,
|
||||
] as ReportStatus[]).includes(status)
|
||||
return (
|
||||
[
|
||||
ReportStatus.NEW,
|
||||
ReportStatus.TRIAGING,
|
||||
ReportStatus.NEEDS_MORE_INFO,
|
||||
] as ReportStatus[]
|
||||
).includes(status)
|
||||
}
|
||||
|
||||
export const isClosedStatus = (status: ReportStatus): boolean => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,104 @@
|
|||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.2/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"includes": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "**/*.json"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 82,
|
||||
"lineEnding": "lf"
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "single",
|
||||
"jsxQuoteStyle": "double",
|
||||
"semicolons": "asNeeded",
|
||||
"trailingCommas": "es5",
|
||||
"arrowParentheses": "always"
|
||||
}
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"complexity": {
|
||||
"noExcessiveCognitiveComplexity": {
|
||||
"level": "error",
|
||||
"options": { "maxAllowedComplexity": 25 }
|
||||
},
|
||||
"noForEach": "off",
|
||||
"useLiteralKeys": "off"
|
||||
},
|
||||
"correctness": {
|
||||
"noUnusedVariables": "error",
|
||||
"noUnusedImports": "error",
|
||||
"useExhaustiveDependencies": "warn",
|
||||
"useHookAtTopLevel": "error",
|
||||
"noUndeclaredVariables": "error"
|
||||
},
|
||||
"style": {
|
||||
"useImportType": "error",
|
||||
"useConst": "error",
|
||||
"useTemplate": "error",
|
||||
"useSelfClosingElements": "error",
|
||||
"useFragmentSyntax": "error",
|
||||
"noNonNullAssertion": "error",
|
||||
"useConsistentArrayType": {
|
||||
"level": "error",
|
||||
"options": { "syntax": "shorthand" }
|
||||
},
|
||||
"useNamingConvention": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noExplicitAny": "error",
|
||||
"noDebugger": "error",
|
||||
"noConsole": "warn",
|
||||
"noArrayIndexKey": "warn",
|
||||
"noAssignInExpressions": "error",
|
||||
"noDoubleEquals": "error",
|
||||
"noRedeclare": "error",
|
||||
"noVar": "error"
|
||||
},
|
||||
"security": {
|
||||
"noDangerouslySetInnerHtml": "error"
|
||||
},
|
||||
"a11y": {
|
||||
"useAltText": "error",
|
||||
"useAnchorContent": "error",
|
||||
"useKeyWithClickEvents": "error",
|
||||
"noStaticElementInteractions": "error",
|
||||
"useButtonType": "error",
|
||||
"useValidAnchor": "error"
|
||||
}
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"includes": ["src/main.tsx"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"style": {
|
||||
"noNonNullAssertion": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"includes": ["src/components/UI/Tooltip.tsx"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"a11y": {
|
||||
"noStaticElementInteractions": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -2,22 +2,16 @@
|
|||
// © AngelaMos | 2025
|
||||
// eslint.config.js
|
||||
// ===================
|
||||
import js from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
import solid from "eslint-plugin-solid/configs/typescript";
|
||||
import jsxA11y from "eslint-plugin-jsx-a11y";
|
||||
import prettierConfig from "eslint-config-prettier";
|
||||
import globals from "globals";
|
||||
import js from '@eslint/js'
|
||||
import prettierConfig from 'eslint-config-prettier'
|
||||
import jsxA11y from 'eslint-plugin-jsx-a11y'
|
||||
import solid from 'eslint-plugin-solid/configs/typescript'
|
||||
import globals from 'globals'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: [
|
||||
"dist",
|
||||
"node_modules",
|
||||
"*.config.js",
|
||||
"*.config.ts",
|
||||
"*.min.js",
|
||||
],
|
||||
ignores: ['dist', 'node_modules', '*.config.js', '*.config.ts', '*.min.js'],
|
||||
},
|
||||
|
||||
js.configs.recommended,
|
||||
|
|
@ -26,16 +20,16 @@ export default tseslint.config(
|
|||
...tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
...solid,
|
||||
plugins: {
|
||||
...solid.plugins,
|
||||
"jsx-a11y": jsxA11y,
|
||||
'jsx-a11y': jsxA11y,
|
||||
},
|
||||
languageOptions: {
|
||||
parser: tseslint.parser,
|
||||
parserOptions: {
|
||||
project: ["./tsconfig.json"],
|
||||
project: ['./tsconfig.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
ecmaFeatures: { jsx: true },
|
||||
},
|
||||
|
|
@ -47,23 +41,23 @@ export default tseslint.config(
|
|||
rules: {
|
||||
...solid.rules,
|
||||
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
argsIgnorePattern: "^_",
|
||||
varsIgnorePattern: "^_",
|
||||
caughtErrorsIgnorePattern: "^_",
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
"@typescript-eslint/consistent-type-imports": [
|
||||
"error",
|
||||
'@typescript-eslint/consistent-type-imports': [
|
||||
'error',
|
||||
{
|
||||
prefer: "type-imports",
|
||||
fixStyle: "inline-type-imports",
|
||||
prefer: 'type-imports',
|
||||
fixStyle: 'inline-type-imports',
|
||||
},
|
||||
],
|
||||
"@typescript-eslint/explicit-function-return-type": [
|
||||
"error",
|
||||
'@typescript-eslint/explicit-function-return-type': [
|
||||
'error',
|
||||
{
|
||||
allowExpressions: true,
|
||||
allowTypedFunctionExpressions: true,
|
||||
|
|
@ -71,15 +65,15 @@ export default tseslint.config(
|
|||
allowDirectConstAssertionInArrowFunctions: true,
|
||||
},
|
||||
],
|
||||
"@typescript-eslint/naming-convention": "off",
|
||||
"@typescript-eslint/no-non-null-assertion": "error",
|
||||
"@typescript-eslint/array-type": ["error", { default: "array" }],
|
||||
"@typescript-eslint/no-explicit-any": "error",
|
||||
"@typescript-eslint/no-confusing-void-expression": "off",
|
||||
"@typescript-eslint/no-unnecessary-condition": "off",
|
||||
"@typescript-eslint/no-floating-promises": "error",
|
||||
"@typescript-eslint/strict-boolean-expressions": [
|
||||
"error",
|
||||
'@typescript-eslint/naming-convention': 'off',
|
||||
'@typescript-eslint/no-non-null-assertion': 'error',
|
||||
'@typescript-eslint/array-type': ['error', { default: 'array' }],
|
||||
'@typescript-eslint/no-explicit-any': 'error',
|
||||
'@typescript-eslint/no-confusing-void-expression': 'off',
|
||||
'@typescript-eslint/no-unnecessary-condition': 'off',
|
||||
'@typescript-eslint/no-floating-promises': 'error',
|
||||
'@typescript-eslint/strict-boolean-expressions': [
|
||||
'error',
|
||||
{
|
||||
allowString: false,
|
||||
allowNumber: false,
|
||||
|
|
@ -88,11 +82,11 @@ export default tseslint.config(
|
|||
allowAny: true,
|
||||
},
|
||||
],
|
||||
"@typescript-eslint/prefer-as-const": "error",
|
||||
"@typescript-eslint/consistent-type-definitions": ["error", "interface"],
|
||||
"@typescript-eslint/restrict-template-expressions": "off",
|
||||
"@typescript-eslint/no-misused-promises": [
|
||||
"error",
|
||||
'@typescript-eslint/prefer-as-const': 'error',
|
||||
'@typescript-eslint/consistent-type-definitions': ['error', 'interface'],
|
||||
'@typescript-eslint/restrict-template-expressions': 'off',
|
||||
'@typescript-eslint/no-misused-promises': [
|
||||
'error',
|
||||
{
|
||||
checksVoidReturn: {
|
||||
attributes: false,
|
||||
|
|
@ -100,57 +94,57 @@ export default tseslint.config(
|
|||
},
|
||||
],
|
||||
|
||||
"solid/reactivity": "warn",
|
||||
"solid/no-destructure": "warn",
|
||||
"solid/jsx-no-undef": "error",
|
||||
"solid/no-react-specific-props": "error",
|
||||
"solid/prefer-for": "warn",
|
||||
"solid/self-closing-comp": "error",
|
||||
"solid/style-prop": "error",
|
||||
"solid/no-innerhtml": "error",
|
||||
"solid/no-unknown-namespaces": "error",
|
||||
"solid/event-handlers": [
|
||||
"error",
|
||||
'solid/reactivity': 'warn',
|
||||
'solid/no-destructure': 'warn',
|
||||
'solid/jsx-no-undef': 'error',
|
||||
'solid/no-react-specific-props': 'error',
|
||||
'solid/prefer-for': 'warn',
|
||||
'solid/self-closing-comp': 'error',
|
||||
'solid/style-prop': 'error',
|
||||
'solid/no-innerhtml': 'error',
|
||||
'solid/no-unknown-namespaces': 'error',
|
||||
'solid/event-handlers': [
|
||||
'error',
|
||||
{
|
||||
ignoreCase: false,
|
||||
warnOnSpread: true,
|
||||
},
|
||||
],
|
||||
"solid/imports": "error",
|
||||
"solid/no-proxy-apis": "off",
|
||||
'solid/imports': 'error',
|
||||
'solid/no-proxy-apis': 'off',
|
||||
|
||||
"jsx-a11y/alt-text": "error",
|
||||
"jsx-a11y/anchor-has-content": "error",
|
||||
"jsx-a11y/click-events-have-key-events": "error",
|
||||
"jsx-a11y/no-static-element-interactions": "error",
|
||||
"jsx-a11y/no-noninteractive-element-interactions": "warn",
|
||||
"jsx-a11y/aria-props": "error",
|
||||
"jsx-a11y/aria-role": "error",
|
||||
"jsx-a11y/role-has-required-aria-props": "error",
|
||||
'jsx-a11y/alt-text': 'error',
|
||||
'jsx-a11y/anchor-has-content': 'error',
|
||||
'jsx-a11y/click-events-have-key-events': 'error',
|
||||
'jsx-a11y/no-static-element-interactions': 'error',
|
||||
'jsx-a11y/no-noninteractive-element-interactions': 'warn',
|
||||
'jsx-a11y/aria-props': 'error',
|
||||
'jsx-a11y/aria-role': 'error',
|
||||
'jsx-a11y/role-has-required-aria-props': 'error',
|
||||
|
||||
"no-console": ["warn", { allow: ["warn", "error"] }],
|
||||
"no-debugger": "error",
|
||||
"no-alert": "error",
|
||||
"no-var": "error",
|
||||
"prefer-const": "error",
|
||||
"prefer-template": "error",
|
||||
"object-shorthand": "error",
|
||||
"no-nested-ternary": "error",
|
||||
"max-depth": ["error", 6],
|
||||
"max-lines": [
|
||||
"error",
|
||||
'no-console': ['warn', { allow: ['warn', 'error'] }],
|
||||
'no-debugger': 'error',
|
||||
'no-alert': 'error',
|
||||
'no-var': 'error',
|
||||
'prefer-const': 'error',
|
||||
'prefer-template': 'error',
|
||||
'object-shorthand': 'error',
|
||||
'no-nested-ternary': 'error',
|
||||
'max-depth': ['error', 6],
|
||||
'max-lines': [
|
||||
'error',
|
||||
{ max: 2000, skipBlankLines: true, skipComments: true },
|
||||
],
|
||||
complexity: ["error", 55],
|
||||
complexity: ['error', 55],
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
files: ["src/index.tsx"],
|
||||
files: ['src/index.tsx'],
|
||||
rules: {
|
||||
"@typescript-eslint/no-non-null-assertion": "off",
|
||||
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
prettierConfig
|
||||
);
|
||||
)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -7,36 +7,38 @@
|
|||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "biome check .",
|
||||
"lint:fix": "biome check --write .",
|
||||
"lint:eslint": "eslint .",
|
||||
"lint:eslint:fix": "eslint . --fix",
|
||||
"lint:types": "tsc --noEmit",
|
||||
"lint": "npm run lint:eslint && npm run lint:types",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx,css}\"",
|
||||
"format:check": "prettier --check \"src/**/*.{ts,tsx,css}\""
|
||||
"format": "biome format --write .",
|
||||
"format:prettier": "prettier --write \"src/**/*.{ts,tsx,css}\"",
|
||||
"format:check": "prettier --check \"src/**/*.{ts,tsx,css}\"",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nanostores/persistent": "^1.2.0",
|
||||
"@nanostores/persistent": "^1.3.3",
|
||||
"@nanostores/solid": "^1.1.1",
|
||||
"@solidjs/router": "^0.15.3",
|
||||
"@tanstack/solid-query": "^5.90.22",
|
||||
"@solidjs/router": "^0.15.4",
|
||||
"@tanstack/solid-query": "^5.90.23",
|
||||
"nanostores": "^1.1.0",
|
||||
"solid-js": "^1.9.10"
|
||||
"solid-js": "^1.9.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.2",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/node": "^25.0.10",
|
||||
"@tailwindcss/vite": "^4.2.0",
|
||||
"@types/node": "^25.2.3",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-solid": "^0.14.5",
|
||||
"globals": "^17.1.0",
|
||||
"globals": "^17.3.0",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"tailwindcss": "^4.2.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.53.1",
|
||||
"typescript-eslint": "^8.56.0",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-solid": "^2.11.10"
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -2,14 +2,14 @@
|
|||
// © AngelaMos | 2025
|
||||
// App.tsx
|
||||
// ===================
|
||||
import { Route } from "@solidjs/router";
|
||||
import { type Component, lazy } from "solid-js";
|
||||
import { Route } from '@solidjs/router'
|
||||
import { type Component, lazy } from 'solid-js'
|
||||
|
||||
const Home = lazy(() => import("./pages/Home"));
|
||||
const Register = lazy(() => import("./pages/Register"));
|
||||
const Login = lazy(() => import("./pages/Login"));
|
||||
const Chat = lazy(() => import("./pages/Chat"));
|
||||
const NotFound = lazy(() => import("./pages/NotFound"));
|
||||
const Home = lazy(() => import('./pages/Home'))
|
||||
const Register = lazy(() => import('./pages/Register'))
|
||||
const Login = lazy(() => import('./pages/Login'))
|
||||
const Chat = lazy(() => import('./pages/Chat'))
|
||||
const NotFound = lazy(() => import('./pages/NotFound'))
|
||||
|
||||
const App: Component = () => {
|
||||
return (
|
||||
|
|
@ -20,7 +20,7 @@ const App: Component = () => {
|
|||
<Route path="/chat" component={Chat} />
|
||||
<Route path="*" component={NotFound} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default App;
|
||||
export default App
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
* 8-bit styled auth card container
|
||||
*/
|
||||
|
||||
import type { ParentProps, JSX } from "solid-js"
|
||||
import { Show } from "solid-js"
|
||||
import type { JSX, ParentProps } from 'solid-js'
|
||||
import { Show } from 'solid-js'
|
||||
|
||||
interface AuthCardProps extends ParentProps {
|
||||
title: string
|
||||
|
|
@ -39,9 +39,7 @@ export function AuthCard(props: AuthCardProps): JSX.Element {
|
|||
</div>
|
||||
|
||||
<div class="mt-4 flex justify-center">
|
||||
<span class="font-pixel text-[8px] text-gray">
|
||||
END-TO-END ENCRYPTED
|
||||
</span>
|
||||
<span class="font-pixel text-[8px] text-gray">END-TO-END ENCRYPTED</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -49,13 +47,61 @@ export function AuthCard(props: AuthCardProps): JSX.Element {
|
|||
|
||||
function LockIcon(): JSX.Element {
|
||||
return (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="7" y="4" width="10" height="2" fill="currentColor" class="text-orange" />
|
||||
<rect x="5" y="6" width="2" height="5" fill="currentColor" class="text-orange" />
|
||||
<rect x="17" y="6" width="2" height="5" fill="currentColor" class="text-orange" />
|
||||
<rect x="4" y="11" width="16" height="2" fill="currentColor" class="text-orange" />
|
||||
<rect x="4" y="13" width="16" height="8" fill="currentColor" class="text-orange" />
|
||||
<rect x="11" y="15" width="2" height="4" fill="currentColor" class="text-black" />
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect
|
||||
x="7"
|
||||
y="4"
|
||||
width="10"
|
||||
height="2"
|
||||
fill="currentColor"
|
||||
class="text-orange"
|
||||
/>
|
||||
<rect
|
||||
x="5"
|
||||
y="6"
|
||||
width="2"
|
||||
height="5"
|
||||
fill="currentColor"
|
||||
class="text-orange"
|
||||
/>
|
||||
<rect
|
||||
x="17"
|
||||
y="6"
|
||||
width="2"
|
||||
height="5"
|
||||
fill="currentColor"
|
||||
class="text-orange"
|
||||
/>
|
||||
<rect
|
||||
x="4"
|
||||
y="11"
|
||||
width="16"
|
||||
height="2"
|
||||
fill="currentColor"
|
||||
class="text-orange"
|
||||
/>
|
||||
<rect
|
||||
x="4"
|
||||
y="13"
|
||||
width="16"
|
||||
height="8"
|
||||
fill="currentColor"
|
||||
class="text-orange"
|
||||
/>
|
||||
<rect
|
||||
x="11"
|
||||
y="15"
|
||||
width="2"
|
||||
height="4"
|
||||
fill="currentColor"
|
||||
class="text-black"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,41 +3,40 @@
|
|||
// AuthForm.tsx
|
||||
// ===================
|
||||
|
||||
import { createSignal, Show } from "solid-js"
|
||||
import type { JSX } from "solid-js"
|
||||
import { A } from "@solidjs/router"
|
||||
import { Input } from "../UI/Input"
|
||||
import { PasskeyButton } from "./PasskeyButton"
|
||||
import { AuthCard } from "./AuthCard"
|
||||
import {
|
||||
validateUsername,
|
||||
validateDisplayName,
|
||||
} from "../../lib/validators"
|
||||
import { authService } from "../../services"
|
||||
import { showToast } from "../../stores"
|
||||
import { ApiError } from "../../types"
|
||||
import { A } from '@solidjs/router'
|
||||
import type { JSX } from 'solid-js'
|
||||
import { createSignal, Show } from 'solid-js'
|
||||
import { validateDisplayName, validateUsername } from '../../lib/validators'
|
||||
import { authService } from '../../services'
|
||||
import { showToast } from '../../stores'
|
||||
import { ApiError } from '../../types'
|
||||
import { Input } from '../UI/Input'
|
||||
import { AuthCard } from './AuthCard'
|
||||
import { PasskeyButton } from './PasskeyButton'
|
||||
|
||||
type AuthMode = "login" | "register"
|
||||
type AuthMode = 'login' | 'register'
|
||||
|
||||
interface AuthFormProps {
|
||||
mode: AuthMode
|
||||
}
|
||||
|
||||
export function AuthForm(props: AuthFormProps): JSX.Element {
|
||||
const [username, setUsername] = createSignal("")
|
||||
const [displayName, setDisplayName] = createSignal("")
|
||||
const [username, setUsername] = createSignal('')
|
||||
const [displayName, setDisplayName] = createSignal('')
|
||||
const [loading, setLoading] = createSignal(false)
|
||||
const [usernameError, setUsernameError] = createSignal<string | undefined>()
|
||||
const [displayNameError, setDisplayNameError] = createSignal<string | undefined>()
|
||||
const [displayNameError, setDisplayNameError] = createSignal<
|
||||
string | undefined
|
||||
>()
|
||||
|
||||
const isRegister = (): boolean => props.mode === "register"
|
||||
const isRegister = (): boolean => props.mode === 'register'
|
||||
|
||||
const title = (): string => isRegister() ? "CREATE ACCOUNT" : "WELCOME BACK"
|
||||
const title = (): string => (isRegister() ? 'CREATE ACCOUNT' : 'WELCOME BACK')
|
||||
|
||||
const subtitle = (): string =>
|
||||
isRegister()
|
||||
? "REGISTER WITH A PASSKEY FOR SECURE, PASSWORDLESS AUTHENTICATION"
|
||||
: "SIGN IN WITH YOUR PASSKEY TO CONTINUE"
|
||||
? 'REGISTER WITH A PASSKEY FOR SECURE, PASSWORDLESS AUTHENTICATION'
|
||||
: 'SIGN IN WITH YOUR PASSKEY TO CONTINUE'
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
let valid = true
|
||||
|
|
@ -73,29 +72,37 @@ export function AuthForm(props: AuthFormProps): JSX.Element {
|
|||
try {
|
||||
if (isRegister()) {
|
||||
await authService.register(username(), displayName())
|
||||
showToast("success", "REGISTRATION COMPLETE", "YOUR PASSKEY HAS BEEN CREATED")
|
||||
showToast(
|
||||
'success',
|
||||
'REGISTRATION COMPLETE',
|
||||
'YOUR PASSKEY HAS BEEN CREATED'
|
||||
)
|
||||
} else {
|
||||
const trimmedUsername = username().trim()
|
||||
const usernameValue = trimmedUsername === "" ? undefined : trimmedUsername
|
||||
const usernameValue = trimmedUsername === '' ? undefined : trimmedUsername
|
||||
await authService.login(usernameValue)
|
||||
showToast("success", "LOGIN SUCCESSFUL", "WELCOME BACK")
|
||||
showToast('success', 'LOGIN SUCCESSFUL', 'WELCOME BACK')
|
||||
}
|
||||
} catch (error) {
|
||||
let message = "AN UNEXPECTED ERROR OCCURRED"
|
||||
let message = 'AN UNEXPECTED ERROR OCCURRED'
|
||||
|
||||
if (error instanceof ApiError) {
|
||||
message = error.message.toUpperCase()
|
||||
} else if (error instanceof Error) {
|
||||
if (error.name === "NotAllowedError") {
|
||||
message = "PASSKEY OPERATION CANCELLED"
|
||||
} else if (error.name === "InvalidStateError") {
|
||||
message = "PASSKEY ALREADY REGISTERED"
|
||||
if (error.name === 'NotAllowedError') {
|
||||
message = 'PASSKEY OPERATION CANCELLED'
|
||||
} else if (error.name === 'InvalidStateError') {
|
||||
message = 'PASSKEY ALREADY REGISTERED'
|
||||
} else {
|
||||
message = error.message.toUpperCase()
|
||||
}
|
||||
}
|
||||
|
||||
showToast("error", isRegister() ? "REGISTRATION FAILED" : "LOGIN FAILED", message)
|
||||
showToast(
|
||||
'error',
|
||||
isRegister() ? 'REGISTRATION FAILED' : 'LOGIN FAILED',
|
||||
message
|
||||
)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
|
|
@ -158,14 +165,14 @@ export function AuthForm(props: AuthFormProps): JSX.Element {
|
|||
when={isRegister()}
|
||||
fallback={
|
||||
<>
|
||||
DON'T HAVE AN ACCOUNT?{" "}
|
||||
DON'T HAVE AN ACCOUNT?{' '}
|
||||
<A href="/register" class="text-orange hover:underline">
|
||||
REGISTER
|
||||
</A>
|
||||
</>
|
||||
}
|
||||
>
|
||||
ALREADY HAVE AN ACCOUNT?{" "}
|
||||
ALREADY HAVE AN ACCOUNT?{' '}
|
||||
<A href="/login" class="text-orange hover:underline">
|
||||
SIGN IN
|
||||
</A>
|
||||
|
|
|
|||
|
|
@ -3,20 +3,16 @@
|
|||
// PasskeyButton.tsx
|
||||
// ===================
|
||||
|
||||
import type { JSX } from 'solid-js'
|
||||
import { createSignal, onMount, Show } from 'solid-js'
|
||||
import {
|
||||
Show,
|
||||
createSignal,
|
||||
onMount,
|
||||
} from "solid-js"
|
||||
import type { JSX } from "solid-js"
|
||||
import { Button } from "../UI/Button"
|
||||
import {
|
||||
isWebAuthnSupported,
|
||||
isPlatformAuthenticatorAvailable,
|
||||
} from "../../services"
|
||||
isWebAuthnSupported,
|
||||
} from '../../services'
|
||||
import { Button } from '../UI/Button'
|
||||
|
||||
interface PasskeyButtonProps {
|
||||
mode: "register" | "login"
|
||||
mode: 'register' | 'login'
|
||||
onClick: () => void | Promise<void>
|
||||
loading?: boolean
|
||||
disabled?: boolean
|
||||
|
|
@ -40,9 +36,11 @@ export function PasskeyButton(props: PasskeyButtonProps): JSX.Element {
|
|||
|
||||
const buttonText = (): string => {
|
||||
if (!webAuthnSupported()) {
|
||||
return "WEBAUTHN NOT SUPPORTED"
|
||||
return 'WEBAUTHN NOT SUPPORTED'
|
||||
}
|
||||
return props.mode === "register" ? "REGISTER WITH PASSKEY" : "LOGIN WITH PASSKEY"
|
||||
return props.mode === 'register'
|
||||
? 'REGISTER WITH PASSKEY'
|
||||
: 'LOGIN WITH PASSKEY'
|
||||
}
|
||||
|
||||
const isDisabled = (): boolean => {
|
||||
|
|
@ -50,7 +48,7 @@ export function PasskeyButton(props: PasskeyButtonProps): JSX.Element {
|
|||
}
|
||||
|
||||
return (
|
||||
<div class={`flex flex-col gap-2 ${props.class ?? ""}`}>
|
||||
<div class={`flex flex-col gap-2 ${props.class ?? ''}`}>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
|
|
@ -80,7 +78,13 @@ export function PasskeyButton(props: PasskeyButtonProps): JSX.Element {
|
|||
|
||||
function PasskeyIcon(): JSX.Element {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="2" y="6" width="2" height="6" />
|
||||
<rect x="4" y="4" width="2" height="2" />
|
||||
<rect x="6" y="2" width="4" height="2" />
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
// © AngelaMos | 2025
|
||||
// index.ts
|
||||
// ===================
|
||||
export { PasskeyButton } from "./PasskeyButton"
|
||||
export { AuthCard } from "./AuthCard"
|
||||
export { AuthForm } from "./AuthForm"
|
||||
|
||||
export { AuthCard } from './AuthCard'
|
||||
export { AuthForm } from './AuthForm'
|
||||
export { PasskeyButton } from './PasskeyButton'
|
||||
|
|
|
|||
|
|
@ -2,13 +2,14 @@
|
|||
// © AngelaMos | 2025
|
||||
// ChatHeader.tsx
|
||||
// ===================
|
||||
import { Show } from "solid-js"
|
||||
import type { JSX } from "solid-js"
|
||||
import type { Room, Participant } from "../../types"
|
||||
import { OnlineStatus } from "./OnlineStatus"
|
||||
import { EncryptionBadge } from "./EncryptionBadge"
|
||||
import { getUserStatus } from "../../stores"
|
||||
import { IconButton } from "../UI/IconButton"
|
||||
|
||||
import type { JSX } from 'solid-js'
|
||||
import { Show } from 'solid-js'
|
||||
import { getUserStatus } from '../../stores'
|
||||
import type { Participant, Room } from '../../types'
|
||||
import { IconButton } from '../UI/IconButton'
|
||||
import { EncryptionBadge } from './EncryptionBadge'
|
||||
import { OnlineStatus } from './OnlineStatus'
|
||||
|
||||
interface ChatHeaderProps {
|
||||
room: Room | null
|
||||
|
|
@ -19,16 +20,16 @@ interface ChatHeaderProps {
|
|||
|
||||
export function ChatHeader(props: ChatHeaderProps): JSX.Element {
|
||||
const otherParticipant = (): Participant | null => {
|
||||
if (props.room?.type !== "direct") return null
|
||||
if (props.room?.type !== 'direct') return null
|
||||
return props.room.participants[0] ?? null
|
||||
}
|
||||
|
||||
const displayName = (): string => {
|
||||
const room = props.room
|
||||
if (room === null) return "CHAT"
|
||||
if (room === null) return 'CHAT'
|
||||
if (room.name !== undefined && room.name !== null) return room.name
|
||||
const other = otherParticipant()
|
||||
return other?.display_name ?? other?.username ?? "CHAT"
|
||||
return other?.display_name ?? other?.username ?? 'CHAT'
|
||||
}
|
||||
|
||||
const initials = (): string => {
|
||||
|
|
@ -37,14 +38,14 @@ export function ChatHeader(props: ChatHeaderProps): JSX.Element {
|
|||
}
|
||||
|
||||
return (
|
||||
<div class={`flex-shrink-0 px-4 py-3 border-b-2 border-orange ${props.class ?? ""}`}>
|
||||
<div
|
||||
class={`flex-shrink-0 px-4 py-3 border-b-2 border-orange ${props.class ?? ''}`}
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative">
|
||||
<div class="w-10 h-10 bg-black border-2 border-orange flex items-center justify-center">
|
||||
<span class="font-pixel text-[10px] text-orange">
|
||||
{initials()}
|
||||
</span>
|
||||
<span class="font-pixel text-[10px] text-orange">{initials()}</span>
|
||||
</div>
|
||||
<Show when={otherParticipant()} keyed>
|
||||
{(participant) => (
|
||||
|
|
@ -59,14 +60,12 @@ export function ChatHeader(props: ChatHeaderProps): JSX.Element {
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<h2 class="font-pixel text-xs text-white">
|
||||
{displayName()}
|
||||
</h2>
|
||||
<h2 class="font-pixel text-xs text-white">{displayName()}</h2>
|
||||
<div class="flex items-center gap-2 mt-0.5">
|
||||
<Show when={props.room?.is_encrypted}>
|
||||
<EncryptionBadge isEncrypted showLabel size="sm" />
|
||||
</Show>
|
||||
<Show when={props.room?.type === "group"}>
|
||||
<Show when={props.room?.type === 'group'}>
|
||||
<span class="font-pixel text-[8px] text-gray">
|
||||
{props.room?.participants.length ?? 0} MEMBERS
|
||||
</span>
|
||||
|
|
@ -107,7 +106,13 @@ export function ChatHeader(props: ChatHeaderProps): JSX.Element {
|
|||
|
||||
function InfoIcon(): JSX.Element {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="7" y="3" width="2" height="2" />
|
||||
<rect x="7" y="7" width="2" height="6" />
|
||||
</svg>
|
||||
|
|
@ -116,7 +121,13 @@ function InfoIcon(): JSX.Element {
|
|||
|
||||
function SettingsIcon(): JSX.Element {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="7" y="1" width="2" height="3" />
|
||||
<rect x="7" y="12" width="2" height="3" />
|
||||
<rect x="1" y="7" width="3" height="2" />
|
||||
|
|
|
|||
|
|
@ -2,11 +2,12 @@
|
|||
// © AngelaMos | 2025
|
||||
// ChatInput.tsx
|
||||
// ===================
|
||||
import { createSignal, Show, onCleanup } from "solid-js"
|
||||
import type { JSX } from "solid-js"
|
||||
import { Button } from "../UI/Button"
|
||||
import { wsManager } from "../../websocket"
|
||||
import { MESSAGE_MAX_LENGTH } from "../../types"
|
||||
|
||||
import type { JSX } from 'solid-js'
|
||||
import { createSignal, onCleanup, Show } from 'solid-js'
|
||||
import { MESSAGE_MAX_LENGTH } from '../../types'
|
||||
import { wsManager } from '../../websocket'
|
||||
import { Button } from '../UI/Button'
|
||||
|
||||
interface ChatInputProps {
|
||||
roomId: string
|
||||
|
|
@ -17,14 +18,15 @@ interface ChatInputProps {
|
|||
}
|
||||
|
||||
export function ChatInput(props: ChatInputProps): JSX.Element {
|
||||
const [message, setMessage] = createSignal("")
|
||||
const [message, setMessage] = createSignal('')
|
||||
const [isTyping, setIsTyping] = createSignal(false)
|
||||
let typingTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
let inputRef: HTMLInputElement | undefined
|
||||
|
||||
const charCount = (): number => message().length
|
||||
const isOverLimit = (): boolean => charCount() > MESSAGE_MAX_LENGTH
|
||||
const canSend = (): boolean => message().trim().length > 0 && !isOverLimit() && (props.disabled !== true)
|
||||
const canSend = (): boolean =>
|
||||
message().trim().length > 0 && !isOverLimit() && props.disabled !== true
|
||||
|
||||
const handleInput = (e: Event): void => {
|
||||
const target = e.target as HTMLInputElement
|
||||
|
|
@ -50,7 +52,7 @@ export function ChatInput(props: ChatInputProps): JSX.Element {
|
|||
|
||||
const content = message().trim()
|
||||
props.onSend?.(content)
|
||||
setMessage("")
|
||||
setMessage('')
|
||||
|
||||
if (isTyping()) {
|
||||
setIsTyping(false)
|
||||
|
|
@ -61,7 +63,7 @@ export function ChatInput(props: ChatInputProps): JSX.Element {
|
|||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
|
|
@ -77,10 +79,14 @@ export function ChatInput(props: ChatInputProps): JSX.Element {
|
|||
})
|
||||
|
||||
return (
|
||||
<div class={`flex-shrink-0 p-4 border-t-2 border-orange ${props.class ?? ""}`}>
|
||||
<div
|
||||
class={`flex-shrink-0 p-4 border-t-2 border-orange ${props.class ?? ''}`}
|
||||
>
|
||||
<div class="flex gap-2">
|
||||
<div class="flex-1 flex items-center bg-black border-2 border-orange">
|
||||
<span class="font-pixel text-[10px] text-orange px-2">>>></span>
|
||||
<span class="font-pixel text-[10px] text-orange px-2">
|
||||
>>>
|
||||
</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
|
|
@ -106,11 +112,11 @@ export function ChatInput(props: ChatInputProps): JSX.Element {
|
|||
|
||||
<div class="flex items-center justify-between mt-2">
|
||||
<Show when={isOverLimit()}>
|
||||
<span class="font-pixel text-[8px] text-error">
|
||||
MESSAGE TOO LONG
|
||||
</span>
|
||||
<span class="font-pixel text-[8px] text-error">MESSAGE TOO LONG</span>
|
||||
</Show>
|
||||
<span class={`font-pixel text-[8px] ml-auto ${isOverLimit() ? "text-error" : "text-gray"}`}>
|
||||
<span
|
||||
class={`font-pixel text-[8px] ml-auto ${isOverLimit() ? 'text-error' : 'text-gray'}`}
|
||||
>
|
||||
{charCount()}/{MESSAGE_MAX_LENGTH}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -120,7 +126,13 @@ export function ChatInput(props: ChatInputProps): JSX.Element {
|
|||
|
||||
function SendIcon(): JSX.Element {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="0" y="5" width="8" height="2" />
|
||||
<rect x="6" y="3" width="2" height="2" />
|
||||
<rect x="6" y="7" width="2" height="2" />
|
||||
|
|
|
|||
|
|
@ -2,13 +2,14 @@
|
|||
// © AngelaMos | 2025
|
||||
// ConversationItem.tsx
|
||||
// ===================
|
||||
import { Show } from "solid-js"
|
||||
import type { JSX } from "solid-js"
|
||||
import type { Room, Participant } from "../../types"
|
||||
import { OnlineStatus } from "./OnlineStatus"
|
||||
import { EncryptionBadge } from "./EncryptionBadge"
|
||||
import { getUserStatus } from "../../stores"
|
||||
import { formatRelativeTime } from "../../lib/date"
|
||||
|
||||
import type { JSX } from 'solid-js'
|
||||
import { Show } from 'solid-js'
|
||||
import { formatRelativeTime } from '../../lib/date'
|
||||
import { getUserStatus } from '../../stores'
|
||||
import type { Participant, Room } from '../../types'
|
||||
import { EncryptionBadge } from './EncryptionBadge'
|
||||
import { OnlineStatus } from './OnlineStatus'
|
||||
|
||||
interface ConversationItemProps {
|
||||
room: Room
|
||||
|
|
@ -19,14 +20,14 @@ interface ConversationItemProps {
|
|||
|
||||
export function ConversationItem(props: ConversationItemProps): JSX.Element {
|
||||
const otherParticipant = (): Participant | null => {
|
||||
if (props.room.type !== "direct") return null
|
||||
if (props.room.type !== 'direct') return null
|
||||
return props.room.participants[0] ?? null
|
||||
}
|
||||
|
||||
const displayName = (): string => {
|
||||
if (props.room.name) return props.room.name
|
||||
const other = otherParticipant()
|
||||
return other?.display_name ?? other?.username ?? "CHAT"
|
||||
return other?.display_name ?? other?.username ?? 'CHAT'
|
||||
}
|
||||
|
||||
const initials = (): string => {
|
||||
|
|
@ -36,20 +37,20 @@ export function ConversationItem(props: ConversationItemProps): JSX.Element {
|
|||
|
||||
const lastMessagePreview = (): string => {
|
||||
const msg = props.room.last_message
|
||||
if (msg === null || msg === undefined) return "NO MESSAGES"
|
||||
if (msg.is_encrypted) return "ENCRYPTED MESSAGE"
|
||||
if (msg === null || msg === undefined) return 'NO MESSAGES'
|
||||
if (msg.is_encrypted) return 'ENCRYPTED MESSAGE'
|
||||
const content = msg.content.slice(0, 40)
|
||||
return content.length < msg.content.length ? `${content}...` : content
|
||||
}
|
||||
|
||||
const lastMessageTime = (): string => {
|
||||
const msg = props.room.last_message
|
||||
if (msg === null || msg === undefined) return ""
|
||||
if (msg === null || msg === undefined) return ''
|
||||
return formatRelativeTime(msg.created_at)
|
||||
}
|
||||
|
||||
const containerClasses = (): string => {
|
||||
const base = "w-full p-3 border-2 transition-colors cursor-pointer"
|
||||
const base = 'w-full p-3 border-2 transition-colors cursor-pointer'
|
||||
if (props.isActive) {
|
||||
return `${base} bg-orange text-black border-orange`
|
||||
}
|
||||
|
|
@ -59,13 +60,17 @@ export function ConversationItem(props: ConversationItemProps): JSX.Element {
|
|||
return (
|
||||
<button
|
||||
type="button"
|
||||
class={`${containerClasses()} ${props.class ?? ""}`}
|
||||
class={`${containerClasses()} ${props.class ?? ''}`}
|
||||
onClick={() => props.onClick()}
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="relative flex-shrink-0">
|
||||
<div class={`w-10 h-10 border-2 flex items-center justify-center ${props.isActive ? "border-black" : "border-orange"}`}>
|
||||
<span class={`font-pixel text-[10px] ${props.isActive ? "text-black" : "text-orange"}`}>
|
||||
<div
|
||||
class={`w-10 h-10 border-2 flex items-center justify-center ${props.isActive ? 'border-black' : 'border-orange'}`}
|
||||
>
|
||||
<span
|
||||
class={`font-pixel text-[10px] ${props.isActive ? 'text-black' : 'text-orange'}`}
|
||||
>
|
||||
{initials()}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -83,16 +88,22 @@ export function ConversationItem(props: ConversationItemProps): JSX.Element {
|
|||
|
||||
<div class="flex-1 min-w-0 text-left">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class={`font-pixel text-[10px] truncate ${props.isActive ? "text-black" : "text-white"}`}>
|
||||
<span
|
||||
class={`font-pixel text-[10px] truncate ${props.isActive ? 'text-black' : 'text-white'}`}
|
||||
>
|
||||
{displayName()}
|
||||
</span>
|
||||
<span class={`font-pixel text-[8px] flex-shrink-0 ${props.isActive ? "text-black/60" : "text-gray"}`}>
|
||||
<span
|
||||
class={`font-pixel text-[8px] flex-shrink-0 ${props.isActive ? 'text-black/60' : 'text-gray'}`}
|
||||
>
|
||||
{lastMessageTime()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-2 mt-1">
|
||||
<span class={`font-pixel text-[8px] truncate ${props.isActive ? "text-black/80" : "text-gray"}`}>
|
||||
<span
|
||||
class={`font-pixel text-[8px] truncate ${props.isActive ? 'text-black/80' : 'text-gray'}`}
|
||||
>
|
||||
{lastMessagePreview()}
|
||||
</span>
|
||||
<div class="flex items-center gap-1 flex-shrink-0">
|
||||
|
|
@ -102,7 +113,9 @@ export function ConversationItem(props: ConversationItemProps): JSX.Element {
|
|||
<Show when={props.room.unread_count > 0}>
|
||||
<div class="min-w-[18px] h-[18px] bg-orange flex items-center justify-center">
|
||||
<span class="font-pixel text-[8px] text-black">
|
||||
{props.room.unread_count > 99 ? "99+" : props.room.unread_count}
|
||||
{props.room.unread_count > 99
|
||||
? '99+'
|
||||
: props.room.unread_count}
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
|
|
|
|||
|
|
@ -2,13 +2,14 @@
|
|||
// © AngelaMos | 2025
|
||||
// ConversationList.tsx
|
||||
// ===================
|
||||
import { Show, For, createMemo } from "solid-js"
|
||||
import type { JSX } from "solid-js"
|
||||
import { useStore } from "@nanostores/solid"
|
||||
import { $rooms, $activeRoomId, setActiveRoom } from "../../stores"
|
||||
import { ConversationItem } from "./ConversationItem"
|
||||
import { Spinner } from "../UI/Spinner"
|
||||
import type { Room } from "../../types"
|
||||
|
||||
import { useStore } from '@nanostores/solid'
|
||||
import type { JSX } from 'solid-js'
|
||||
import { createMemo, For, Show } from 'solid-js'
|
||||
import { $activeRoomId, $rooms, setActiveRoom } from '../../stores'
|
||||
import type { Room } from '../../types'
|
||||
import { Spinner } from '../UI/Spinner'
|
||||
import { ConversationItem } from './ConversationItem'
|
||||
|
||||
interface ConversationListProps {
|
||||
loading?: boolean
|
||||
|
|
@ -35,11 +36,9 @@ export function ConversationList(props: ConversationListProps): JSX.Element {
|
|||
}
|
||||
|
||||
return (
|
||||
<div class={`flex flex-col h-full ${props.class ?? ""}`}>
|
||||
<div class={`flex flex-col h-full ${props.class ?? ''}`}>
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b-2 border-dark-gray">
|
||||
<h2 class="font-pixel text-[10px] text-orange">
|
||||
CONVERSATIONS
|
||||
</h2>
|
||||
<h2 class="font-pixel text-[10px] text-orange">CONVERSATIONS</h2>
|
||||
<Show when={props.onNewChat}>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -112,7 +111,13 @@ function EmptyConversations(props: EmptyConversationsProps): JSX.Element {
|
|||
|
||||
function PlusIcon(): JSX.Element {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="5" y="1" width="2" height="10" />
|
||||
<rect x="1" y="5" width="10" height="2" />
|
||||
</svg>
|
||||
|
|
@ -121,7 +126,14 @@ function PlusIcon(): JSX.Element {
|
|||
|
||||
function ChatIcon(): JSX.Element {
|
||||
return (
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="currentColor" class="text-dark-gray">
|
||||
<svg
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 40 40"
|
||||
fill="currentColor"
|
||||
class="text-dark-gray"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="6" y="6" width="28" height="3" />
|
||||
<rect x="3" y="9" width="3" height="20" />
|
||||
<rect x="34" y="9" width="3" height="20" />
|
||||
|
|
|
|||
|
|
@ -2,31 +2,31 @@
|
|||
// © AngelaMos | 2025
|
||||
// EncryptionBadge.tsx
|
||||
// ===================
|
||||
import { Show } from "solid-js"
|
||||
import type { JSX } from "solid-js"
|
||||
|
||||
import type { JSX } from 'solid-js'
|
||||
import { Show } from 'solid-js'
|
||||
|
||||
interface EncryptionBadgeProps {
|
||||
isEncrypted: boolean
|
||||
showLabel?: boolean
|
||||
size?: "sm" | "md"
|
||||
size?: 'sm' | 'md'
|
||||
class?: string
|
||||
}
|
||||
|
||||
export function EncryptionBadge(props: EncryptionBadgeProps): JSX.Element {
|
||||
const iconSize = (): string => props.size === "sm" ? "w-3 h-3" : "w-4 h-4"
|
||||
const textSize = (): string => props.size === "sm" ? "text-[6px]" : "text-[8px]"
|
||||
const iconSize = (): string => (props.size === 'sm' ? 'w-3 h-3' : 'w-4 h-4')
|
||||
const textSize = (): string =>
|
||||
props.size === 'sm' ? 'text-[6px]' : 'text-[8px]'
|
||||
|
||||
return (
|
||||
<Show when={props.isEncrypted}>
|
||||
<div
|
||||
class={`flex items-center gap-1 ${props.class ?? ""}`}
|
||||
class={`flex items-center gap-1 ${props.class ?? ''}`}
|
||||
title="END-TO-END ENCRYPTED"
|
||||
>
|
||||
<LockIcon class={`${iconSize()} text-success`} />
|
||||
<Show when={props.showLabel}>
|
||||
<span class={`font-pixel ${textSize()} text-success`}>
|
||||
E2E
|
||||
</span>
|
||||
<span class={`font-pixel ${textSize()} text-success`}>E2E</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
|
@ -50,31 +50,37 @@ function LockIcon(props: LockIconProps): JSX.Element {
|
|||
<rect x="11" y="4" width="1" height="4" />
|
||||
<rect x="3" y="8" width="10" height="1" />
|
||||
<rect x="3" y="9" width="10" height="5" />
|
||||
<rect x="7" y="11" width="2" height="2" fill="currentColor" class="text-black" />
|
||||
<rect
|
||||
x="7"
|
||||
y="11"
|
||||
width="2"
|
||||
height="2"
|
||||
fill="currentColor"
|
||||
class="text-black"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
interface UnencryptedBadgeProps {
|
||||
showLabel?: boolean
|
||||
size?: "sm" | "md"
|
||||
size?: 'sm' | 'md'
|
||||
class?: string
|
||||
}
|
||||
|
||||
export function UnencryptedBadge(props: UnencryptedBadgeProps): JSX.Element {
|
||||
const iconSize = (): string => props.size === "sm" ? "w-3 h-3" : "w-4 h-4"
|
||||
const textSize = (): string => props.size === "sm" ? "text-[6px]" : "text-[8px]"
|
||||
const iconSize = (): string => (props.size === 'sm' ? 'w-3 h-3' : 'w-4 h-4')
|
||||
const textSize = (): string =>
|
||||
props.size === 'sm' ? 'text-[6px]' : 'text-[8px]'
|
||||
|
||||
return (
|
||||
<div
|
||||
class={`flex items-center gap-1 ${props.class ?? ""}`}
|
||||
class={`flex items-center gap-1 ${props.class ?? ''}`}
|
||||
title="NOT ENCRYPTED"
|
||||
>
|
||||
<UnlockIcon class={`${iconSize()} text-gray`} />
|
||||
<Show when={props.showLabel}>
|
||||
<span class={`font-pixel ${textSize()} text-gray`}>
|
||||
UNENCRYPTED
|
||||
</span>
|
||||
<span class={`font-pixel ${textSize()} text-gray`}>UNENCRYPTED</span>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -93,7 +99,14 @@ function UnlockIcon(props: LockIconProps): JSX.Element {
|
|||
<rect x="11" y="2" width="1" height="2" />
|
||||
<rect x="3" y="8" width="10" height="1" />
|
||||
<rect x="3" y="9" width="10" height="5" />
|
||||
<rect x="7" y="11" width="2" height="2" fill="currentColor" class="text-black" />
|
||||
<rect
|
||||
x="7"
|
||||
y="11"
|
||||
width="2"
|
||||
height="2"
|
||||
fill="currentColor"
|
||||
class="text-black"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,11 +2,12 @@
|
|||
// © AngelaMos | 2025
|
||||
// MessageBubble.tsx
|
||||
// ===================
|
||||
import { Show, Switch, Match } from "solid-js"
|
||||
import type { JSX } from "solid-js"
|
||||
import type { Message, MessageStatus } from "../../types"
|
||||
import { EncryptionBadge } from "./EncryptionBadge"
|
||||
import { formatTime } from "../../lib/date"
|
||||
|
||||
import type { JSX } from 'solid-js'
|
||||
import { Match, Show, Switch } from 'solid-js'
|
||||
import { formatTime } from '../../lib/date'
|
||||
import type { Message, MessageStatus } from '../../types'
|
||||
import { EncryptionBadge } from './EncryptionBadge'
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: Message
|
||||
|
|
@ -17,7 +18,7 @@ interface MessageBubbleProps {
|
|||
|
||||
export function MessageBubble(props: MessageBubbleProps): JSX.Element {
|
||||
const bubbleClasses = (): string => {
|
||||
const base = "max-w-[70%] p-4"
|
||||
const base = 'max-w-[70%] p-4'
|
||||
if (props.isOwnMessage) {
|
||||
return `${base} bg-orange text-black`
|
||||
}
|
||||
|
|
@ -25,7 +26,9 @@ export function MessageBubble(props: MessageBubbleProps): JSX.Element {
|
|||
}
|
||||
|
||||
return (
|
||||
<div class={`flex px-4 ${props.isOwnMessage ? "justify-end" : "justify-start"} ${props.class ?? ""}`}>
|
||||
<div
|
||||
class={`flex px-4 ${props.isOwnMessage ? 'justify-end' : 'justify-start'} ${props.class ?? ''}`}
|
||||
>
|
||||
<div class={bubbleClasses()}>
|
||||
<Show when={props.showSender === true && !props.isOwnMessage}>
|
||||
<div class="font-pixel text-[10px] text-orange mb-2">
|
||||
|
|
@ -33,7 +36,10 @@ export function MessageBubble(props: MessageBubbleProps): JSX.Element {
|
|||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="font-placeholder break-words whitespace-pre-wrap" style="font-size: 32px; line-height: 1.4;">
|
||||
<div
|
||||
class="font-placeholder break-words whitespace-pre-wrap"
|
||||
style="font-size: 32px; line-height: 1.4;"
|
||||
>
|
||||
{props.message.content}
|
||||
</div>
|
||||
|
||||
|
|
@ -41,7 +47,9 @@ export function MessageBubble(props: MessageBubbleProps): JSX.Element {
|
|||
<Show when={props.message.is_encrypted}>
|
||||
<EncryptionBadge isEncrypted size="sm" />
|
||||
</Show>
|
||||
<span class={`font-pixel text-[8px] ${props.isOwnMessage ? "text-black/60" : "text-gray"}`}>
|
||||
<span
|
||||
class={`font-pixel text-[8px] ${props.isOwnMessage ? 'text-black/60' : 'text-gray'}`}
|
||||
>
|
||||
{formatTime(props.message.created_at)}
|
||||
</span>
|
||||
<Show when={props.isOwnMessage}>
|
||||
|
|
@ -59,20 +67,20 @@ interface MessageStatusIconProps {
|
|||
|
||||
function MessageStatusIcon(props: MessageStatusIconProps): JSX.Element {
|
||||
return (
|
||||
<Switch fallback={<></>}>
|
||||
<Match when={props.status === "sending"}>
|
||||
<Switch fallback={null}>
|
||||
<Match when={props.status === 'sending'}>
|
||||
<ClockIcon class="w-3 h-3 text-black/40" />
|
||||
</Match>
|
||||
<Match when={props.status === "sent"}>
|
||||
<Match when={props.status === 'sent'}>
|
||||
<CheckIcon class="w-3 h-3 text-black/60" />
|
||||
</Match>
|
||||
<Match when={props.status === "delivered"}>
|
||||
<Match when={props.status === 'delivered'}>
|
||||
<DoubleCheckIcon class="w-3 h-3 text-black/60" />
|
||||
</Match>
|
||||
<Match when={props.status === "read"}>
|
||||
<Match when={props.status === 'read'}>
|
||||
<DoubleCheckIcon class="w-3 h-3 text-success" />
|
||||
</Match>
|
||||
<Match when={props.status === "failed"}>
|
||||
<Match when={props.status === 'failed'}>
|
||||
<ErrorIcon class="w-3 h-3 text-error" />
|
||||
</Match>
|
||||
</Switch>
|
||||
|
|
@ -85,7 +93,12 @@ interface IconProps {
|
|||
|
||||
function ClockIcon(props: IconProps): JSX.Element {
|
||||
return (
|
||||
<svg viewBox="0 0 12 12" fill="currentColor" class={props.class}>
|
||||
<svg
|
||||
viewBox="0 0 12 12"
|
||||
fill="currentColor"
|
||||
class={props.class}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="5" y="1" width="2" height="1" />
|
||||
<rect x="3" y="2" width="2" height="1" />
|
||||
<rect x="7" y="2" width="2" height="1" />
|
||||
|
|
@ -106,7 +119,12 @@ function ClockIcon(props: IconProps): JSX.Element {
|
|||
|
||||
function CheckIcon(props: IconProps): JSX.Element {
|
||||
return (
|
||||
<svg viewBox="0 0 12 12" fill="currentColor" class={props.class}>
|
||||
<svg
|
||||
viewBox="0 0 12 12"
|
||||
fill="currentColor"
|
||||
class={props.class}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="2" y="6" width="2" height="2" />
|
||||
<rect x="4" y="8" width="2" height="2" />
|
||||
<rect x="6" y="6" width="2" height="2" />
|
||||
|
|
@ -118,7 +136,12 @@ function CheckIcon(props: IconProps): JSX.Element {
|
|||
|
||||
function DoubleCheckIcon(props: IconProps): JSX.Element {
|
||||
return (
|
||||
<svg viewBox="0 0 16 12" fill="currentColor" class={props.class}>
|
||||
<svg
|
||||
viewBox="0 0 16 12"
|
||||
fill="currentColor"
|
||||
class={props.class}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="0" y="6" width="2" height="2" />
|
||||
<rect x="2" y="8" width="2" height="2" />
|
||||
<rect x="4" y="6" width="2" height="2" />
|
||||
|
|
@ -135,7 +158,12 @@ function DoubleCheckIcon(props: IconProps): JSX.Element {
|
|||
|
||||
function ErrorIcon(props: IconProps): JSX.Element {
|
||||
return (
|
||||
<svg viewBox="0 0 12 12" fill="currentColor" class={props.class}>
|
||||
<svg
|
||||
viewBox="0 0 12 12"
|
||||
fill="currentColor"
|
||||
class={props.class}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="5" y="1" width="2" height="1" />
|
||||
<rect x="3" y="2" width="2" height="1" />
|
||||
<rect x="7" y="2" width="2" height="1" />
|
||||
|
|
|
|||
|
|
@ -2,15 +2,19 @@
|
|||
// © AngelaMos | 2025
|
||||
// MessageList.tsx
|
||||
// ===================
|
||||
import { Show, For, createEffect, createSignal, onMount } from "solid-js"
|
||||
import type { JSX } from "solid-js"
|
||||
import { useStore } from "@nanostores/solid"
|
||||
import { $activeRoomMessages, $activeRoomPendingMessages } from "../../stores"
|
||||
import { $userId } from "../../stores"
|
||||
import { MessageBubble } from "./MessageBubble"
|
||||
import { TypingIndicator } from "./TypingIndicator"
|
||||
import { Spinner } from "../UI/Spinner"
|
||||
import type { Message } from "../../types"
|
||||
|
||||
import { useStore } from '@nanostores/solid'
|
||||
import type { JSX } from 'solid-js'
|
||||
import { createEffect, createSignal, For, onMount, Show } from 'solid-js'
|
||||
import {
|
||||
$activeRoomMessages,
|
||||
$activeRoomPendingMessages,
|
||||
$userId,
|
||||
} from '../../stores'
|
||||
import type { Message } from '../../types'
|
||||
import { Spinner } from '../UI/Spinner'
|
||||
import { MessageBubble } from './MessageBubble'
|
||||
import { TypingIndicator } from './TypingIndicator'
|
||||
|
||||
interface MessageListProps {
|
||||
roomId: string
|
||||
|
|
@ -39,7 +43,12 @@ export function MessageList(props: MessageListProps): JSX.Element {
|
|||
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50
|
||||
setAutoScroll(isAtBottom)
|
||||
|
||||
if (scrollTop < 100 && props.hasMore === true && props.loading !== true && props.onLoadMore !== undefined) {
|
||||
if (
|
||||
scrollTop < 100 &&
|
||||
props.hasMore === true &&
|
||||
props.loading !== true &&
|
||||
props.onLoadMore !== undefined
|
||||
) {
|
||||
props.onLoadMore()
|
||||
}
|
||||
}
|
||||
|
|
@ -61,10 +70,10 @@ export function MessageList(props: MessageListProps): JSX.Element {
|
|||
const groups = new Map<string, Message[]>()
|
||||
|
||||
for (const msg of msgs) {
|
||||
const date = new Date(msg.created_at).toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
const date = new Date(msg.created_at).toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
|
||||
const existing = groups.get(date) ?? []
|
||||
|
|
@ -77,7 +86,7 @@ export function MessageList(props: MessageListProps): JSX.Element {
|
|||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
class={`flex-1 overflow-y-auto scrollbar-pixel p-4 ${props.class ?? ""}`}
|
||||
class={`flex-1 overflow-y-auto scrollbar-pixel p-4 ${props.class ?? ''}`}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<Show when={props.loading}>
|
||||
|
|
@ -86,10 +95,7 @@ export function MessageList(props: MessageListProps): JSX.Element {
|
|||
</div>
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={allMessages().length > 0}
|
||||
fallback={<EmptyMessages />}
|
||||
>
|
||||
<Show when={allMessages().length > 0} fallback={<EmptyMessages />}>
|
||||
<For each={Array.from(groupMessagesByDate(allMessages()).entries())}>
|
||||
{([date, dateMessages]) => (
|
||||
<div class="mb-6">
|
||||
|
|
@ -101,7 +107,9 @@ export function MessageList(props: MessageListProps): JSX.Element {
|
|||
index() > 0 ? dateMessages[index() - 1] : undefined
|
||||
const showSender = (): boolean => {
|
||||
const prev = prevMessage()
|
||||
return prev === undefined || prev.sender_id !== message.sender_id
|
||||
return (
|
||||
prev === undefined || prev.sender_id !== message.sender_id
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -130,12 +138,8 @@ function EmptyMessages(): JSX.Element {
|
|||
return (
|
||||
<div class="h-full flex flex-col items-center justify-center py-12">
|
||||
<MessageIcon />
|
||||
<p class="font-pixel text-[10px] text-gray mt-4">
|
||||
NO MESSAGES YET
|
||||
</p>
|
||||
<p class="font-pixel text-[8px] text-gray mt-1">
|
||||
START THE CONVERSATION
|
||||
</p>
|
||||
<p class="font-pixel text-[10px] text-gray mt-4">NO MESSAGES YET</p>
|
||||
<p class="font-pixel text-[8px] text-gray mt-1">START THE CONVERSATION</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -148,9 +152,7 @@ function DateSeparator(props: DateSeparatorProps): JSX.Element {
|
|||
return (
|
||||
<div class="flex items-center gap-4 my-4">
|
||||
<div class="flex-1 h-px bg-dark-gray" />
|
||||
<span class="font-pixel text-[8px] text-gray uppercase">
|
||||
{props.date}
|
||||
</span>
|
||||
<span class="font-pixel text-[8px] text-gray uppercase">{props.date}</span>
|
||||
<div class="flex-1 h-px bg-dark-gray" />
|
||||
</div>
|
||||
)
|
||||
|
|
@ -158,7 +160,14 @@ function DateSeparator(props: DateSeparatorProps): JSX.Element {
|
|||
|
||||
function MessageIcon(): JSX.Element {
|
||||
return (
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="currentColor" class="text-dark-gray">
|
||||
<svg
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 48 48"
|
||||
fill="currentColor"
|
||||
class="text-dark-gray"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="8" y="8" width="32" height="4" />
|
||||
<rect x="4" y="12" width="4" height="24" />
|
||||
<rect x="40" y="12" width="4" height="24" />
|
||||
|
|
|
|||
|
|
@ -2,13 +2,14 @@
|
|||
// © AngelaMos | 2025
|
||||
// NewConversation.tsx
|
||||
// ===================
|
||||
import { createSignal, Show } from "solid-js"
|
||||
import type { JSX } from "solid-js"
|
||||
import { Modal } from "../UI/Modal"
|
||||
import { Button } from "../UI/Button"
|
||||
import { UserSearch } from "./UserSearch"
|
||||
import type { User } from "../../types"
|
||||
import { showToast } from "../../stores"
|
||||
|
||||
import type { JSX } from 'solid-js'
|
||||
import { createSignal, Show } from 'solid-js'
|
||||
import { showToast } from '../../stores'
|
||||
import type { User } from '../../types'
|
||||
import { Button } from '../UI/Button'
|
||||
import { Modal } from '../UI/Modal'
|
||||
import { UserSearch } from './UserSearch'
|
||||
|
||||
interface NewConversationProps {
|
||||
isOpen: boolean
|
||||
|
|
@ -32,10 +33,14 @@ export function NewConversation(props: NewConversationProps): JSX.Element {
|
|||
|
||||
try {
|
||||
await props.onCreateRoom(user.id)
|
||||
showToast("success", "CHAT CREATED", `STARTED CONVERSATION WITH ${user.display_name.toUpperCase()}`)
|
||||
showToast(
|
||||
'success',
|
||||
'CHAT CREATED',
|
||||
`STARTED CONVERSATION WITH ${user.display_name.toUpperCase()}`
|
||||
)
|
||||
handleClose()
|
||||
} catch (_error: unknown) {
|
||||
showToast("error", "FAILED TO CREATE CHAT", "PLEASE TRY AGAIN")
|
||||
showToast('error', 'FAILED TO CREATE CHAT', 'PLEASE TRY AGAIN')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
|
|
@ -56,9 +61,9 @@ export function NewConversation(props: NewConversationProps): JSX.Element {
|
|||
>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="font-pixel text-[10px] text-gray block mb-2">
|
||||
<span class="font-pixel text-[10px] text-gray block mb-2">
|
||||
SEARCH FOR A USER
|
||||
</label>
|
||||
</span>
|
||||
<UserSearch
|
||||
onSelect={handleUserSelect}
|
||||
placeholder="ENTER USERNAME..."
|
||||
|
|
@ -97,12 +102,7 @@ export function NewConversation(props: NewConversationProps): JSX.Element {
|
|||
</Show>
|
||||
|
||||
<div class="flex items-center gap-3 pt-4 border-t-2 border-dark-gray">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
onClick={handleClose}
|
||||
fullWidth
|
||||
>
|
||||
<Button variant="secondary" size="md" onClick={handleClose} fullWidth>
|
||||
CANCEL
|
||||
</Button>
|
||||
<Button
|
||||
|
|
@ -123,7 +123,13 @@ export function NewConversation(props: NewConversationProps): JSX.Element {
|
|||
|
||||
function CloseIcon(): JSX.Element {
|
||||
return (
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="currentColor">
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 10 10"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="1" y="2" width="2" height="2" />
|
||||
<rect x="2" y="3" width="2" height="2" />
|
||||
<rect x="3" y="4" width="2" height="2" />
|
||||
|
|
|
|||
|
|
@ -2,57 +2,57 @@
|
|||
// © AngelaMos | 2025
|
||||
// OnlineStatus.tsx
|
||||
// ===================
|
||||
import { Show } from "solid-js"
|
||||
import type { JSX } from "solid-js"
|
||||
import type { PresenceStatus } from "../../types"
|
||||
|
||||
import type { JSX } from 'solid-js'
|
||||
import { Show } from 'solid-js'
|
||||
import type { PresenceStatus } from '../../types'
|
||||
|
||||
interface OnlineStatusProps {
|
||||
status: PresenceStatus
|
||||
size?: "sm" | "md" | "lg"
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
showLabel?: boolean
|
||||
class?: string
|
||||
}
|
||||
|
||||
const SIZE_MAP = {
|
||||
sm: "w-2 h-2",
|
||||
md: "w-3 h-3",
|
||||
lg: "w-4 h-4",
|
||||
sm: 'w-2 h-2',
|
||||
md: 'w-3 h-3',
|
||||
lg: 'w-4 h-4',
|
||||
}
|
||||
|
||||
export function OnlineStatus(props: OnlineStatusProps): JSX.Element {
|
||||
const sizeClass = (): string => SIZE_MAP[props.size ?? "md"]
|
||||
const sizeClass = (): string => SIZE_MAP[props.size ?? 'md']
|
||||
|
||||
const statusColor = (): string => {
|
||||
switch (props.status) {
|
||||
case "online":
|
||||
return "bg-success"
|
||||
case "away":
|
||||
return "bg-away"
|
||||
case "offline":
|
||||
return "bg-offline"
|
||||
case 'online':
|
||||
return 'bg-success'
|
||||
case 'away':
|
||||
return 'bg-away'
|
||||
case 'offline':
|
||||
return 'bg-offline'
|
||||
default:
|
||||
return "bg-gray"
|
||||
return 'bg-gray'
|
||||
}
|
||||
}
|
||||
|
||||
const statusLabel = (): string => {
|
||||
switch (props.status) {
|
||||
case "online":
|
||||
return "ONLINE"
|
||||
case "away":
|
||||
return "AWAY"
|
||||
case "offline":
|
||||
return "OFFLINE"
|
||||
case 'online':
|
||||
return 'ONLINE'
|
||||
case 'away':
|
||||
return 'AWAY'
|
||||
case 'offline':
|
||||
return 'OFFLINE'
|
||||
default:
|
||||
return "UNKNOWN"
|
||||
return 'UNKNOWN'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class={`flex items-center gap-2 ${props.class ?? ""}`}>
|
||||
<div
|
||||
class={`${sizeClass()} ${statusColor()}`}
|
||||
role="status"
|
||||
<div class={`flex items-center gap-2 ${props.class ?? ''}`}>
|
||||
<output
|
||||
class={`${sizeClass()} ${statusColor()} block`}
|
||||
aria-label={statusLabel()}
|
||||
/>
|
||||
<Show when={props.showLabel === true}>
|
||||
|
|
|
|||
|
|
@ -2,10 +2,11 @@
|
|||
// © AngelaMos | 2025
|
||||
// TypingIndicator.tsx
|
||||
// ===================
|
||||
import { Show, For } from "solid-js"
|
||||
import type { JSX } from "solid-js"
|
||||
import { useStore } from "@nanostores/solid"
|
||||
import { $activeRoomTypingUsernames } from "../../stores"
|
||||
|
||||
import { useStore } from '@nanostores/solid'
|
||||
import type { JSX } from 'solid-js'
|
||||
import { For, Show } from 'solid-js'
|
||||
import { $activeRoomTypingUsernames } from '../../stores'
|
||||
|
||||
interface TypingIndicatorProps {
|
||||
class?: string
|
||||
|
|
@ -16,7 +17,7 @@ export function TypingIndicator(props: TypingIndicatorProps): JSX.Element {
|
|||
|
||||
const typingText = (): string => {
|
||||
const users = typingUsernames()
|
||||
if (users.length === 0) return ""
|
||||
if (users.length === 0) return ''
|
||||
if (users.length === 1) return `${users[0]} IS TYPING`
|
||||
if (users.length === 2) return `${users[0]} AND ${users[1]} ARE TYPING`
|
||||
return `${users[0]} AND ${users.length - 1} OTHERS ARE TYPING`
|
||||
|
|
@ -24,11 +25,9 @@ export function TypingIndicator(props: TypingIndicatorProps): JSX.Element {
|
|||
|
||||
return (
|
||||
<Show when={typingUsernames().length > 0}>
|
||||
<div class={`flex items-center gap-2 ${props.class ?? ""}`}>
|
||||
<div class={`flex items-center gap-2 ${props.class ?? ''}`}>
|
||||
<TypingDots />
|
||||
<span class="font-pixel text-[8px] text-gray">
|
||||
{typingText()}
|
||||
</span>
|
||||
<span class="font-pixel text-[8px] text-gray">{typingText()}</span>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
|
|
@ -41,7 +40,7 @@ function TypingDots(): JSX.Element {
|
|||
{(index) => (
|
||||
<div
|
||||
class="w-1 h-1 bg-orange animate-bounce-pixel"
|
||||
style={{ "animation-delay": `${index * 150}ms` }}
|
||||
style={{ 'animation-delay': `${index * 150}ms` }}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
|
|
@ -54,10 +53,12 @@ interface TypingIndicatorInlineProps {
|
|||
class?: string
|
||||
}
|
||||
|
||||
export function TypingIndicatorInline(props: TypingIndicatorInlineProps): JSX.Element {
|
||||
export function TypingIndicatorInline(
|
||||
props: TypingIndicatorInlineProps
|
||||
): JSX.Element {
|
||||
const typingText = (): string => {
|
||||
const users = props.usernames
|
||||
if (users.length === 0) return ""
|
||||
if (users.length === 0) return ''
|
||||
if (users.length === 1) return `${users[0]} IS TYPING`
|
||||
if (users.length === 2) return `${users[0]} AND ${users[1]} ARE TYPING`
|
||||
return `${users[0]} AND ${users.length - 1} OTHERS ARE TYPING`
|
||||
|
|
@ -65,11 +66,9 @@ export function TypingIndicatorInline(props: TypingIndicatorInlineProps): JSX.El
|
|||
|
||||
return (
|
||||
<Show when={props.usernames.length > 0}>
|
||||
<div class={`flex items-center gap-2 ${props.class ?? ""}`}>
|
||||
<div class={`flex items-center gap-2 ${props.class ?? ''}`}>
|
||||
<TypingDots />
|
||||
<span class="font-pixel text-[8px] text-gray">
|
||||
{typingText()}
|
||||
</span>
|
||||
<span class="font-pixel text-[8px] text-gray">{typingText()}</span>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,13 +2,14 @@
|
|||
// © AngelaMos | 2025
|
||||
// UserSearch.tsx
|
||||
// ===================
|
||||
import { createSignal, Show, For, onCleanup } from "solid-js"
|
||||
import type { JSX } from "solid-js"
|
||||
import { Input } from "../UI/Input"
|
||||
import { Spinner } from "../UI/Spinner"
|
||||
import { api } from "../../lib/api-client"
|
||||
import { USER_SEARCH_MIN_LENGTH, USER_SEARCH_DEFAULT_LIMIT } from "../../config"
|
||||
import type { User } from "../../types"
|
||||
|
||||
import type { JSX } from 'solid-js'
|
||||
import { createSignal, For, onCleanup, Show } from 'solid-js'
|
||||
import { USER_SEARCH_DEFAULT_LIMIT, USER_SEARCH_MIN_LENGTH } from '../../config'
|
||||
import { api } from '../../lib/api-client'
|
||||
import type { User } from '../../types'
|
||||
import { Input } from '../UI/Input'
|
||||
import { Spinner } from '../UI/Spinner'
|
||||
|
||||
interface UserSearchProps {
|
||||
onSelect: (user: User) => void
|
||||
|
|
@ -24,7 +25,7 @@ interface SearchResult {
|
|||
}
|
||||
|
||||
export function UserSearch(props: UserSearchProps): JSX.Element {
|
||||
const [query, setQuery] = createSignal("")
|
||||
const [query, setQuery] = createSignal('')
|
||||
const [result, setResult] = createSignal<SearchResult>({
|
||||
users: [],
|
||||
loading: false,
|
||||
|
|
@ -61,14 +62,15 @@ export function UserSearch(props: UserSearchProps): JSX.Element {
|
|||
})
|
||||
setResult({ users: response.users, loading: false, error: null })
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Search failed"
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Search failed'
|
||||
setResult({ users: [], loading: false, error: errorMessage })
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelect = (user: User): void => {
|
||||
props.onSelect(user)
|
||||
setQuery("")
|
||||
setQuery('')
|
||||
setResult({ users: [], loading: false, error: null })
|
||||
setIsFocused(false)
|
||||
}
|
||||
|
|
@ -79,7 +81,10 @@ export function UserSearch(props: UserSearchProps): JSX.Element {
|
|||
}
|
||||
|
||||
const showResults = (): boolean => {
|
||||
return isFocused() && (result().loading || filteredUsers().length > 0 || result().error !== null)
|
||||
return (
|
||||
isFocused() &&
|
||||
(result().loading || filteredUsers().length > 0 || result().error !== null)
|
||||
)
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
|
|
@ -89,10 +94,10 @@ export function UserSearch(props: UserSearchProps): JSX.Element {
|
|||
})
|
||||
|
||||
return (
|
||||
<div class={`relative ${props.class ?? ""}`}>
|
||||
<div class={`relative ${props.class ?? ''}`}>
|
||||
<Input
|
||||
name="user-search"
|
||||
placeholder={props.placeholder ?? "SEARCH USERS..."}
|
||||
placeholder={props.placeholder ?? 'SEARCH USERS...'}
|
||||
value={query()}
|
||||
onInput={handleInput}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
|
|
@ -143,11 +148,16 @@ export function UserSearch(props: UserSearchProps): JSX.Element {
|
|||
</For>
|
||||
</Show>
|
||||
|
||||
<Show when={!result().loading && !result().error && filteredUsers().length === 0 && query().length >= USER_SEARCH_MIN_LENGTH}>
|
||||
<Show
|
||||
when={
|
||||
!result().loading &&
|
||||
!result().error &&
|
||||
filteredUsers().length === 0 &&
|
||||
query().length >= USER_SEARCH_MIN_LENGTH
|
||||
}
|
||||
>
|
||||
<div class="p-3">
|
||||
<span class="font-pixel text-[10px] text-gray">
|
||||
NO USERS FOUND
|
||||
</span>
|
||||
<span class="font-pixel text-[10px] text-gray">NO USERS FOUND</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
|
@ -158,7 +168,14 @@ export function UserSearch(props: UserSearchProps): JSX.Element {
|
|||
|
||||
function SearchIcon(): JSX.Element {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor" class="text-gray">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 14 14"
|
||||
fill="currentColor"
|
||||
class="text-gray"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="4" y="1" width="4" height="1" />
|
||||
<rect x="2" y="2" width="2" height="1" />
|
||||
<rect x="8" y="2" width="2" height="1" />
|
||||
|
|
|
|||
|
|
@ -2,14 +2,15 @@
|
|||
// © AngelaMos | 2025
|
||||
// index.ts
|
||||
// ===================
|
||||
export * from "./ConversationList"
|
||||
export * from "./ConversationItem"
|
||||
export * from "./MessageList"
|
||||
export * from "./MessageBubble"
|
||||
export * from "./ChatInput"
|
||||
export * from "./ChatHeader"
|
||||
export * from "./TypingIndicator"
|
||||
export * from "./OnlineStatus"
|
||||
export * from "./EncryptionBadge"
|
||||
export * from "./UserSearch"
|
||||
export * from "./NewConversation"
|
||||
|
||||
export * from './ChatHeader'
|
||||
export * from './ChatInput'
|
||||
export * from './ConversationItem'
|
||||
export * from './ConversationList'
|
||||
export * from './EncryptionBadge'
|
||||
export * from './MessageBubble'
|
||||
export * from './MessageList'
|
||||
export * from './NewConversation'
|
||||
export * from './OnlineStatus'
|
||||
export * from './TypingIndicator'
|
||||
export * from './UserSearch'
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@
|
|||
* Main application shell layout
|
||||
*/
|
||||
|
||||
import type { ParentProps, JSX } from "solid-js"
|
||||
import { Show } from "solid-js"
|
||||
import { useStore } from "@nanostores/solid"
|
||||
import { $sidebarOpen, $isMobile } from "../../stores"
|
||||
import { Sidebar } from "./Sidebar"
|
||||
import { Header } from "./Header"
|
||||
import { useStore } from '@nanostores/solid'
|
||||
import type { JSX, ParentProps } from 'solid-js'
|
||||
import { Show } from 'solid-js'
|
||||
import { $isMobile, $sidebarOpen } from '../../stores'
|
||||
import { Header } from './Header'
|
||||
import { Sidebar } from './Sidebar'
|
||||
|
||||
interface AppShellProps extends ParentProps {
|
||||
showSidebar?: boolean
|
||||
|
|
@ -34,15 +34,19 @@ export function AppShell(props: AppShellProps): JSX.Element {
|
|||
</div>
|
||||
</Show>
|
||||
|
||||
<main class="flex-1 overflow-hidden bg-black">
|
||||
{props.children}
|
||||
</main>
|
||||
<main class="flex-1 overflow-hidden bg-black">{props.children}</main>
|
||||
</div>
|
||||
|
||||
<Show when={isMobile() && sidebarOpen()}>
|
||||
<div
|
||||
class="fixed inset-0 z-30 bg-black/80"
|
||||
<button
|
||||
type="button"
|
||||
class="fixed inset-0 z-30 bg-black/80 w-full h-full border-0 cursor-default appearance-none"
|
||||
tabIndex={-1}
|
||||
aria-label="Close sidebar"
|
||||
onClick={() => $sidebarOpen.set(false)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') $sidebarOpen.set(false)
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,20 +2,20 @@
|
|||
* 8-bit styled header/navbar component
|
||||
*/
|
||||
|
||||
import type { JSX } from "solid-js"
|
||||
import { Show } from "solid-js"
|
||||
import { A, useLocation } from "@solidjs/router"
|
||||
import { useStore } from "@nanostores/solid"
|
||||
import { useStore } from '@nanostores/solid'
|
||||
import { A, useLocation } from '@solidjs/router'
|
||||
import type { JSX } from 'solid-js'
|
||||
import { Show } from 'solid-js'
|
||||
import {
|
||||
$currentUser,
|
||||
$isAuthenticated,
|
||||
$sidebarOpen,
|
||||
$currentUser,
|
||||
toggleSidebar,
|
||||
openModal,
|
||||
} from "../../stores"
|
||||
import { IconButton } from "../UI/IconButton"
|
||||
import { Badge } from "../UI/Badge"
|
||||
import { Avatar } from "../UI/Avatar"
|
||||
toggleSidebar,
|
||||
} from '../../stores'
|
||||
import { Avatar } from '../UI/Avatar'
|
||||
import { Badge } from '../UI/Badge'
|
||||
import { IconButton } from '../UI/IconButton'
|
||||
|
||||
export function Header(): JSX.Element {
|
||||
const isAuthenticated = useStore($isAuthenticated)
|
||||
|
|
@ -30,7 +30,7 @@ export function Header(): JSX.Element {
|
|||
<Show when={isAuthenticated()}>
|
||||
<IconButton
|
||||
icon={sidebarOpen() ? <CloseMenuIcon /> : <MenuIcon />}
|
||||
ariaLabel={sidebarOpen() ? "Close menu" : "Open menu"}
|
||||
ariaLabel={sidebarOpen() ? 'Close menu' : 'Open menu'}
|
||||
onClick={toggleSidebar}
|
||||
size="sm"
|
||||
/>
|
||||
|
|
@ -75,20 +75,26 @@ export function Header(): JSX.Element {
|
|||
icon={<SearchIcon />}
|
||||
ariaLabel="Search"
|
||||
size="sm"
|
||||
onClick={() => openModal("new-conversation")}
|
||||
onClick={() => openModal('new-conversation')}
|
||||
/>
|
||||
<Show when={currentUser()} keyed>
|
||||
{(user) => (
|
||||
<A
|
||||
href="/settings"
|
||||
class={`flex items-center gap-2 px-2 py-1 border-2 ${
|
||||
location.pathname === "/settings"
|
||||
? "border-orange bg-orange text-black"
|
||||
: "border-transparent text-white hover:text-orange"
|
||||
location.pathname === '/settings'
|
||||
? 'border-orange bg-orange text-black'
|
||||
: 'border-transparent text-white hover:text-orange'
|
||||
}`}
|
||||
>
|
||||
<Avatar alt={user.display_name} size="xs" fallback={user.display_name.slice(0, 2)} />
|
||||
<span class="font-pixel text-[10px] hidden sm:block">{user.display_name}</span>
|
||||
<Avatar
|
||||
alt={user.display_name}
|
||||
size="xs"
|
||||
fallback={user.display_name.slice(0, 2)}
|
||||
/>
|
||||
<span class="font-pixel text-[10px] hidden sm:block">
|
||||
{user.display_name}
|
||||
</span>
|
||||
</A>
|
||||
)}
|
||||
</Show>
|
||||
|
|
@ -101,7 +107,13 @@ export function Header(): JSX.Element {
|
|||
|
||||
function MenuIcon(): JSX.Element {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="2" y="3" width="12" height="2" />
|
||||
<rect x="2" y="7" width="12" height="2" />
|
||||
<rect x="2" y="11" width="12" height="2" />
|
||||
|
|
@ -111,7 +123,13 @@ function MenuIcon(): JSX.Element {
|
|||
|
||||
function CloseMenuIcon(): JSX.Element {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="3" y="3" width="2" height="2" />
|
||||
<rect x="5" y="5" width="2" height="2" />
|
||||
<rect x="7" y="7" width="2" height="2" />
|
||||
|
|
@ -127,20 +145,74 @@ function CloseMenuIcon(): JSX.Element {
|
|||
|
||||
function LockIcon(): JSX.Element {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<rect x="6" y="3" width="8" height="2" fill="currentColor" class="text-orange" />
|
||||
<rect x="4" y="5" width="2" height="4" fill="currentColor" class="text-orange" />
|
||||
<rect x="14" y="5" width="2" height="4" fill="currentColor" class="text-orange" />
|
||||
<rect x="3" y="9" width="14" height="2" fill="currentColor" class="text-orange" />
|
||||
<rect x="3" y="11" width="14" height="6" fill="currentColor" class="text-orange" />
|
||||
<rect x="9" y="12" width="2" height="4" fill="currentColor" class="text-black" />
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect
|
||||
x="6"
|
||||
y="3"
|
||||
width="8"
|
||||
height="2"
|
||||
fill="currentColor"
|
||||
class="text-orange"
|
||||
/>
|
||||
<rect
|
||||
x="4"
|
||||
y="5"
|
||||
width="2"
|
||||
height="4"
|
||||
fill="currentColor"
|
||||
class="text-orange"
|
||||
/>
|
||||
<rect
|
||||
x="14"
|
||||
y="5"
|
||||
width="2"
|
||||
height="4"
|
||||
fill="currentColor"
|
||||
class="text-orange"
|
||||
/>
|
||||
<rect
|
||||
x="3"
|
||||
y="9"
|
||||
width="14"
|
||||
height="2"
|
||||
fill="currentColor"
|
||||
class="text-orange"
|
||||
/>
|
||||
<rect
|
||||
x="3"
|
||||
y="11"
|
||||
width="14"
|
||||
height="6"
|
||||
fill="currentColor"
|
||||
class="text-orange"
|
||||
/>
|
||||
<rect
|
||||
x="9"
|
||||
y="12"
|
||||
width="2"
|
||||
height="4"
|
||||
fill="currentColor"
|
||||
class="text-black"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchIcon(): JSX.Element {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="4" y="2" width="6" height="2" />
|
||||
<rect x="2" y="4" width="2" height="6" />
|
||||
<rect x="10" y="4" width="2" height="6" />
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@
|
|||
* Protected route wrapper that redirects unauthenticated users
|
||||
*/
|
||||
|
||||
import type { ParentProps, JSX } from "solid-js"
|
||||
import { Show, createEffect } from "solid-js"
|
||||
import { useNavigate, useLocation } from "@solidjs/router"
|
||||
import { useStore } from "@nanostores/solid"
|
||||
import { $isAuthenticated } from "../../stores"
|
||||
import { Spinner } from "../UI/Spinner"
|
||||
import { useStore } from '@nanostores/solid'
|
||||
import { useLocation, useNavigate } from '@solidjs/router'
|
||||
import type { JSX, ParentProps } from 'solid-js'
|
||||
import { createEffect, Show } from 'solid-js'
|
||||
import { $isAuthenticated } from '../../stores'
|
||||
import { Spinner } from '../UI/Spinner'
|
||||
|
||||
interface ProtectedRouteProps extends ParentProps {
|
||||
redirectTo?: string
|
||||
|
|
@ -18,14 +18,15 @@ export function ProtectedRoute(props: ProtectedRouteProps): JSX.Element {
|
|||
const location = useLocation()
|
||||
const isAuthenticated = useStore($isAuthenticated)
|
||||
|
||||
const redirectPath = (): string => props.redirectTo ?? "/login"
|
||||
const redirectPath = (): string => props.redirectTo ?? '/login'
|
||||
|
||||
createEffect(() => {
|
||||
if (!isAuthenticated()) {
|
||||
const currentPath = location.pathname
|
||||
const redirectUrl = currentPath !== "/"
|
||||
? `${redirectPath()}?redirect=${encodeURIComponent(currentPath)}`
|
||||
: redirectPath()
|
||||
const redirectUrl =
|
||||
currentPath !== '/'
|
||||
? `${redirectPath()}?redirect=${encodeURIComponent(currentPath)}`
|
||||
: redirectPath()
|
||||
|
||||
navigate(redirectUrl, { replace: true })
|
||||
}
|
||||
|
|
@ -58,7 +59,7 @@ export function GuestRoute(props: ParentProps): JSX.Element {
|
|||
createEffect(() => {
|
||||
if (isAuthenticated()) {
|
||||
const params = new URLSearchParams(location.search)
|
||||
const redirectPath = params.get("redirect") ?? "/chat"
|
||||
const redirectPath = params.get('redirect') ?? '/chat'
|
||||
navigate(redirectPath, { replace: true })
|
||||
}
|
||||
})
|
||||
|
|
@ -70,9 +71,7 @@ export function GuestRoute(props: ParentProps): JSX.Element {
|
|||
<div class="h-full flex items-center justify-center bg-black">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<Spinner size="lg" />
|
||||
<span class="font-pixel text-[10px] text-orange">
|
||||
REDIRECTING...
|
||||
</span>
|
||||
<span class="font-pixel text-[10px] text-orange">REDIRECTING...</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,21 +2,21 @@
|
|||
* Sidebar - Room list only
|
||||
*/
|
||||
|
||||
import type { JSX } from "solid-js"
|
||||
import { Show, Index, createMemo } from "solid-js"
|
||||
import type { Participant } from "../../types"
|
||||
import { useStore } from "@nanostores/solid"
|
||||
import { useStore } from '@nanostores/solid'
|
||||
import type { JSX } from 'solid-js'
|
||||
import { createMemo, Index, Show } from 'solid-js'
|
||||
import {
|
||||
$activeRoomId,
|
||||
$currentUser,
|
||||
$rooms,
|
||||
$activeRoomId,
|
||||
$totalUnreadCount,
|
||||
setActiveRoom,
|
||||
openModal,
|
||||
} from "../../stores"
|
||||
import { Avatar } from "../UI/Avatar"
|
||||
import { Badge } from "../UI/Badge"
|
||||
import { IconButton } from "../UI/IconButton"
|
||||
setActiveRoom,
|
||||
} from '../../stores'
|
||||
import type { Participant } from '../../types'
|
||||
import { Avatar } from '../UI/Avatar'
|
||||
import { Badge } from '../UI/Badge'
|
||||
import { IconButton } from '../UI/IconButton'
|
||||
|
||||
export function Sidebar(): JSX.Element {
|
||||
const currentUser = useStore($currentUser)
|
||||
|
|
@ -39,7 +39,9 @@ export function Sidebar(): JSX.Element {
|
|||
<div class="flex items-center justify-between">
|
||||
<h2 class="font-pixel text-[10px] text-orange uppercase">Messages</h2>
|
||||
<Show when={totalUnread() > 0}>
|
||||
<Badge variant="primary" size="xs">{totalUnread()}</Badge>
|
||||
<Badge variant="primary" size="xs">
|
||||
{totalUnread()}
|
||||
</Badge>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -51,16 +53,22 @@ export function Sidebar(): JSX.Element {
|
|||
variant="subtle"
|
||||
size="sm"
|
||||
class="w-full justify-start gap-2 px-3"
|
||||
onClick={() => openModal("new-conversation")}
|
||||
onClick={() => openModal('new-conversation')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-2">
|
||||
<Index each={sortedRooms()}>
|
||||
{(room, idx) => {
|
||||
{(room, _idx) => {
|
||||
const isActive = createMemo(() => activeRoomId() === room().id)
|
||||
const other = createMemo(() => room().participants?.find((p: Participant) => p.user_id !== currentUser()?.id))
|
||||
const displayName = createMemo(() => room().name ?? other()?.display_name ?? "Chat")
|
||||
const other = createMemo(() =>
|
||||
room().participants?.find(
|
||||
(p: Participant) => p.user_id !== currentUser()?.id
|
||||
)
|
||||
)
|
||||
const displayName = createMemo(
|
||||
() => room().name ?? other()?.display_name ?? 'Chat'
|
||||
)
|
||||
|
||||
return (
|
||||
<button
|
||||
|
|
@ -69,17 +77,19 @@ export function Sidebar(): JSX.Element {
|
|||
data-active={isActive()}
|
||||
onClick={() => setActiveRoom(room().id)}
|
||||
style={{
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
"align-items": "center",
|
||||
gap: "12px",
|
||||
padding: "12px",
|
||||
"margin-bottom": "4px",
|
||||
border: isActive() ? "2px solid #FF5300" : "2px solid transparent",
|
||||
background: isActive() ? "#FF5300" : "black",
|
||||
color: isActive() ? "black" : "white",
|
||||
cursor: "pointer",
|
||||
"text-align": "left",
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
'align-items': 'center',
|
||||
gap: '12px',
|
||||
padding: '12px',
|
||||
'margin-bottom': '4px',
|
||||
border: isActive()
|
||||
? '2px solid #FF5300'
|
||||
: '2px solid transparent',
|
||||
background: isActive() ? '#FF5300' : 'black',
|
||||
color: isActive() ? 'black' : 'white',
|
||||
cursor: 'pointer',
|
||||
'text-align': 'left',
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
|
|
@ -87,14 +97,14 @@ export function Sidebar(): JSX.Element {
|
|||
size="sm"
|
||||
fallback={displayName().slice(0, 2)}
|
||||
/>
|
||||
<div style={{ flex: 1, "min-width": 0 }}>
|
||||
<div style={{ flex: 1, 'min-width': 0 }}>
|
||||
<span class="font-pixel text-[10px] truncate block">
|
||||
{displayName()}
|
||||
</span>
|
||||
<Show when={room().last_message}>
|
||||
<p
|
||||
class="font-pixel text-[8px] truncate mt-0.5"
|
||||
style={{ color: isActive() ? "rgba(0,0,0,0.7)" : "#888" }}
|
||||
style={{ color: isActive() ? 'rgba(0,0,0,0.7)' : '#888' }}
|
||||
>
|
||||
{room().last_message?.content}
|
||||
</p>
|
||||
|
|
@ -116,7 +126,13 @@ export function Sidebar(): JSX.Element {
|
|||
|
||||
function PlusIcon(): JSX.Element {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="7" y="2" width="2" height="12" />
|
||||
<rect x="2" y="7" width="12" height="2" />
|
||||
</svg>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
// © AngelaMos | 2025
|
||||
// index.ts
|
||||
// ===================
|
||||
export { AppShell } from "./AppShell"
|
||||
export { Sidebar } from "./Sidebar"
|
||||
export { Header } from "./Header"
|
||||
export { ProtectedRoute, GuestRoute } from "./ProtectedRoute"
|
||||
export { AppShell } from './AppShell'
|
||||
export { Header } from './Header'
|
||||
export { GuestRoute, ProtectedRoute } from './ProtectedRoute'
|
||||
export { Sidebar } from './Sidebar'
|
||||
|
|
|
|||
|
|
@ -2,37 +2,37 @@
|
|||
* 8-bit styled avatar component
|
||||
*/
|
||||
|
||||
import { Show, createSignal, onMount } from "solid-js"
|
||||
import type { JSX } from "solid-js"
|
||||
import type { AvatarProps, Size, PresenceStatus } from "../../types"
|
||||
import type { JSX } from 'solid-js'
|
||||
import { createSignal, onMount, Show } from 'solid-js'
|
||||
import type { AvatarProps, PresenceStatus, Size } from '../../types'
|
||||
|
||||
const SIZE_CLASSES: Record<Size, string> = {
|
||||
xs: "w-6 h-6 text-[8px]",
|
||||
sm: "w-8 h-8 text-[10px]",
|
||||
md: "w-10 h-10 text-xs",
|
||||
lg: "w-12 h-12 text-sm",
|
||||
xl: "w-16 h-16 text-base",
|
||||
xs: 'w-6 h-6 text-[8px]',
|
||||
sm: 'w-8 h-8 text-[10px]',
|
||||
md: 'w-10 h-10 text-xs',
|
||||
lg: 'w-12 h-12 text-sm',
|
||||
xl: 'w-16 h-16 text-base',
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<PresenceStatus, string> = {
|
||||
online: "bg-success",
|
||||
away: "bg-orange",
|
||||
offline: "bg-gray",
|
||||
online: 'bg-success',
|
||||
away: 'bg-orange',
|
||||
offline: 'bg-gray',
|
||||
}
|
||||
|
||||
const STATUS_SIZE: Record<Size, string> = {
|
||||
xs: "w-2 h-2",
|
||||
sm: "w-2.5 h-2.5",
|
||||
md: "w-3 h-3",
|
||||
lg: "w-3.5 h-3.5",
|
||||
xl: "w-4 h-4",
|
||||
xs: 'w-2 h-2',
|
||||
sm: 'w-2.5 h-2.5',
|
||||
md: 'w-3 h-3',
|
||||
lg: 'w-3.5 h-3.5',
|
||||
xl: 'w-4 h-4',
|
||||
}
|
||||
|
||||
export function Avatar(props: AvatarProps): JSX.Element {
|
||||
const [imageError, setImageError] = createSignal(false)
|
||||
let imgRef: HTMLImageElement | undefined
|
||||
|
||||
const size = (): Size => props.size ?? "md"
|
||||
const size = (): Size => props.size ?? 'md'
|
||||
const showStatus = (): boolean => props.showStatus ?? false
|
||||
|
||||
const getFallbackInitials = (): string => {
|
||||
|
|
@ -44,7 +44,7 @@ export function Avatar(props: AvatarProps): JSX.Element {
|
|||
|
||||
onMount(() => {
|
||||
if (imgRef !== undefined) {
|
||||
imgRef.addEventListener("error", () => setImageError(true))
|
||||
imgRef.addEventListener('error', () => setImageError(true))
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -58,7 +58,7 @@ export function Avatar(props: AvatarProps): JSX.Element {
|
|||
relative inline-flex items-center justify-center
|
||||
bg-black border-2 border-orange
|
||||
${SIZE_CLASSES[size()]}
|
||||
${props.class ?? ""}
|
||||
${props.class ?? ''}
|
||||
`}
|
||||
>
|
||||
<Show
|
||||
|
|
@ -74,7 +74,7 @@ export function Avatar(props: AvatarProps): JSX.Element {
|
|||
src={props.src}
|
||||
alt={props.alt}
|
||||
class="w-full h-full object-cover"
|
||||
style={{ "image-rendering": "pixelated" }}
|
||||
style={{ 'image-rendering': 'pixelated' }}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
|
|
|
|||
|
|
@ -2,37 +2,37 @@
|
|||
* 8-bit styled badge component
|
||||
*/
|
||||
|
||||
import { Show } from "solid-js"
|
||||
import type { JSX } from "solid-js"
|
||||
import type { BadgeProps, Size, BadgeVariant } from "../../types"
|
||||
import type { JSX } from 'solid-js'
|
||||
import { Show } from 'solid-js'
|
||||
import type { BadgeProps, BadgeVariant, Size } from '../../types'
|
||||
|
||||
const SIZE_CLASSES: Record<Size, string> = {
|
||||
xs: "px-1 py-0.5 text-[6px]",
|
||||
sm: "px-1.5 py-0.5 text-[8px]",
|
||||
md: "px-2 py-1 text-[10px]",
|
||||
lg: "px-3 py-1 text-xs",
|
||||
xl: "px-4 py-1.5 text-sm",
|
||||
xs: 'px-1 py-0.5 text-[6px]',
|
||||
sm: 'px-1.5 py-0.5 text-[8px]',
|
||||
md: 'px-2 py-1 text-[10px]',
|
||||
lg: 'px-3 py-1 text-xs',
|
||||
xl: 'px-4 py-1.5 text-sm',
|
||||
}
|
||||
|
||||
const VARIANT_CLASSES: Record<BadgeVariant, string> = {
|
||||
default: "bg-dark-gray text-white border-gray",
|
||||
primary: "bg-black text-orange border-orange",
|
||||
success: "bg-black text-success border-success",
|
||||
warning: "bg-black text-orange border-orange",
|
||||
error: "bg-black text-error border-error",
|
||||
default: 'bg-dark-gray text-white border-gray',
|
||||
primary: 'bg-black text-orange border-orange',
|
||||
success: 'bg-black text-success border-success',
|
||||
warning: 'bg-black text-orange border-orange',
|
||||
error: 'bg-black text-error border-error',
|
||||
}
|
||||
|
||||
const DOT_COLORS: Record<BadgeVariant, string> = {
|
||||
default: "bg-gray",
|
||||
primary: "bg-orange",
|
||||
success: "bg-success",
|
||||
warning: "bg-orange",
|
||||
error: "bg-error",
|
||||
default: 'bg-gray',
|
||||
primary: 'bg-orange',
|
||||
success: 'bg-success',
|
||||
warning: 'bg-orange',
|
||||
error: 'bg-error',
|
||||
}
|
||||
|
||||
export function Badge(props: BadgeProps): JSX.Element {
|
||||
const variant = (): BadgeVariant => props.variant ?? "default"
|
||||
const size = (): Size => props.size ?? "md"
|
||||
const variant = (): BadgeVariant => props.variant ?? 'default'
|
||||
const size = (): Size => props.size ?? 'md'
|
||||
const showDot = (): boolean => props.dot ?? false
|
||||
|
||||
return (
|
||||
|
|
@ -42,7 +42,7 @@ export function Badge(props: BadgeProps): JSX.Element {
|
|||
font-pixel border-2 uppercase
|
||||
${SIZE_CLASSES[size()]}
|
||||
${VARIANT_CLASSES[variant()]}
|
||||
${props.class ?? ""}
|
||||
${props.class ?? ''}
|
||||
`}
|
||||
>
|
||||
<Show when={showDot()}>
|
||||
|
|
|
|||
|
|
@ -2,17 +2,17 @@
|
|||
* 8-bit styled button component
|
||||
*/
|
||||
|
||||
import { Show, splitProps } from "solid-js"
|
||||
import type { JSX } from "solid-js"
|
||||
import type { ButtonProps, Size, ButtonVariant } from "../../types"
|
||||
import { Spinner } from "./Spinner"
|
||||
import type { JSX } from 'solid-js'
|
||||
import { Show, splitProps } from 'solid-js'
|
||||
import type { ButtonProps, ButtonVariant, Size } from '../../types'
|
||||
import { Spinner } from './Spinner'
|
||||
|
||||
const SIZE_CLASSES: Record<Size, string> = {
|
||||
xs: "px-2 py-1 text-[8px]",
|
||||
sm: "px-3 py-1.5 text-[10px]",
|
||||
md: "px-4 py-2 text-xs",
|
||||
lg: "px-6 py-3 text-sm",
|
||||
xl: "px-8 py-4 text-base",
|
||||
xs: 'px-2 py-1 text-[8px]',
|
||||
sm: 'px-3 py-1.5 text-[10px]',
|
||||
md: 'px-4 py-2 text-xs',
|
||||
lg: 'px-6 py-3 text-sm',
|
||||
xl: 'px-8 py-4 text-base',
|
||||
}
|
||||
|
||||
const VARIANT_CLASSES: Record<ButtonVariant, string> = {
|
||||
|
|
@ -49,21 +49,21 @@ const DISABLED_CLASSES = `
|
|||
|
||||
export function Button(props: ButtonProps): JSX.Element {
|
||||
const [local, rest] = splitProps(props, [
|
||||
"variant",
|
||||
"size",
|
||||
"fullWidth",
|
||||
"disabled",
|
||||
"loading",
|
||||
"leftIcon",
|
||||
"rightIcon",
|
||||
"type",
|
||||
"onClick",
|
||||
"class",
|
||||
"children",
|
||||
'variant',
|
||||
'size',
|
||||
'fullWidth',
|
||||
'disabled',
|
||||
'loading',
|
||||
'leftIcon',
|
||||
'rightIcon',
|
||||
'type',
|
||||
'onClick',
|
||||
'class',
|
||||
'children',
|
||||
])
|
||||
|
||||
const variant = (): ButtonVariant => local.variant ?? "primary"
|
||||
const size = (): Size => local.size ?? "md"
|
||||
const variant = (): ButtonVariant => local.variant ?? 'primary'
|
||||
const size = (): Size => local.size ?? 'md'
|
||||
const isDisabled = (): boolean => local.disabled ?? false
|
||||
const isLoading = (): boolean => local.loading ?? false
|
||||
|
||||
|
|
@ -75,7 +75,7 @@ export function Button(props: ButtonProps): JSX.Element {
|
|||
|
||||
return (
|
||||
<button
|
||||
type={local.type ?? "button"}
|
||||
type={local.type ?? 'button'}
|
||||
disabled={isDisabled() || isLoading()}
|
||||
onClick={handleClick}
|
||||
class={`
|
||||
|
|
@ -84,22 +84,18 @@ export function Button(props: ButtonProps): JSX.Element {
|
|||
focus:outline-none focus:ring-2 focus:ring-orange focus:ring-offset-2 focus:ring-offset-black
|
||||
${SIZE_CLASSES[size()]}
|
||||
${VARIANT_CLASSES[variant()]}
|
||||
${(isDisabled() || isLoading()) ? DISABLED_CLASSES : ""}
|
||||
${local.fullWidth === true ? "w-full" : ""}
|
||||
${local.class ?? ""}
|
||||
${isDisabled() || isLoading() ? DISABLED_CLASSES : ''}
|
||||
${local.fullWidth === true ? 'w-full' : ''}
|
||||
${local.class ?? ''}
|
||||
`}
|
||||
{...rest}
|
||||
>
|
||||
<Show when={isLoading()}>
|
||||
<Spinner size="xs" />
|
||||
</Show>
|
||||
<Show when={!isLoading() && local.leftIcon}>
|
||||
{local.leftIcon}
|
||||
</Show>
|
||||
<Show when={!isLoading() && local.leftIcon}>{local.leftIcon}</Show>
|
||||
<span>{local.children}</span>
|
||||
<Show when={!isLoading() && local.rightIcon}>
|
||||
{local.rightIcon}
|
||||
</Show>
|
||||
<Show when={!isLoading() && local.rightIcon}>{local.rightIcon}</Show>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,30 +2,32 @@
|
|||
* 8-bit styled dropdown menu component
|
||||
*/
|
||||
|
||||
import { Show, For, createSignal, onCleanup, createEffect } from "solid-js"
|
||||
import type { JSX } from "solid-js"
|
||||
import type { DropdownProps, DropdownItem } from "../../types"
|
||||
import type { JSX } from 'solid-js'
|
||||
import { createEffect, createSignal, For, onCleanup, Show } from 'solid-js'
|
||||
import type { DropdownItem, DropdownProps } from '../../types'
|
||||
|
||||
type DropdownAlign = "left" | "right"
|
||||
type DropdownAlign = 'left' | 'right'
|
||||
|
||||
export function Dropdown(props: DropdownProps): JSX.Element {
|
||||
const [isOpen, setIsOpen] = createSignal(false)
|
||||
let containerRef: HTMLDivElement | undefined
|
||||
|
||||
const align = (): DropdownAlign => props.align ?? "left"
|
||||
const align = (): DropdownAlign => props.align ?? 'left'
|
||||
|
||||
const getItemClasses = (item: DropdownItem): string => {
|
||||
if (item.disabled === true) {
|
||||
return "text-gray cursor-not-allowed"
|
||||
return 'text-gray cursor-not-allowed'
|
||||
}
|
||||
if (item.danger === true) {
|
||||
return "text-error hover:bg-error hover:text-white"
|
||||
return 'text-error hover:bg-error hover:text-white'
|
||||
}
|
||||
return "text-white hover:bg-orange hover:text-black"
|
||||
return 'text-white hover:bg-orange hover:text-black'
|
||||
}
|
||||
|
||||
const getItemActiveClass = (item: DropdownItem): string => {
|
||||
return item.disabled === true ? "" : "active:translate-x-[1px] active:translate-y-[1px]"
|
||||
return item.disabled === true
|
||||
? ''
|
||||
: 'active:translate-x-[1px] active:translate-y-[1px]'
|
||||
}
|
||||
|
||||
const handleToggle = (): void => {
|
||||
|
|
@ -45,41 +47,38 @@ export function Dropdown(props: DropdownProps): JSX.Element {
|
|||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||
if (e.key === "Escape") {
|
||||
if (e.key === 'Escape') {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (isOpen()) {
|
||||
document.addEventListener("click", handleClickOutside)
|
||||
document.addEventListener("keydown", handleKeyDown)
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
document.removeEventListener("click", handleClickOutside)
|
||||
document.removeEventListener("keydown", handleKeyDown)
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
class={`relative inline-block ${props.class ?? ""}`}
|
||||
>
|
||||
<div
|
||||
role="button"
|
||||
<div ref={containerRef} class={`relative inline-block ${props.class ?? ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={0}
|
||||
onClick={handleToggle}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
handleToggle()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{props.trigger}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<Show when={isOpen()}>
|
||||
<div
|
||||
|
|
@ -88,7 +87,7 @@ export function Dropdown(props: DropdownProps): JSX.Element {
|
|||
bg-black border-2 border-orange
|
||||
shadow-[4px_4px_0_var(--color-orange)]
|
||||
animate-scale-in origin-top
|
||||
${align() === "right" ? "right-0" : "left-0"}
|
||||
${align() === 'right' ? 'right-0' : 'left-0'}
|
||||
`}
|
||||
role="menu"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -2,27 +2,27 @@
|
|||
* 8-bit styled icon button component
|
||||
*/
|
||||
|
||||
import { Show, splitProps } from "solid-js"
|
||||
import type { JSX } from "solid-js"
|
||||
import type { IconButtonProps, Size } from "../../types"
|
||||
import { Spinner } from "./Spinner"
|
||||
import type { JSX } from 'solid-js'
|
||||
import { Show, splitProps } from 'solid-js'
|
||||
import type { IconButtonProps, Size } from '../../types'
|
||||
import { Spinner } from './Spinner'
|
||||
|
||||
type IconButtonVariant = "ghost" | "subtle"
|
||||
type IconButtonVariant = 'ghost' | 'subtle'
|
||||
|
||||
const SIZE_CLASSES: Record<Size, string> = {
|
||||
xs: "w-6 h-6",
|
||||
sm: "w-8 h-8",
|
||||
md: "w-10 h-10",
|
||||
lg: "w-12 h-12",
|
||||
xl: "w-14 h-14",
|
||||
xs: 'w-6 h-6',
|
||||
sm: 'w-8 h-8',
|
||||
md: 'w-10 h-10',
|
||||
lg: 'w-12 h-12',
|
||||
xl: 'w-14 h-14',
|
||||
}
|
||||
|
||||
const ICON_SIZE_CLASSES: Record<Size, string> = {
|
||||
xs: "w-3 h-3",
|
||||
sm: "w-4 h-4",
|
||||
md: "w-5 h-5",
|
||||
lg: "w-6 h-6",
|
||||
xl: "w-7 h-7",
|
||||
xs: 'w-3 h-3',
|
||||
sm: 'w-4 h-4',
|
||||
md: 'w-5 h-5',
|
||||
lg: 'w-6 h-6',
|
||||
xl: 'w-7 h-7',
|
||||
}
|
||||
|
||||
const VARIANT_CLASSES: Record<IconButtonVariant, string> = {
|
||||
|
|
@ -38,22 +38,22 @@ const VARIANT_CLASSES: Record<IconButtonVariant, string> = {
|
|||
`,
|
||||
}
|
||||
|
||||
const DISABLED_CLASSES = "opacity-50 cursor-not-allowed hover:bg-transparent"
|
||||
const DISABLED_CLASSES = 'opacity-50 cursor-not-allowed hover:bg-transparent'
|
||||
|
||||
export function IconButton(props: IconButtonProps): JSX.Element {
|
||||
const [local, rest] = splitProps(props, [
|
||||
"icon",
|
||||
"onClick",
|
||||
"size",
|
||||
"variant",
|
||||
"ariaLabel",
|
||||
"disabled",
|
||||
"loading",
|
||||
"class",
|
||||
'icon',
|
||||
'onClick',
|
||||
'size',
|
||||
'variant',
|
||||
'ariaLabel',
|
||||
'disabled',
|
||||
'loading',
|
||||
'class',
|
||||
])
|
||||
|
||||
const size = (): Size => local.size ?? "md"
|
||||
const variant = (): IconButtonVariant => local.variant ?? "ghost"
|
||||
const size = (): Size => local.size ?? 'md'
|
||||
const variant = (): IconButtonVariant => local.variant ?? 'ghost'
|
||||
const isDisabled = (): boolean => local.disabled ?? false
|
||||
const isLoading = (): boolean => local.loading ?? false
|
||||
|
||||
|
|
@ -75,18 +75,16 @@ export function IconButton(props: IconButtonProps): JSX.Element {
|
|||
focus:outline-none focus:ring-2 focus:ring-orange focus:ring-offset-1 focus:ring-offset-black
|
||||
${SIZE_CLASSES[size()]}
|
||||
${VARIANT_CLASSES[variant()]}
|
||||
${(isDisabled() || isLoading()) ? DISABLED_CLASSES : ""}
|
||||
${local.class ?? ""}
|
||||
${isDisabled() || isLoading() ? DISABLED_CLASSES : ''}
|
||||
${local.class ?? ''}
|
||||
`}
|
||||
{...rest}
|
||||
>
|
||||
<Show
|
||||
when={!isLoading()}
|
||||
fallback={<Spinner size={size() === "xs" ? "xs" : "sm"} />}
|
||||
fallback={<Spinner size={size() === 'xs' ? 'xs' : 'sm'} />}
|
||||
>
|
||||
<span class={ICON_SIZE_CLASSES[size()]}>
|
||||
{local.icon}
|
||||
</span>
|
||||
<span class={ICON_SIZE_CLASSES[size()]}>{local.icon}</span>
|
||||
</Show>
|
||||
</button>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,32 +2,32 @@
|
|||
* 8-bit styled input component
|
||||
*/
|
||||
|
||||
import { Show, splitProps, createSignal } from "solid-js"
|
||||
import type { JSX } from "solid-js"
|
||||
import type { InputProps } from "../../types"
|
||||
import type { JSX } from 'solid-js'
|
||||
import { createSignal, Show, splitProps } from 'solid-js'
|
||||
import type { InputProps } from '../../types'
|
||||
|
||||
export function Input(props: InputProps): JSX.Element {
|
||||
const [local, rest] = splitProps(props, [
|
||||
"type",
|
||||
"name",
|
||||
"placeholder",
|
||||
"value",
|
||||
"onInput",
|
||||
"onChange",
|
||||
"onFocus",
|
||||
"onBlur",
|
||||
"disabled",
|
||||
"error",
|
||||
"label",
|
||||
"hint",
|
||||
"leftIcon",
|
||||
"rightIcon",
|
||||
"fullWidth",
|
||||
"maxLength",
|
||||
"minLength",
|
||||
"required",
|
||||
"autofocus",
|
||||
"class",
|
||||
'type',
|
||||
'name',
|
||||
'placeholder',
|
||||
'value',
|
||||
'onInput',
|
||||
'onChange',
|
||||
'onFocus',
|
||||
'onBlur',
|
||||
'disabled',
|
||||
'error',
|
||||
'label',
|
||||
'hint',
|
||||
'leftIcon',
|
||||
'rightIcon',
|
||||
'fullWidth',
|
||||
'maxLength',
|
||||
'minLength',
|
||||
'required',
|
||||
'autofocus',
|
||||
'class',
|
||||
])
|
||||
|
||||
const [focused, setFocused] = createSignal(false)
|
||||
|
|
@ -48,7 +48,9 @@ export function Input(props: InputProps): JSX.Element {
|
|||
const isDisabled = (): boolean => local.disabled ?? false
|
||||
|
||||
return (
|
||||
<div class={`flex flex-col gap-1 ${local.fullWidth === true ? "w-full" : ""}`}>
|
||||
<div
|
||||
class={`flex flex-col gap-1 ${local.fullWidth === true ? 'w-full' : ''}`}
|
||||
>
|
||||
<Show when={local.label}>
|
||||
<label
|
||||
class="font-pixel text-[10px] text-orange uppercase tracking-wider"
|
||||
|
|
@ -66,9 +68,9 @@ export function Input(props: InputProps): JSX.Element {
|
|||
relative flex items-center
|
||||
bg-black border-2
|
||||
transition-all duration-100
|
||||
${focused() ? "border-orange shadow-[0_0_0_2px_var(--color-orange)]" : "border-dark-gray"}
|
||||
${hasError() ? "border-error shadow-[0_0_0_2px_var(--color-error)]" : ""}
|
||||
${isDisabled() ? "opacity-50 cursor-not-allowed" : ""}
|
||||
${focused() ? 'border-orange shadow-[0_0_0_2px_var(--color-orange)]' : 'border-dark-gray'}
|
||||
${hasError() ? 'border-error shadow-[0_0_0_2px_var(--color-error)]' : ''}
|
||||
${isDisabled() ? 'opacity-50 cursor-not-allowed' : ''}
|
||||
`}
|
||||
>
|
||||
<Show when={local.leftIcon}>
|
||||
|
|
@ -76,11 +78,11 @@ export function Input(props: InputProps): JSX.Element {
|
|||
</Show>
|
||||
|
||||
<input
|
||||
type={local.type ?? "text"}
|
||||
type={local.type ?? 'text'}
|
||||
name={local.name}
|
||||
id={local.name}
|
||||
placeholder={local.placeholder}
|
||||
value={local.value ?? ""}
|
||||
value={local.value ?? ''}
|
||||
disabled={isDisabled()}
|
||||
maxLength={local.maxLength}
|
||||
minLength={local.minLength}
|
||||
|
|
@ -102,9 +104,9 @@ export function Input(props: InputProps): JSX.Element {
|
|||
placeholder:font-placeholder placeholder:text-[16px] placeholder:text-gray
|
||||
focus:outline-none
|
||||
disabled:cursor-not-allowed
|
||||
${local.leftIcon !== undefined ? "pl-1" : ""}
|
||||
${local.rightIcon !== undefined ? "pr-1" : ""}
|
||||
${local.class ?? ""}
|
||||
${local.leftIcon !== undefined ? 'pl-1' : ''}
|
||||
${local.rightIcon !== undefined ? 'pr-1' : ''}
|
||||
${local.class ?? ''}
|
||||
`}
|
||||
{...rest}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -2,50 +2,44 @@
|
|||
* 8-bit styled modal component
|
||||
*/
|
||||
|
||||
import { Show, createEffect, onCleanup } from "solid-js"
|
||||
import type { JSX } from "solid-js"
|
||||
import type { ModalProps, Size } from "../../types"
|
||||
import { IconButton } from "./IconButton"
|
||||
import type { JSX } from 'solid-js'
|
||||
import { createEffect, onCleanup, Show } from 'solid-js'
|
||||
import type { ModalProps, Size } from '../../types'
|
||||
import { IconButton } from './IconButton'
|
||||
|
||||
const SIZE_CLASSES: Record<Size, string> = {
|
||||
xs: "max-w-xs",
|
||||
sm: "max-w-sm",
|
||||
md: "max-w-md",
|
||||
lg: "max-w-lg",
|
||||
xl: "max-w-xl",
|
||||
xs: 'max-w-xs',
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-lg',
|
||||
xl: 'max-w-xl',
|
||||
}
|
||||
|
||||
export function Modal(props: ModalProps): JSX.Element {
|
||||
const size = (): Size => props.size ?? "md"
|
||||
const size = (): Size => props.size ?? 'md'
|
||||
const closeOnOverlayClick = (): boolean => props.closeOnOverlayClick ?? true
|
||||
const showCloseButton = (): boolean => props.showCloseButton ?? true
|
||||
|
||||
const handleOverlayClick = (e: MouseEvent): void => {
|
||||
if (e.target === e.currentTarget && closeOnOverlayClick()) {
|
||||
props.onClose()
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||
if (e.key === "Escape") {
|
||||
if (e.key === 'Escape') {
|
||||
props.onClose()
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (props.isOpen) {
|
||||
document.addEventListener("keydown", handleKeyDown)
|
||||
document.body.style.overflow = "hidden"
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
document.body.style.overflow = 'hidden'
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
document.removeEventListener("keydown", handleKeyDown)
|
||||
document.body.style.overflow = ""
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
document.body.style.overflow = ''
|
||||
})
|
||||
})
|
||||
|
||||
const handleOverlayKeyDown = (e: KeyboardEvent): void => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
if (closeOnOverlayClick()) {
|
||||
props.onClose()
|
||||
|
|
@ -55,15 +49,17 @@ export function Modal(props: ModalProps): JSX.Element {
|
|||
|
||||
return (
|
||||
<Show when={props.isOpen}>
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={handleOverlayClick}
|
||||
onKeyDown={handleOverlayKeyDown}
|
||||
aria-label="Close modal overlay"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/80 animate-fade-in" />
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<button
|
||||
type="button"
|
||||
class="absolute inset-0 w-full h-full bg-black/80 animate-fade-in border-0 cursor-default appearance-none"
|
||||
tabIndex={-1}
|
||||
aria-label="Close modal"
|
||||
onClick={() => {
|
||||
if (closeOnOverlayClick()) props.onClose()
|
||||
}}
|
||||
onKeyDown={handleOverlayKeyDown}
|
||||
/>
|
||||
|
||||
<div
|
||||
class={`
|
||||
|
|
@ -75,9 +71,14 @@ export function Modal(props: ModalProps): JSX.Element {
|
|||
`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={props.title ? "modal-title" : undefined}
|
||||
aria-labelledby={props.title ? 'modal-title' : undefined}
|
||||
>
|
||||
<Show when={(props.title !== undefined && props.title !== "") || showCloseButton()}>
|
||||
<Show
|
||||
when={
|
||||
(props.title !== undefined && props.title !== '') ||
|
||||
showCloseButton()
|
||||
}
|
||||
>
|
||||
<div class="flex items-start justify-between p-4 border-b-2 border-orange">
|
||||
<div class="flex-1">
|
||||
<Show when={props.title}>
|
||||
|
|
@ -107,9 +108,7 @@ export function Modal(props: ModalProps): JSX.Element {
|
|||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="p-4">
|
||||
{props.children}
|
||||
</div>
|
||||
<div class="p-4">{props.children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
|
@ -124,6 +123,7 @@ function CloseIcon(): JSX.Element {
|
|||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M2 2L14 14M14 2L2 14"
|
||||
|
|
|
|||
|
|
@ -2,25 +2,24 @@
|
|||
* 8-bit styled skeleton loading component
|
||||
*/
|
||||
|
||||
import { For } from "solid-js"
|
||||
import type { JSX } from "solid-js"
|
||||
import type { SkeletonProps } from "../../types"
|
||||
import type { JSX } from 'solid-js'
|
||||
import { For } from 'solid-js'
|
||||
import type { SkeletonProps } from '../../types'
|
||||
|
||||
type SkeletonVariant = "text" | "circular" | "rectangular"
|
||||
type SkeletonVariant = 'text' | 'circular' | 'rectangular'
|
||||
|
||||
export function Skeleton(props: SkeletonProps): JSX.Element {
|
||||
const variant = (): SkeletonVariant => props.variant ?? "text"
|
||||
const variant = (): SkeletonVariant => props.variant ?? 'text'
|
||||
const lines = (): number => props.lines ?? 1
|
||||
|
||||
const getVariantClasses = (): string => {
|
||||
switch (variant()) {
|
||||
case "circular":
|
||||
return "rounded-none aspect-square"
|
||||
case "rectangular":
|
||||
return ""
|
||||
case "text":
|
||||
case 'circular':
|
||||
return 'rounded-none aspect-square'
|
||||
case 'rectangular':
|
||||
return ''
|
||||
default:
|
||||
return "h-4"
|
||||
return 'h-4'
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -39,7 +38,7 @@ export function Skeleton(props: SkeletonProps): JSX.Element {
|
|||
}
|
||||
|
||||
return (
|
||||
<div class={`flex flex-col gap-2 ${props.class ?? ""}`}>
|
||||
<div class={`flex flex-col gap-2 ${props.class ?? ''}`}>
|
||||
<For each={Array(lines()).fill(0)}>
|
||||
{(_, index) => (
|
||||
<div
|
||||
|
|
@ -47,7 +46,7 @@ export function Skeleton(props: SkeletonProps): JSX.Element {
|
|||
bg-dark-gray
|
||||
animate-pixel-pulse
|
||||
${getVariantClasses()}
|
||||
${index() === lines() - 1 && variant() === "text" ? "w-3/4" : "w-full"}
|
||||
${index() === lines() - 1 && variant() === 'text' ? 'w-3/4' : 'w-full'}
|
||||
`}
|
||||
style={getStyle()}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -2,28 +2,27 @@
|
|||
* 8-bit styled spinner component
|
||||
*/
|
||||
|
||||
import type { JSX } from "solid-js"
|
||||
import type { SpinnerProps, Size } from "../../types"
|
||||
import type { JSX } from 'solid-js'
|
||||
import type { Size, SpinnerProps } from '../../types'
|
||||
|
||||
const SIZE_CLASSES: Record<Size, string> = {
|
||||
xs: "w-3 h-3",
|
||||
sm: "w-4 h-4",
|
||||
md: "w-6 h-6",
|
||||
lg: "w-8 h-8",
|
||||
xl: "w-12 h-12",
|
||||
xs: 'w-3 h-3',
|
||||
sm: 'w-4 h-4',
|
||||
md: 'w-6 h-6',
|
||||
lg: 'w-8 h-8',
|
||||
xl: 'w-12 h-12',
|
||||
}
|
||||
|
||||
export function Spinner(props: SpinnerProps): JSX.Element {
|
||||
const size = (): Size => props.size ?? "md"
|
||||
const size = (): Size => props.size ?? 'md'
|
||||
|
||||
return (
|
||||
<div
|
||||
<output
|
||||
class={`
|
||||
inline-block animate-pixel-spin
|
||||
${SIZE_CLASSES[size()]}
|
||||
${props.class ?? ""}
|
||||
${props.class ?? ''}
|
||||
`}
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
>
|
||||
<svg
|
||||
|
|
@ -31,17 +30,74 @@ export function Spinner(props: SpinnerProps): JSX.Element {
|
|||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-full h-full"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="6" y="0" width="4" height="4" fill="currentColor" class="text-orange" />
|
||||
<rect x="10" y="2" width="4" height="4" fill="currentColor" class="text-orange/80" />
|
||||
<rect x="12" y="6" width="4" height="4" fill="currentColor" class="text-orange/60" />
|
||||
<rect x="10" y="10" width="4" height="4" fill="currentColor" class="text-orange/40" />
|
||||
<rect x="6" y="12" width="4" height="4" fill="currentColor" class="text-orange/30" />
|
||||
<rect x="2" y="10" width="4" height="4" fill="currentColor" class="text-orange/20" />
|
||||
<rect x="0" y="6" width="4" height="4" fill="currentColor" class="text-orange/10" />
|
||||
<rect x="2" y="2" width="4" height="4" fill="currentColor" class="text-orange/90" />
|
||||
<rect
|
||||
x="6"
|
||||
y="0"
|
||||
width="4"
|
||||
height="4"
|
||||
fill="currentColor"
|
||||
class="text-orange"
|
||||
/>
|
||||
<rect
|
||||
x="10"
|
||||
y="2"
|
||||
width="4"
|
||||
height="4"
|
||||
fill="currentColor"
|
||||
class="text-orange/80"
|
||||
/>
|
||||
<rect
|
||||
x="12"
|
||||
y="6"
|
||||
width="4"
|
||||
height="4"
|
||||
fill="currentColor"
|
||||
class="text-orange/60"
|
||||
/>
|
||||
<rect
|
||||
x="10"
|
||||
y="10"
|
||||
width="4"
|
||||
height="4"
|
||||
fill="currentColor"
|
||||
class="text-orange/40"
|
||||
/>
|
||||
<rect
|
||||
x="6"
|
||||
y="12"
|
||||
width="4"
|
||||
height="4"
|
||||
fill="currentColor"
|
||||
class="text-orange/30"
|
||||
/>
|
||||
<rect
|
||||
x="2"
|
||||
y="10"
|
||||
width="4"
|
||||
height="4"
|
||||
fill="currentColor"
|
||||
class="text-orange/20"
|
||||
/>
|
||||
<rect
|
||||
x="0"
|
||||
y="6"
|
||||
width="4"
|
||||
height="4"
|
||||
fill="currentColor"
|
||||
class="text-orange/10"
|
||||
/>
|
||||
<rect
|
||||
x="2"
|
||||
y="2"
|
||||
width="4"
|
||||
height="4"
|
||||
fill="currentColor"
|
||||
class="text-orange/90"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</output>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,23 +2,23 @@
|
|||
* 8-bit styled textarea component
|
||||
*/
|
||||
|
||||
import { Show, splitProps, createSignal, createEffect, onMount } from "solid-js"
|
||||
import type { JSX } from "solid-js"
|
||||
import type { TextAreaProps } from "../../types"
|
||||
import type { JSX } from 'solid-js'
|
||||
import { createEffect, createSignal, onMount, Show, splitProps } from 'solid-js'
|
||||
import type { TextAreaProps } from '../../types'
|
||||
|
||||
export function TextArea(props: TextAreaProps): JSX.Element {
|
||||
const [local, rest] = splitProps(props, [
|
||||
"name",
|
||||
"placeholder",
|
||||
"value",
|
||||
"onInput",
|
||||
"disabled",
|
||||
"error",
|
||||
"label",
|
||||
"rows",
|
||||
"maxLength",
|
||||
"autoResize",
|
||||
"class",
|
||||
'name',
|
||||
'placeholder',
|
||||
'value',
|
||||
'onInput',
|
||||
'disabled',
|
||||
'error',
|
||||
'label',
|
||||
'rows',
|
||||
'maxLength',
|
||||
'autoResize',
|
||||
'class',
|
||||
])
|
||||
|
||||
const [focused, setFocused] = createSignal(false)
|
||||
|
|
@ -36,7 +36,7 @@ export function TextArea(props: TextAreaProps): JSX.Element {
|
|||
|
||||
const adjustHeight = (): void => {
|
||||
if (textareaRef !== undefined) {
|
||||
textareaRef.style.height = "auto"
|
||||
textareaRef.style.height = 'auto'
|
||||
textareaRef.style.height = `${textareaRef.scrollHeight}px`
|
||||
}
|
||||
}
|
||||
|
|
@ -48,14 +48,18 @@ export function TextArea(props: TextAreaProps): JSX.Element {
|
|||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (local.autoResize === true && local.value !== undefined && textareaRef !== undefined) {
|
||||
if (
|
||||
local.autoResize === true &&
|
||||
local.value !== undefined &&
|
||||
textareaRef !== undefined
|
||||
) {
|
||||
adjustHeight()
|
||||
}
|
||||
})
|
||||
|
||||
const hasError = (): boolean => Boolean(local.error)
|
||||
const isDisabled = (): boolean => local.disabled ?? false
|
||||
const currentLength = (): number => (local.value ?? "").length
|
||||
const currentLength = (): number => (local.value ?? '').length
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-1 w-full">
|
||||
|
|
@ -73,9 +77,9 @@ export function TextArea(props: TextAreaProps): JSX.Element {
|
|||
relative
|
||||
bg-black border-2
|
||||
transition-all duration-100
|
||||
${focused() ? "border-orange shadow-[0_0_0_2px_var(--color-orange)]" : "border-dark-gray"}
|
||||
${hasError() ? "border-error shadow-[0_0_0_2px_var(--color-error)]" : ""}
|
||||
${isDisabled() ? "opacity-50 cursor-not-allowed" : ""}
|
||||
${focused() ? 'border-orange shadow-[0_0_0_2px_var(--color-orange)]' : 'border-dark-gray'}
|
||||
${hasError() ? 'border-error shadow-[0_0_0_2px_var(--color-error)]' : ''}
|
||||
${isDisabled() ? 'opacity-50 cursor-not-allowed' : ''}
|
||||
`}
|
||||
>
|
||||
<textarea
|
||||
|
|
@ -83,7 +87,7 @@ export function TextArea(props: TextAreaProps): JSX.Element {
|
|||
name={local.name}
|
||||
id={local.name}
|
||||
placeholder={local.placeholder}
|
||||
value={local.value ?? ""}
|
||||
value={local.value ?? ''}
|
||||
disabled={isDisabled()}
|
||||
rows={local.rows ?? 3}
|
||||
maxLength={local.maxLength}
|
||||
|
|
@ -97,8 +101,8 @@ export function TextArea(props: TextAreaProps): JSX.Element {
|
|||
focus:outline-none
|
||||
disabled:cursor-not-allowed
|
||||
resize-none
|
||||
${local.autoResize === true ? "overflow-hidden" : ""}
|
||||
${local.class ?? ""}
|
||||
${local.autoResize === true ? 'overflow-hidden' : ''}
|
||||
${local.class ?? ''}
|
||||
`}
|
||||
{...rest}
|
||||
/>
|
||||
|
|
@ -116,8 +120,8 @@ export function TextArea(props: TextAreaProps): JSX.Element {
|
|||
<span
|
||||
class={`font-pixel text-[8px] ${
|
||||
currentLength() > (local.maxLength ?? 0) * 0.9
|
||||
? "text-error"
|
||||
: "text-gray"
|
||||
? 'text-error'
|
||||
: 'text-gray'
|
||||
}`}
|
||||
>
|
||||
{currentLength()}/{local.maxLength}
|
||||
|
|
|
|||
|
|
@ -2,20 +2,20 @@
|
|||
* 8-bit styled toast notification component
|
||||
*/
|
||||
|
||||
import { For, Show } from "solid-js"
|
||||
import type { JSX } from "solid-js"
|
||||
import type { ToastProps } from "../../types"
|
||||
import { $toasts, dismissToast } from "../../stores"
|
||||
import { useStore } from "@nanostores/solid"
|
||||
import { IconButton } from "./IconButton"
|
||||
import { useStore } from '@nanostores/solid'
|
||||
import type { JSX } from 'solid-js'
|
||||
import { For, Show } from 'solid-js'
|
||||
import { $toasts, dismissToast } from '../../stores'
|
||||
import type { ToastProps } from '../../types'
|
||||
import { IconButton } from './IconButton'
|
||||
|
||||
type ToastVariant = "info" | "success" | "warning" | "error"
|
||||
type ToastVariant = 'info' | 'success' | 'warning' | 'error'
|
||||
|
||||
const VARIANT_CLASSES: Record<ToastVariant, string> = {
|
||||
info: "border-info text-info",
|
||||
success: "border-success text-success",
|
||||
warning: "border-orange text-orange",
|
||||
error: "border-error text-error",
|
||||
info: 'border-info text-info',
|
||||
success: 'border-success text-success',
|
||||
warning: 'border-orange text-orange',
|
||||
error: 'border-error text-error',
|
||||
}
|
||||
|
||||
const VARIANT_ICONS: Record<ToastVariant, () => JSX.Element> = {
|
||||
|
|
@ -41,22 +41,17 @@ export function Toast(props: ToastProps): JSX.Element {
|
|||
`}
|
||||
role="alert"
|
||||
>
|
||||
<span class="flex-shrink-0 mt-0.5">
|
||||
{VARIANT_ICONS[props.variant]()}
|
||||
</span>
|
||||
<span class="flex-shrink-0 mt-0.5">{VARIANT_ICONS[props.variant]()}</span>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-pixel text-[10px] text-white uppercase">
|
||||
{props.title}
|
||||
</p>
|
||||
<p class="font-pixel text-[10px] text-white uppercase">{props.title}</p>
|
||||
<Show when={props.description}>
|
||||
<p class="font-pixel text-[8px] text-gray mt-1">
|
||||
{props.description}
|
||||
</p>
|
||||
<p class="font-pixel text-[8px] text-gray mt-1">{props.description}</p>
|
||||
</Show>
|
||||
<Show when={props.action} keyed>
|
||||
{(action) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => action.onClick()}
|
||||
class="font-pixel text-[8px] text-orange hover:underline mt-2"
|
||||
>
|
||||
|
|
@ -82,16 +77,20 @@ export function ToastContainer(): JSX.Element {
|
|||
|
||||
return (
|
||||
<div class="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-sm">
|
||||
<For each={toasts()}>
|
||||
{(toast) => <Toast {...toast} />}
|
||||
</For>
|
||||
<For each={toasts()}>{(toast) => <Toast {...toast} />}</For>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InfoIcon(): JSX.Element {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="7" y="3" width="2" height="2" />
|
||||
<rect x="7" y="7" width="2" height="6" />
|
||||
</svg>
|
||||
|
|
@ -100,7 +99,13 @@ function InfoIcon(): JSX.Element {
|
|||
|
||||
function CheckIcon(): JSX.Element {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="3" y="8" width="2" height="2" />
|
||||
<rect x="5" y="10" width="2" height="2" />
|
||||
<rect x="7" y="8" width="2" height="2" />
|
||||
|
|
@ -112,7 +117,13 @@ function CheckIcon(): JSX.Element {
|
|||
|
||||
function WarningIcon(): JSX.Element {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="7" y="2" width="2" height="8" />
|
||||
<rect x="7" y="12" width="2" height="2" />
|
||||
</svg>
|
||||
|
|
@ -121,7 +132,13 @@ function WarningIcon(): JSX.Element {
|
|||
|
||||
function ErrorIcon(): JSX.Element {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="3" y="3" width="2" height="2" />
|
||||
<rect x="5" y="5" width="2" height="2" />
|
||||
<rect x="7" y="7" width="2" height="2" />
|
||||
|
|
@ -137,7 +154,13 @@ function ErrorIcon(): JSX.Element {
|
|||
|
||||
function CloseIcon(): JSX.Element {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="2" y="2" width="2" height="2" />
|
||||
<rect x="4" y="4" width="2" height="2" />
|
||||
<rect x="6" y="4" width="2" height="2" />
|
||||
|
|
|
|||
|
|
@ -2,24 +2,26 @@
|
|||
* 8-bit styled tooltip component
|
||||
*/
|
||||
|
||||
import { Show, createSignal, onCleanup } from "solid-js"
|
||||
import type { JSX } from "solid-js"
|
||||
import type { TooltipProps } from "../../types"
|
||||
import type { JSX } from 'solid-js'
|
||||
import { createSignal, onCleanup, Show } from 'solid-js'
|
||||
import type { TooltipProps } from '../../types'
|
||||
|
||||
type TooltipPosition = "top" | "bottom" | "left" | "right"
|
||||
type TooltipPosition = 'top' | 'bottom' | 'left' | 'right'
|
||||
|
||||
const POSITION_CLASSES: Record<TooltipPosition, string> = {
|
||||
top: "bottom-full left-1/2 -translate-x-1/2 mb-2",
|
||||
bottom: "top-full left-1/2 -translate-x-1/2 mt-2",
|
||||
left: "right-full top-1/2 -translate-y-1/2 mr-2",
|
||||
right: "left-full top-1/2 -translate-y-1/2 ml-2",
|
||||
top: 'bottom-full left-1/2 -translate-x-1/2 mb-2',
|
||||
bottom: 'top-full left-1/2 -translate-x-1/2 mt-2',
|
||||
left: 'right-full top-1/2 -translate-y-1/2 mr-2',
|
||||
right: 'left-full top-1/2 -translate-y-1/2 ml-2',
|
||||
}
|
||||
|
||||
const ARROW_CLASSES: Record<TooltipPosition, string> = {
|
||||
top: "top-full left-1/2 -translate-x-1/2 border-t-orange border-x-transparent border-b-transparent",
|
||||
bottom: "bottom-full left-1/2 -translate-x-1/2 border-b-orange border-x-transparent border-t-transparent",
|
||||
left: "left-full top-1/2 -translate-y-1/2 border-l-orange border-y-transparent border-r-transparent",
|
||||
right: "right-full top-1/2 -translate-y-1/2 border-r-orange border-y-transparent border-l-transparent",
|
||||
top: 'top-full left-1/2 -translate-x-1/2 border-t-orange border-x-transparent border-b-transparent',
|
||||
bottom:
|
||||
'bottom-full left-1/2 -translate-x-1/2 border-b-orange border-x-transparent border-t-transparent',
|
||||
left: 'left-full top-1/2 -translate-y-1/2 border-l-orange border-y-transparent border-r-transparent',
|
||||
right:
|
||||
'right-full top-1/2 -translate-y-1/2 border-r-orange border-y-transparent border-l-transparent',
|
||||
}
|
||||
|
||||
const DEFAULT_DELAY_MS = 300
|
||||
|
|
@ -28,7 +30,7 @@ export function Tooltip(props: TooltipProps): JSX.Element {
|
|||
const [visible, setVisible] = createSignal(false)
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const position = (): TooltipPosition => props.position ?? "top"
|
||||
const position = (): TooltipPosition => props.position ?? 'top'
|
||||
const delay = (): number => props.delay ?? DEFAULT_DELAY_MS
|
||||
|
||||
const showTooltip = (): void => {
|
||||
|
|
@ -51,9 +53,8 @@ export function Tooltip(props: TooltipProps): JSX.Element {
|
|||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
<span
|
||||
class="relative inline-block"
|
||||
role="presentation"
|
||||
onMouseEnter={showTooltip}
|
||||
onMouseLeave={hideTooltip}
|
||||
onFocus={showTooltip}
|
||||
|
|
@ -91,6 +92,6 @@ export function Tooltip(props: TooltipProps): JSX.Element {
|
|||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,15 +2,21 @@
|
|||
// © AngelaMos | 2025
|
||||
// index.ts
|
||||
// ===================
|
||||
export { Button } from "./Button"
|
||||
export { Input } from "./Input"
|
||||
export { TextArea } from "./TextArea"
|
||||
export { Avatar } from "./Avatar"
|
||||
export { Badge } from "./Badge"
|
||||
export { Modal } from "./Modal"
|
||||
export { Spinner, LoadingOverlay } from "./Spinner"
|
||||
export { Toast, ToastContainer } from "./Toast"
|
||||
export { Skeleton, MessageSkeleton, ConversationSkeleton, AvatarSkeleton } from "./Skeleton"
|
||||
export { Tooltip } from "./Tooltip"
|
||||
export { Dropdown } from "./Dropdown"
|
||||
export { IconButton } from "./IconButton"
|
||||
|
||||
export { Avatar } from './Avatar'
|
||||
export { Badge } from './Badge'
|
||||
export { Button } from './Button'
|
||||
export { Dropdown } from './Dropdown'
|
||||
export { IconButton } from './IconButton'
|
||||
export { Input } from './Input'
|
||||
export { Modal } from './Modal'
|
||||
export {
|
||||
AvatarSkeleton,
|
||||
ConversationSkeleton,
|
||||
MessageSkeleton,
|
||||
Skeleton,
|
||||
} from './Skeleton'
|
||||
export { LoadingOverlay, Spinner } from './Spinner'
|
||||
export { TextArea } from './TextArea'
|
||||
export { Toast, ToastContainer } from './Toast'
|
||||
export { Tooltip } from './Tooltip'
|
||||
|
|
|
|||
|
|
@ -2,21 +2,32 @@
|
|||
// © AngelaMos | 2025
|
||||
// config.ts
|
||||
// ===================
|
||||
const envApiUrl: string | undefined = import.meta.env.VITE_API_URL as string | undefined;
|
||||
const envWsUrl: string | undefined = import.meta.env.VITE_WS_URL as string | undefined;
|
||||
const envRpId: string | undefined = import.meta.env.VITE_RP_ID as string | undefined;
|
||||
const envApiUrl: string | undefined = import.meta.env.VITE_API_URL as
|
||||
| string
|
||||
| undefined
|
||||
const envWsUrl: string | undefined = import.meta.env.VITE_WS_URL as
|
||||
| string
|
||||
| undefined
|
||||
const envRpId: string | undefined = import.meta.env.VITE_RP_ID as
|
||||
| string
|
||||
| undefined
|
||||
|
||||
export const API_URL = envApiUrl !== undefined && envApiUrl !== "" ? envApiUrl : "http://localhost:8000";
|
||||
export const WS_URL = envWsUrl !== undefined && envWsUrl !== "" ? envWsUrl : "ws://localhost:8000";
|
||||
export const RP_ID = envRpId !== undefined && envRpId !== "" ? envRpId : "localhost";
|
||||
export const API_URL =
|
||||
envApiUrl !== undefined && envApiUrl !== ''
|
||||
? envApiUrl
|
||||
: 'http://localhost:8000'
|
||||
export const WS_URL =
|
||||
envWsUrl !== undefined && envWsUrl !== '' ? envWsUrl : 'ws://localhost:8000'
|
||||
export const RP_ID =
|
||||
envRpId !== undefined && envRpId !== '' ? envRpId : 'localhost'
|
||||
|
||||
export const WS_HEARTBEAT_INTERVAL = 30000;
|
||||
export const WS_RECONNECT_DELAY = 5000;
|
||||
export const MESSAGE_MAX_LENGTH = 50000;
|
||||
export const USERNAME_MIN_LENGTH = 3;
|
||||
export const USERNAME_MAX_LENGTH = 50;
|
||||
export const DISPLAY_NAME_MIN_LENGTH = 1;
|
||||
export const DISPLAY_NAME_MAX_LENGTH = 100;
|
||||
export const WS_HEARTBEAT_INTERVAL = 30000
|
||||
export const WS_RECONNECT_DELAY = 5000
|
||||
export const MESSAGE_MAX_LENGTH = 50000
|
||||
export const USERNAME_MIN_LENGTH = 3
|
||||
export const USERNAME_MAX_LENGTH = 50
|
||||
export const DISPLAY_NAME_MIN_LENGTH = 1
|
||||
export const DISPLAY_NAME_MAX_LENGTH = 100
|
||||
|
||||
export const USER_SEARCH_MIN_LENGTH = 2;
|
||||
export const USER_SEARCH_DEFAULT_LIMIT = 10;
|
||||
export const USER_SEARCH_MIN_LENGTH = 2
|
||||
export const USER_SEARCH_DEFAULT_LIMIT = 10
|
||||
|
|
|
|||
|
|
@ -2,53 +2,54 @@
|
|||
// © AngelaMos | 2025
|
||||
// crypto-service.ts
|
||||
// ===================
|
||||
|
||||
import { api } from '../lib/api-client'
|
||||
import type {
|
||||
IdentityKeyPair,
|
||||
SignedPreKey,
|
||||
OneTimePreKey,
|
||||
DoubleRatchetState,
|
||||
EncryptedMessage,
|
||||
X3DHHeader,
|
||||
FullMessageHeader,
|
||||
IdentityKeyPair,
|
||||
MessageHeader,
|
||||
} from "../types"
|
||||
import { DEFAULT_ONE_TIME_PREKEY_COUNT } from "../types"
|
||||
OneTimePreKey,
|
||||
SignedPreKey,
|
||||
X3DHHeader,
|
||||
} from '../types'
|
||||
import { DEFAULT_ONE_TIME_PREKEY_COUNT } from '../types'
|
||||
import {
|
||||
generateIdentityKeyPair,
|
||||
generateSignedPreKey,
|
||||
generateOneTimePreKeys,
|
||||
initiateX3DH,
|
||||
receiveX3DH,
|
||||
} from "./x3dh"
|
||||
import {
|
||||
initializeRatchetSender,
|
||||
initializeRatchetReceiver,
|
||||
encryptMessage,
|
||||
decryptMessage,
|
||||
serializeRatchetState,
|
||||
deserializeRatchetState,
|
||||
} from "./double-ratchet"
|
||||
encryptMessage,
|
||||
initializeRatchetReceiver,
|
||||
initializeRatchetSender,
|
||||
serializeRatchetState,
|
||||
} from './double-ratchet'
|
||||
import {
|
||||
saveIdentityKey,
|
||||
getIdentityKey,
|
||||
saveSignedPreKey,
|
||||
getLatestSignedPreKey,
|
||||
saveOneTimePreKeys,
|
||||
getUnusedOneTimePreKeys,
|
||||
getOneTimePreKeyByPublicKey,
|
||||
markOneTimePreKeyUsed,
|
||||
saveRatchetState,
|
||||
getRatchetState,
|
||||
deleteRatchetState,
|
||||
clearAllKeys,
|
||||
} from "./key-store"
|
||||
import { api } from "../lib/api-client"
|
||||
deleteRatchetState,
|
||||
getIdentityKey,
|
||||
getLatestSignedPreKey,
|
||||
getOneTimePreKeyByPublicKey,
|
||||
getRatchetState,
|
||||
getUnusedOneTimePreKeys,
|
||||
markOneTimePreKeyUsed,
|
||||
saveIdentityKey,
|
||||
saveOneTimePreKeys,
|
||||
saveRatchetState,
|
||||
saveSignedPreKey,
|
||||
} from './key-store'
|
||||
import {
|
||||
base64ToBytes,
|
||||
bytesToBase64,
|
||||
importX25519PrivateKey,
|
||||
importX25519PublicKey,
|
||||
} from "./primitives"
|
||||
} from './primitives'
|
||||
import {
|
||||
generateIdentityKeyPair,
|
||||
generateOneTimePreKeys,
|
||||
generateSignedPreKey,
|
||||
initiateX3DH,
|
||||
receiveX3DH,
|
||||
} from './x3dh'
|
||||
|
||||
class CryptoService {
|
||||
private userId: string | null = null
|
||||
|
|
@ -72,7 +73,10 @@ class CryptoService {
|
|||
|
||||
this.signedPreKey = await getLatestSignedPreKey(userId)
|
||||
|
||||
if (this.signedPreKey === null || this.isSignedPreKeyExpired(this.signedPreKey)) {
|
||||
if (
|
||||
this.signedPreKey === null ||
|
||||
this.isSignedPreKeyExpired(this.signedPreKey)
|
||||
) {
|
||||
await this.rotateSignedPreKey()
|
||||
}
|
||||
|
||||
|
|
@ -89,40 +93,49 @@ class CryptoService {
|
|||
}
|
||||
|
||||
private async generateAndStoreKeys(): Promise<void> {
|
||||
if (!this.userId) throw new Error("User ID not set")
|
||||
if (!this.userId) throw new Error('User ID not set')
|
||||
|
||||
this.identityKeyPair = await generateIdentityKeyPair()
|
||||
await saveIdentityKey(this.userId, this.identityKeyPair)
|
||||
|
||||
this.signedPreKey = await generateSignedPreKey(this.identityKeyPair.ed25519_private)
|
||||
this.signedPreKey = await generateSignedPreKey(
|
||||
this.identityKeyPair.ed25519_private
|
||||
)
|
||||
await saveSignedPreKey(this.userId, this.signedPreKey)
|
||||
|
||||
const oneTimePreKeys = await generateOneTimePreKeys(DEFAULT_ONE_TIME_PREKEY_COUNT)
|
||||
const oneTimePreKeys = await generateOneTimePreKeys(
|
||||
DEFAULT_ONE_TIME_PREKEY_COUNT
|
||||
)
|
||||
await saveOneTimePreKeys(this.userId, oneTimePreKeys)
|
||||
|
||||
await this.uploadPublicKeys(oneTimePreKeys)
|
||||
}
|
||||
|
||||
private async rotateSignedPreKey(): Promise<void> {
|
||||
if (this.userId === null || this.identityKeyPair === null) throw new Error("Not initialized")
|
||||
if (this.userId === null || this.identityKeyPair === null)
|
||||
throw new Error('Not initialized')
|
||||
|
||||
this.signedPreKey = await generateSignedPreKey(this.identityKeyPair.ed25519_private)
|
||||
this.signedPreKey = await generateSignedPreKey(
|
||||
this.identityKeyPair.ed25519_private
|
||||
)
|
||||
await saveSignedPreKey(this.userId, this.signedPreKey)
|
||||
|
||||
await api.encryption.rotateSignedPrekey(this.userId)
|
||||
}
|
||||
|
||||
private async replenishOneTimePreKeys(): Promise<void> {
|
||||
if (!this.userId) throw new Error("User ID not set")
|
||||
if (!this.userId) throw new Error('User ID not set')
|
||||
|
||||
const newPreKeys = await generateOneTimePreKeys(DEFAULT_ONE_TIME_PREKEY_COUNT / 2)
|
||||
const newPreKeys = await generateOneTimePreKeys(
|
||||
DEFAULT_ONE_TIME_PREKEY_COUNT / 2
|
||||
)
|
||||
await saveOneTimePreKeys(this.userId, newPreKeys)
|
||||
}
|
||||
|
||||
private async uploadPublicKeys(oneTimePreKeys: OneTimePreKey[]): Promise<void> {
|
||||
if (!this.userId) throw new Error("User ID not set")
|
||||
if (!this.userId) throw new Error('User ID not set')
|
||||
if (!this.identityKeyPair || !this.signedPreKey) {
|
||||
throw new Error("Keys not generated")
|
||||
throw new Error('Keys not generated')
|
||||
}
|
||||
|
||||
await api.encryption.uploadKeys(this.userId, {
|
||||
|
|
@ -135,7 +148,8 @@ class CryptoService {
|
|||
}
|
||||
|
||||
async establishSession(peerId: string): Promise<void> {
|
||||
if (this.identityKeyPair === null) throw new Error("Identity keys not initialized")
|
||||
if (this.identityKeyPair === null)
|
||||
throw new Error('Identity keys not initialized')
|
||||
|
||||
const existingState = await this.getRatchetState(peerId)
|
||||
if (existingState !== null) return
|
||||
|
|
@ -157,7 +171,9 @@ class CryptoService {
|
|||
const x3dhHeader: X3DHHeader = {
|
||||
identity_key: this.identityKeyPair.x25519_public,
|
||||
ephemeral_key: x3dhResult.ephemeral_public_key,
|
||||
one_time_prekey_id: x3dhResult.used_one_time_prekey ? peerBundle.one_time_prekey : null,
|
||||
one_time_prekey_id: x3dhResult.used_one_time_prekey
|
||||
? peerBundle.one_time_prekey
|
||||
: null,
|
||||
}
|
||||
this.pendingX3DHHeaders.set(peerId, x3dhHeader)
|
||||
|
||||
|
|
@ -172,7 +188,7 @@ class CryptoService {
|
|||
oneTimePreKeyPublic: string | null
|
||||
): Promise<void> {
|
||||
if (this.identityKeyPair === null || this.signedPreKey === null) {
|
||||
throw new Error("Keys not initialized")
|
||||
throw new Error('Keys not initialized')
|
||||
}
|
||||
|
||||
let oneTimePreKey: OneTimePreKey | null = null
|
||||
|
|
@ -214,7 +230,10 @@ class CryptoService {
|
|||
await saveRatchetState(serialized)
|
||||
}
|
||||
|
||||
async encrypt(peerId: string, plaintext: string): Promise<{
|
||||
async encrypt(
|
||||
peerId: string,
|
||||
plaintext: string
|
||||
): Promise<{
|
||||
ciphertext: string
|
||||
nonce: string
|
||||
header: string
|
||||
|
|
@ -261,7 +280,7 @@ class CryptoService {
|
|||
let ratchetHeader: MessageHeader
|
||||
let x3dhHeader: X3DHHeader | undefined
|
||||
|
||||
if ("ratchet" in parsedHeader) {
|
||||
if ('ratchet' in parsedHeader) {
|
||||
ratchetHeader = parsedHeader.ratchet
|
||||
x3dhHeader = parsedHeader.x3dh
|
||||
} else {
|
||||
|
|
@ -276,7 +295,7 @@ class CryptoService {
|
|||
|
||||
if (state === null) {
|
||||
if (!x3dhHeader) {
|
||||
throw new Error("Cannot establish session: missing X3DH header")
|
||||
throw new Error('Cannot establish session: missing X3DH header')
|
||||
}
|
||||
|
||||
await this.handleIncomingSession(
|
||||
|
|
@ -289,7 +308,7 @@ class CryptoService {
|
|||
}
|
||||
|
||||
if (state === null) {
|
||||
throw new Error("Failed to establish session")
|
||||
throw new Error('Failed to establish session')
|
||||
}
|
||||
|
||||
const plaintextBytes = await decryptMessage(state, encryptedMessage)
|
||||
|
|
@ -300,7 +319,9 @@ class CryptoService {
|
|||
return new TextDecoder().decode(plaintextBytes)
|
||||
}
|
||||
|
||||
private async getRatchetState(peerId: string): Promise<DoubleRatchetState | null> {
|
||||
private async getRatchetState(
|
||||
peerId: string
|
||||
): Promise<DoubleRatchetState | null> {
|
||||
let state = this.ratchetStates.get(peerId)
|
||||
|
||||
if (state === undefined) {
|
||||
|
|
@ -337,18 +358,19 @@ class CryptoService {
|
|||
this.ratchetStates.clear()
|
||||
this.pendingX3DHHeaders.clear()
|
||||
|
||||
const database = indexedDB.open("encrypted-chat-keys", 1)
|
||||
const database = indexedDB.open('encrypted-chat-keys', 1)
|
||||
database.onsuccess = () => {
|
||||
const db = database.result
|
||||
const tx = db.transaction("ratchet_states", "readwrite")
|
||||
tx.objectStore("ratchet_states").clear()
|
||||
const tx = db.transaction('ratchet_states', 'readwrite')
|
||||
tx.objectStore('ratchet_states').clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const cryptoService = new CryptoService()
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
(window as unknown as { resetCryptoSessions: () => Promise<void> }).resetCryptoSessions =
|
||||
() => cryptoService.resetAllSessions()
|
||||
if (typeof window !== 'undefined') {
|
||||
;(
|
||||
window as unknown as { resetCryptoSessions: () => Promise<void> }
|
||||
).resetCryptoSessions = () => cryptoService.resetAllSessions()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,32 +4,35 @@
|
|||
// ===================
|
||||
import type {
|
||||
DoubleRatchetState,
|
||||
SerializedRatchetState,
|
||||
EncryptedMessage,
|
||||
MessageHeader,
|
||||
} from "../types"
|
||||
import { MAX_SKIP_MESSAGE_KEYS } from "../types"
|
||||
SerializedRatchetState,
|
||||
} from '../types'
|
||||
import { MAX_SKIP_MESSAGE_KEYS } from '../types'
|
||||
import {
|
||||
generateX25519KeyPair,
|
||||
x25519DeriveSharedSecret,
|
||||
importX25519PublicKey,
|
||||
importX25519PrivateKey,
|
||||
exportPublicKey,
|
||||
exportPrivateKey,
|
||||
hkdfDerive,
|
||||
aesGcmEncrypt,
|
||||
aesGcmDecrypt,
|
||||
hmacSha256,
|
||||
concatBytes,
|
||||
bytesToBase64,
|
||||
aesGcmEncrypt,
|
||||
base64ToBytes,
|
||||
} from "./primitives"
|
||||
bytesToBase64,
|
||||
concatBytes,
|
||||
exportPrivateKey,
|
||||
exportPublicKey,
|
||||
generateX25519KeyPair,
|
||||
hkdfDerive,
|
||||
hmacSha256,
|
||||
importX25519PrivateKey,
|
||||
importX25519PublicKey,
|
||||
x25519DeriveSharedSecret,
|
||||
} from './primitives'
|
||||
|
||||
const RATCHET_INFO = new TextEncoder().encode("DoubleRatchet")
|
||||
const MESSAGE_KEY_INFO = new TextEncoder().encode("MessageKey")
|
||||
const CHAIN_KEY_INFO = new TextEncoder().encode("ChainKey")
|
||||
const RATCHET_INFO = new TextEncoder().encode('DoubleRatchet')
|
||||
const MESSAGE_KEY_INFO = new TextEncoder().encode('MessageKey')
|
||||
const CHAIN_KEY_INFO = new TextEncoder().encode('ChainKey')
|
||||
|
||||
function createSkippedKeyId(dhPublicKey: Uint8Array, messageNumber: number): string {
|
||||
function createSkippedKeyId(
|
||||
dhPublicKey: Uint8Array,
|
||||
messageNumber: number
|
||||
): string {
|
||||
return `${bytesToBase64(dhPublicKey)}:${messageNumber}`
|
||||
}
|
||||
|
||||
|
|
@ -45,7 +48,12 @@ export async function initializeRatchetSender(
|
|||
const dhOutput = await x25519DeriveSharedSecret(dhKeyPair.privateKey, peerKey)
|
||||
|
||||
const rootChainInput = concatBytes(sharedKey, dhOutput)
|
||||
const derivedKeys = await hkdfDerive(rootChainInput, new Uint8Array(32), RATCHET_INFO, 64)
|
||||
const derivedKeys = await hkdfDerive(
|
||||
rootChainInput,
|
||||
new Uint8Array(32),
|
||||
RATCHET_INFO,
|
||||
64
|
||||
)
|
||||
|
||||
const rootKey = derivedKeys.slice(0, 32)
|
||||
const sendingChainKey = derivedKeys.slice(32, 64)
|
||||
|
|
@ -105,7 +113,7 @@ async function performDHRatchet(
|
|||
peerPublicKey: Uint8Array
|
||||
): Promise<void> {
|
||||
if (state.dh_private_key === null) {
|
||||
throw new Error("DH private key not initialized")
|
||||
throw new Error('DH private key not initialized')
|
||||
}
|
||||
|
||||
state.previous_sending_chain_length = state.sending_message_number
|
||||
|
|
@ -114,10 +122,18 @@ async function performDHRatchet(
|
|||
state.dh_peer_public_key = peerPublicKey
|
||||
|
||||
const peerKey = await importX25519PublicKey(peerPublicKey)
|
||||
const dhOutput = await x25519DeriveSharedSecret(state.dh_private_key.privateKey, peerKey)
|
||||
const dhOutput = await x25519DeriveSharedSecret(
|
||||
state.dh_private_key.privateKey,
|
||||
peerKey
|
||||
)
|
||||
|
||||
const rootChainInput = concatBytes(state.root_key, dhOutput)
|
||||
const derivedKeys = await hkdfDerive(rootChainInput, new Uint8Array(32), RATCHET_INFO, 64)
|
||||
const derivedKeys = await hkdfDerive(
|
||||
rootChainInput,
|
||||
new Uint8Array(32),
|
||||
RATCHET_INFO,
|
||||
64
|
||||
)
|
||||
|
||||
state.root_key = derivedKeys.slice(0, 32)
|
||||
state.receiving_chain_key = derivedKeys.slice(32, 64)
|
||||
|
|
@ -126,9 +142,17 @@ async function performDHRatchet(
|
|||
state.dh_private_key = newDHKeyPair
|
||||
state.dh_public_key = await exportPublicKey(newDHKeyPair.publicKey)
|
||||
|
||||
const newDHOutput = await x25519DeriveSharedSecret(newDHKeyPair.privateKey, peerKey)
|
||||
const newDHOutput = await x25519DeriveSharedSecret(
|
||||
newDHKeyPair.privateKey,
|
||||
peerKey
|
||||
)
|
||||
const newRootChainInput = concatBytes(state.root_key, newDHOutput)
|
||||
const newDerivedKeys = await hkdfDerive(newRootChainInput, new Uint8Array(32), RATCHET_INFO, 64)
|
||||
const newDerivedKeys = await hkdfDerive(
|
||||
newRootChainInput,
|
||||
new Uint8Array(32),
|
||||
RATCHET_INFO,
|
||||
64
|
||||
)
|
||||
|
||||
state.root_key = newDerivedKeys.slice(0, 32)
|
||||
state.sending_chain_key = newDerivedKeys.slice(32, 64)
|
||||
|
|
@ -138,16 +162,22 @@ async function skipMessageKeys(
|
|||
state: DoubleRatchetState,
|
||||
until: number
|
||||
): Promise<void> {
|
||||
if (state.receiving_chain_key === null || state.dh_peer_public_key === null) return
|
||||
if (state.receiving_chain_key === null || state.dh_peer_public_key === null)
|
||||
return
|
||||
|
||||
if (until - state.receiving_message_number > MAX_SKIP_MESSAGE_KEYS) {
|
||||
throw new Error("Too many skipped messages")
|
||||
throw new Error('Too many skipped messages')
|
||||
}
|
||||
|
||||
while (state.receiving_message_number < until) {
|
||||
const { messageKey, nextChainKey } = await deriveMessageKey(state.receiving_chain_key)
|
||||
const { messageKey, nextChainKey } = await deriveMessageKey(
|
||||
state.receiving_chain_key
|
||||
)
|
||||
|
||||
const keyId = createSkippedKeyId(state.dh_peer_public_key, state.receiving_message_number)
|
||||
const keyId = createSkippedKeyId(
|
||||
state.dh_peer_public_key,
|
||||
state.receiving_message_number
|
||||
)
|
||||
state.skipped_message_keys.set(keyId, messageKey)
|
||||
|
||||
state.receiving_chain_key = nextChainKey
|
||||
|
|
@ -161,10 +191,12 @@ export async function encryptMessage(
|
|||
associatedData?: Uint8Array
|
||||
): Promise<EncryptedMessage> {
|
||||
if (state.dh_public_key === null) {
|
||||
throw new Error("DH public key not initialized")
|
||||
throw new Error('DH public key not initialized')
|
||||
}
|
||||
|
||||
const { messageKey, nextChainKey } = await deriveMessageKey(state.sending_chain_key)
|
||||
const { messageKey, nextChainKey } = await deriveMessageKey(
|
||||
state.sending_chain_key
|
||||
)
|
||||
|
||||
const header: MessageHeader = {
|
||||
dh_public_key: bytesToBase64(state.dh_public_key),
|
||||
|
|
@ -173,7 +205,10 @@ export async function encryptMessage(
|
|||
}
|
||||
|
||||
const headerBytes = new TextEncoder().encode(JSON.stringify(header))
|
||||
const aad = associatedData !== undefined ? concatBytes(associatedData, headerBytes) : headerBytes
|
||||
const aad =
|
||||
associatedData !== undefined
|
||||
? concatBytes(associatedData, headerBytes)
|
||||
: headerBytes
|
||||
|
||||
const { ciphertext, nonce } = await aesGcmEncrypt(messageKey, plaintext, aad)
|
||||
|
||||
|
|
@ -194,14 +229,20 @@ export async function decryptMessage(
|
|||
): Promise<Uint8Array> {
|
||||
const peerPublicKey = base64ToBytes(message.header.dh_public_key)
|
||||
|
||||
const skippedKeyId = createSkippedKeyId(peerPublicKey, message.header.message_number)
|
||||
const skippedKeyId = createSkippedKeyId(
|
||||
peerPublicKey,
|
||||
message.header.message_number
|
||||
)
|
||||
const skippedKey = state.skipped_message_keys.get(skippedKeyId)
|
||||
|
||||
if (skippedKey !== undefined) {
|
||||
state.skipped_message_keys.delete(skippedKeyId)
|
||||
|
||||
const headerBytes = new TextEncoder().encode(JSON.stringify(message.header))
|
||||
const aad = associatedData !== undefined ? concatBytes(associatedData, headerBytes) : headerBytes
|
||||
const aad =
|
||||
associatedData !== undefined
|
||||
? concatBytes(associatedData, headerBytes)
|
||||
: headerBytes
|
||||
|
||||
return await aesGcmDecrypt(skippedKey, message.ciphertext, message.nonce, aad)
|
||||
}
|
||||
|
|
@ -221,15 +262,25 @@ export async function decryptMessage(
|
|||
await skipMessageKeys(state, message.header.message_number)
|
||||
|
||||
if (state.receiving_chain_key === null) {
|
||||
throw new Error("Receiving chain key not initialized after ratchet")
|
||||
throw new Error('Receiving chain key not initialized after ratchet')
|
||||
}
|
||||
|
||||
const { messageKey, nextChainKey } = await deriveMessageKey(state.receiving_chain_key)
|
||||
const { messageKey, nextChainKey } = await deriveMessageKey(
|
||||
state.receiving_chain_key
|
||||
)
|
||||
|
||||
const headerBytes = new TextEncoder().encode(JSON.stringify(message.header))
|
||||
const aad = associatedData !== undefined ? concatBytes(associatedData, headerBytes) : headerBytes
|
||||
const aad =
|
||||
associatedData !== undefined
|
||||
? concatBytes(associatedData, headerBytes)
|
||||
: headerBytes
|
||||
|
||||
const plaintext = await aesGcmDecrypt(messageKey, message.ciphertext, message.nonce, aad)
|
||||
const plaintext = await aesGcmDecrypt(
|
||||
messageKey,
|
||||
message.ciphertext,
|
||||
message.nonce,
|
||||
aad
|
||||
)
|
||||
|
||||
state.receiving_chain_key = nextChainKey
|
||||
state.receiving_message_number++
|
||||
|
|
@ -242,7 +293,9 @@ export async function serializeRatchetState(
|
|||
): Promise<SerializedRatchetState> {
|
||||
let dhPrivateKey: string | null = null
|
||||
if (state.dh_private_key !== null) {
|
||||
const privateKeyBytes = await exportPrivateKey(state.dh_private_key.privateKey)
|
||||
const privateKeyBytes = await exportPrivateKey(
|
||||
state.dh_private_key.privateKey
|
||||
)
|
||||
dhPrivateKey = bytesToBase64(privateKeyBytes)
|
||||
}
|
||||
|
||||
|
|
@ -255,14 +308,17 @@ export async function serializeRatchetState(
|
|||
peer_id: state.peer_id,
|
||||
root_key: bytesToBase64(state.root_key),
|
||||
sending_chain_key: bytesToBase64(state.sending_chain_key),
|
||||
receiving_chain_key: state.receiving_chain_key !== null
|
||||
? bytesToBase64(state.receiving_chain_key)
|
||||
: null,
|
||||
receiving_chain_key:
|
||||
state.receiving_chain_key !== null
|
||||
? bytesToBase64(state.receiving_chain_key)
|
||||
: null,
|
||||
dh_private_key: dhPrivateKey,
|
||||
dh_public_key: state.dh_public_key !== null ? bytesToBase64(state.dh_public_key) : null,
|
||||
dh_peer_public_key: state.dh_peer_public_key !== null
|
||||
? bytesToBase64(state.dh_peer_public_key)
|
||||
: null,
|
||||
dh_public_key:
|
||||
state.dh_public_key !== null ? bytesToBase64(state.dh_public_key) : null,
|
||||
dh_peer_public_key:
|
||||
state.dh_peer_public_key !== null
|
||||
? bytesToBase64(state.dh_peer_public_key)
|
||||
: null,
|
||||
sending_message_number: state.sending_message_number,
|
||||
receiving_message_number: state.receiving_message_number,
|
||||
previous_sending_chain_length: state.previous_sending_chain_length,
|
||||
|
|
@ -332,7 +388,9 @@ interface SerializedEncryptedMessage {
|
|||
header: MessageHeader
|
||||
}
|
||||
|
||||
export function deserializeEncryptedMessage(serialized: string): EncryptedMessage {
|
||||
export function deserializeEncryptedMessage(
|
||||
serialized: string
|
||||
): EncryptedMessage {
|
||||
const parsed = JSON.parse(serialized) as SerializedEncryptedMessage
|
||||
return {
|
||||
ciphertext: base64ToBytes(parsed.ciphertext),
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@
|
|||
// © AngelaMos | 2025
|
||||
// index.ts
|
||||
// ===================
|
||||
export * from "./primitives"
|
||||
export * from "./key-store"
|
||||
export * from "./message-store"
|
||||
export * from "./x3dh"
|
||||
export * from "./double-ratchet"
|
||||
export * from "./crypto-service"
|
||||
|
||||
export * from './crypto-service'
|
||||
export * from './double-ratchet'
|
||||
export * from './key-store'
|
||||
export * from './message-store'
|
||||
export * from './primitives'
|
||||
export * from './x3dh'
|
||||
|
|
|
|||
|
|
@ -4,19 +4,19 @@
|
|||
// ===================
|
||||
import type {
|
||||
IdentityKeyPair,
|
||||
SignedPreKey,
|
||||
OneTimePreKey,
|
||||
SerializedRatchetState,
|
||||
} from "../types"
|
||||
SignedPreKey,
|
||||
} from '../types'
|
||||
|
||||
const DB_NAME = "encrypted-chat-keys"
|
||||
const DB_NAME = 'encrypted-chat-keys'
|
||||
const DB_VERSION = 1
|
||||
|
||||
const STORES = {
|
||||
IDENTITY: "identity_keys",
|
||||
SIGNED_PREKEYS: "signed_prekeys",
|
||||
ONE_TIME_PREKEYS: "one_time_prekeys",
|
||||
RATCHET_STATES: "ratchet_states",
|
||||
IDENTITY: 'identity_keys',
|
||||
SIGNED_PREKEYS: 'signed_prekeys',
|
||||
ONE_TIME_PREKEYS: 'one_time_prekeys',
|
||||
RATCHET_STATES: 'ratchet_states',
|
||||
} as const
|
||||
|
||||
let db: IDBDatabase | null = null
|
||||
|
|
@ -28,7 +28,7 @@ async function openDatabase(): Promise<IDBDatabase> {
|
|||
const request = indexedDB.open(DB_NAME, DB_VERSION)
|
||||
|
||||
request.onerror = () => {
|
||||
reject(new Error("Failed to open key database"))
|
||||
reject(new Error('Failed to open key database'))
|
||||
}
|
||||
|
||||
request.onsuccess = () => {
|
||||
|
|
@ -40,22 +40,26 @@ async function openDatabase(): Promise<IDBDatabase> {
|
|||
const database = (event.target as IDBOpenDBRequest).result
|
||||
|
||||
if (!database.objectStoreNames.contains(STORES.IDENTITY)) {
|
||||
database.createObjectStore(STORES.IDENTITY, { keyPath: "userId" })
|
||||
database.createObjectStore(STORES.IDENTITY, { keyPath: 'userId' })
|
||||
}
|
||||
|
||||
if (!database.objectStoreNames.contains(STORES.SIGNED_PREKEYS)) {
|
||||
const signedStore = database.createObjectStore(STORES.SIGNED_PREKEYS, { keyPath: "id" })
|
||||
signedStore.createIndex("userId", "userId", { unique: false })
|
||||
const signedStore = database.createObjectStore(STORES.SIGNED_PREKEYS, {
|
||||
keyPath: 'id',
|
||||
})
|
||||
signedStore.createIndex('userId', 'userId', { unique: false })
|
||||
}
|
||||
|
||||
if (!database.objectStoreNames.contains(STORES.ONE_TIME_PREKEYS)) {
|
||||
const otpStore = database.createObjectStore(STORES.ONE_TIME_PREKEYS, { keyPath: "id" })
|
||||
otpStore.createIndex("userId", "userId", { unique: false })
|
||||
otpStore.createIndex("isUsed", "is_used", { unique: false })
|
||||
const otpStore = database.createObjectStore(STORES.ONE_TIME_PREKEYS, {
|
||||
keyPath: 'id',
|
||||
})
|
||||
otpStore.createIndex('userId', 'userId', { unique: false })
|
||||
otpStore.createIndex('isUsed', 'is_used', { unique: false })
|
||||
}
|
||||
|
||||
if (!database.objectStoreNames.contains(STORES.RATCHET_STATES)) {
|
||||
database.createObjectStore(STORES.RATCHET_STATES, { keyPath: "peer_id" })
|
||||
database.createObjectStore(STORES.RATCHET_STATES, { keyPath: 'peer_id' })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -74,7 +78,8 @@ async function performTransaction<T>(
|
|||
const request = operation(store)
|
||||
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
request.onerror = () => reject(new Error(request.error?.message ?? "Database operation failed"))
|
||||
request.onerror = () =>
|
||||
reject(new Error(request.error?.message ?? 'Database operation failed'))
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -91,17 +96,17 @@ export async function saveIdentityKey(
|
|||
...keyPair,
|
||||
}
|
||||
|
||||
await performTransaction(
|
||||
STORES.IDENTITY,
|
||||
"readwrite",
|
||||
(store) => store.put(stored)
|
||||
await performTransaction(STORES.IDENTITY, 'readwrite', (store) =>
|
||||
store.put(stored)
|
||||
)
|
||||
}
|
||||
|
||||
export async function getIdentityKey(userId: string): Promise<IdentityKeyPair | null> {
|
||||
export async function getIdentityKey(
|
||||
userId: string
|
||||
): Promise<IdentityKeyPair | null> {
|
||||
const result = await performTransaction<StoredIdentityKey | undefined>(
|
||||
STORES.IDENTITY,
|
||||
"readonly",
|
||||
'readonly',
|
||||
(store) => store.get(userId) as IDBRequest<StoredIdentityKey | undefined>
|
||||
)
|
||||
|
||||
|
|
@ -116,10 +121,8 @@ export async function getIdentityKey(userId: string): Promise<IdentityKeyPair |
|
|||
}
|
||||
|
||||
export async function deleteIdentityKey(userId: string): Promise<void> {
|
||||
await performTransaction(
|
||||
STORES.IDENTITY,
|
||||
"readwrite",
|
||||
(store) => store.delete(userId)
|
||||
await performTransaction(STORES.IDENTITY, 'readwrite', (store) =>
|
||||
store.delete(userId)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -136,17 +139,15 @@ export async function saveSignedPreKey(
|
|||
...preKey,
|
||||
}
|
||||
|
||||
await performTransaction(
|
||||
STORES.SIGNED_PREKEYS,
|
||||
"readwrite",
|
||||
(store) => store.put(stored)
|
||||
await performTransaction(STORES.SIGNED_PREKEYS, 'readwrite', (store) =>
|
||||
store.put(stored)
|
||||
)
|
||||
}
|
||||
|
||||
export async function getSignedPreKey(id: string): Promise<SignedPreKey | null> {
|
||||
const result = await performTransaction<StoredSignedPreKey | undefined>(
|
||||
STORES.SIGNED_PREKEYS,
|
||||
"readonly",
|
||||
'readonly',
|
||||
(store) => store.get(id) as IDBRequest<StoredSignedPreKey | undefined>
|
||||
)
|
||||
|
||||
|
|
@ -162,13 +163,15 @@ export async function getSignedPreKey(id: string): Promise<SignedPreKey | null>
|
|||
}
|
||||
}
|
||||
|
||||
export async function getLatestSignedPreKey(userId: string): Promise<SignedPreKey | null> {
|
||||
export async function getLatestSignedPreKey(
|
||||
userId: string
|
||||
): Promise<SignedPreKey | null> {
|
||||
const database = await openDatabase()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = database.transaction(STORES.SIGNED_PREKEYS, "readonly")
|
||||
const transaction = database.transaction(STORES.SIGNED_PREKEYS, 'readonly')
|
||||
const store = transaction.objectStore(STORES.SIGNED_PREKEYS)
|
||||
const index = store.index("userId")
|
||||
const index = store.index('userId')
|
||||
const request = index.getAll(userId)
|
||||
|
||||
request.onsuccess = () => {
|
||||
|
|
@ -179,7 +182,8 @@ export async function getLatestSignedPreKey(userId: string): Promise<SignedPreKe
|
|||
}
|
||||
|
||||
const sorted = results.sort(
|
||||
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
(a, b) =>
|
||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
)
|
||||
|
||||
const latest = sorted[0]
|
||||
|
|
@ -193,15 +197,14 @@ export async function getLatestSignedPreKey(userId: string): Promise<SignedPreKe
|
|||
})
|
||||
}
|
||||
|
||||
request.onerror = () => reject(new Error(request.error?.message ?? "Failed to get signed prekey"))
|
||||
request.onerror = () =>
|
||||
reject(new Error(request.error?.message ?? 'Failed to get signed prekey'))
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteSignedPreKey(id: string): Promise<void> {
|
||||
await performTransaction(
|
||||
STORES.SIGNED_PREKEYS,
|
||||
"readwrite",
|
||||
(store) => store.delete(id)
|
||||
await performTransaction(STORES.SIGNED_PREKEYS, 'readwrite', (store) =>
|
||||
store.delete(id)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -218,10 +221,8 @@ export async function saveOneTimePreKey(
|
|||
...preKey,
|
||||
}
|
||||
|
||||
await performTransaction(
|
||||
STORES.ONE_TIME_PREKEYS,
|
||||
"readwrite",
|
||||
(store) => store.put(stored)
|
||||
await performTransaction(STORES.ONE_TIME_PREKEYS, 'readwrite', (store) =>
|
||||
store.put(stored)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -232,11 +233,14 @@ export async function saveOneTimePreKeys(
|
|||
const database = await openDatabase()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = database.transaction(STORES.ONE_TIME_PREKEYS, "readwrite")
|
||||
const transaction = database.transaction(STORES.ONE_TIME_PREKEYS, 'readwrite')
|
||||
const store = transaction.objectStore(STORES.ONE_TIME_PREKEYS)
|
||||
|
||||
transaction.oncomplete = () => resolve()
|
||||
transaction.onerror = () => reject(new Error(transaction.error?.message ?? "Failed to save one-time prekeys"))
|
||||
transaction.onerror = () =>
|
||||
reject(
|
||||
new Error(transaction.error?.message ?? 'Failed to save one-time prekeys')
|
||||
)
|
||||
|
||||
for (const preKey of preKeys) {
|
||||
const stored: StoredOneTimePreKey = {
|
||||
|
|
@ -248,10 +252,12 @@ export async function saveOneTimePreKeys(
|
|||
})
|
||||
}
|
||||
|
||||
export async function getOneTimePreKey(id: string): Promise<OneTimePreKey | null> {
|
||||
export async function getOneTimePreKey(
|
||||
id: string
|
||||
): Promise<OneTimePreKey | null> {
|
||||
const result = await performTransaction<StoredOneTimePreKey | undefined>(
|
||||
STORES.ONE_TIME_PREKEYS,
|
||||
"readonly",
|
||||
'readonly',
|
||||
(store) => store.get(id) as IDBRequest<StoredOneTimePreKey | undefined>
|
||||
)
|
||||
|
||||
|
|
@ -266,11 +272,13 @@ export async function getOneTimePreKey(id: string): Promise<OneTimePreKey | null
|
|||
}
|
||||
}
|
||||
|
||||
export async function getOneTimePreKeyByPublicKey(publicKey: string): Promise<OneTimePreKey | null> {
|
||||
export async function getOneTimePreKeyByPublicKey(
|
||||
publicKey: string
|
||||
): Promise<OneTimePreKey | null> {
|
||||
const database = await openDatabase()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = database.transaction(STORES.ONE_TIME_PREKEYS, "readonly")
|
||||
const transaction = database.transaction(STORES.ONE_TIME_PREKEYS, 'readonly')
|
||||
const store = transaction.objectStore(STORES.ONE_TIME_PREKEYS)
|
||||
const request = store.getAll()
|
||||
|
||||
|
|
@ -292,17 +300,22 @@ export async function getOneTimePreKeyByPublicKey(publicKey: string): Promise<On
|
|||
})
|
||||
}
|
||||
|
||||
request.onerror = () => reject(new Error(request.error?.message ?? "Failed to find one-time prekey"))
|
||||
request.onerror = () =>
|
||||
reject(
|
||||
new Error(request.error?.message ?? 'Failed to find one-time prekey')
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export async function getUnusedOneTimePreKeys(userId: string): Promise<OneTimePreKey[]> {
|
||||
export async function getUnusedOneTimePreKeys(
|
||||
userId: string
|
||||
): Promise<OneTimePreKey[]> {
|
||||
const database = await openDatabase()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = database.transaction(STORES.ONE_TIME_PREKEYS, "readonly")
|
||||
const transaction = database.transaction(STORES.ONE_TIME_PREKEYS, 'readonly')
|
||||
const store = transaction.objectStore(STORES.ONE_TIME_PREKEYS)
|
||||
const index = store.index("userId")
|
||||
const index = store.index('userId')
|
||||
const request = index.getAll(userId)
|
||||
|
||||
request.onsuccess = () => {
|
||||
|
|
@ -320,14 +333,19 @@ export async function getUnusedOneTimePreKeys(userId: string): Promise<OneTimePr
|
|||
resolve(unused)
|
||||
}
|
||||
|
||||
request.onerror = () => reject(new Error(request.error?.message ?? "Failed to get unused one-time prekeys"))
|
||||
request.onerror = () =>
|
||||
reject(
|
||||
new Error(
|
||||
request.error?.message ?? 'Failed to get unused one-time prekeys'
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export async function markOneTimePreKeyUsed(id: string): Promise<void> {
|
||||
const preKey = await performTransaction<StoredOneTimePreKey | undefined>(
|
||||
STORES.ONE_TIME_PREKEYS,
|
||||
"readonly",
|
||||
'readonly',
|
||||
(store) => store.get(id) as IDBRequest<StoredOneTimePreKey | undefined>
|
||||
)
|
||||
|
||||
|
|
@ -335,33 +353,31 @@ export async function markOneTimePreKeyUsed(id: string): Promise<void> {
|
|||
|
||||
preKey.is_used = true
|
||||
|
||||
await performTransaction(
|
||||
STORES.ONE_TIME_PREKEYS,
|
||||
"readwrite",
|
||||
(store) => store.put(preKey)
|
||||
await performTransaction(STORES.ONE_TIME_PREKEYS, 'readwrite', (store) =>
|
||||
store.put(preKey)
|
||||
)
|
||||
}
|
||||
|
||||
export async function deleteOneTimePreKey(id: string): Promise<void> {
|
||||
await performTransaction(
|
||||
STORES.ONE_TIME_PREKEYS,
|
||||
"readwrite",
|
||||
(store) => store.delete(id)
|
||||
await performTransaction(STORES.ONE_TIME_PREKEYS, 'readwrite', (store) =>
|
||||
store.delete(id)
|
||||
)
|
||||
}
|
||||
|
||||
export async function saveRatchetState(state: SerializedRatchetState): Promise<void> {
|
||||
await performTransaction(
|
||||
STORES.RATCHET_STATES,
|
||||
"readwrite",
|
||||
(store) => store.put(state)
|
||||
export async function saveRatchetState(
|
||||
state: SerializedRatchetState
|
||||
): Promise<void> {
|
||||
await performTransaction(STORES.RATCHET_STATES, 'readwrite', (store) =>
|
||||
store.put(state)
|
||||
)
|
||||
}
|
||||
|
||||
export async function getRatchetState(peerId: string): Promise<SerializedRatchetState | null> {
|
||||
export async function getRatchetState(
|
||||
peerId: string
|
||||
): Promise<SerializedRatchetState | null> {
|
||||
const result = await performTransaction<SerializedRatchetState | undefined>(
|
||||
STORES.RATCHET_STATES,
|
||||
"readonly",
|
||||
'readonly',
|
||||
(store) => store.get(peerId) as IDBRequest<SerializedRatchetState | undefined>
|
||||
)
|
||||
|
||||
|
|
@ -369,10 +385,8 @@ export async function getRatchetState(peerId: string): Promise<SerializedRatchet
|
|||
}
|
||||
|
||||
export async function deleteRatchetState(peerId: string): Promise<void> {
|
||||
await performTransaction(
|
||||
STORES.RATCHET_STATES,
|
||||
"readwrite",
|
||||
(store) => store.delete(peerId)
|
||||
await performTransaction(STORES.RATCHET_STATES, 'readwrite', (store) =>
|
||||
store.delete(peerId)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -380,12 +394,15 @@ export async function getAllRatchetStates(): Promise<SerializedRatchetState[]> {
|
|||
const database = await openDatabase()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = database.transaction(STORES.RATCHET_STATES, "readonly")
|
||||
const transaction = database.transaction(STORES.RATCHET_STATES, 'readonly')
|
||||
const store = transaction.objectStore(STORES.RATCHET_STATES)
|
||||
const request = store.getAll()
|
||||
|
||||
request.onsuccess = () => resolve(request.result as SerializedRatchetState[])
|
||||
request.onerror = () => reject(new Error(request.error?.message ?? "Failed to get all ratchet states"))
|
||||
request.onerror = () =>
|
||||
reject(
|
||||
new Error(request.error?.message ?? 'Failed to get all ratchet states')
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -400,10 +417,11 @@ export async function clearAllKeys(): Promise<void> {
|
|||
STORES.RATCHET_STATES,
|
||||
]
|
||||
|
||||
const transaction = database.transaction(storeNames, "readwrite")
|
||||
const transaction = database.transaction(storeNames, 'readwrite')
|
||||
|
||||
transaction.oncomplete = () => resolve()
|
||||
transaction.onerror = () => reject(new Error(transaction.error?.message ?? "Failed to clear all keys"))
|
||||
transaction.onerror = () =>
|
||||
reject(new Error(transaction.error?.message ?? 'Failed to clear all keys'))
|
||||
|
||||
for (const storeName of storeNames) {
|
||||
transaction.objectStore(storeName).clear()
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@
|
|||
// © AngelaMos | 2025
|
||||
// message-store.ts
|
||||
// ===================
|
||||
import type { Message } from "../types"
|
||||
import type { Message } from '../types'
|
||||
|
||||
const DB_NAME = "encrypted-chat-messages"
|
||||
const DB_NAME = 'encrypted-chat-messages'
|
||||
const DB_VERSION = 1
|
||||
|
||||
const STORES = {
|
||||
MESSAGES: "decrypted_messages",
|
||||
MESSAGES: 'decrypted_messages',
|
||||
} as const
|
||||
|
||||
let db: IDBDatabase | null = null
|
||||
|
|
@ -20,7 +20,7 @@ async function openDatabase(): Promise<IDBDatabase> {
|
|||
const request = indexedDB.open(DB_NAME, DB_VERSION)
|
||||
|
||||
request.onerror = () => {
|
||||
reject(new Error("Failed to open message database"))
|
||||
reject(new Error('Failed to open message database'))
|
||||
}
|
||||
|
||||
request.onsuccess = () => {
|
||||
|
|
@ -32,10 +32,14 @@ async function openDatabase(): Promise<IDBDatabase> {
|
|||
const database = (event.target as IDBOpenDBRequest).result
|
||||
|
||||
if (!database.objectStoreNames.contains(STORES.MESSAGES)) {
|
||||
const messageStore = database.createObjectStore(STORES.MESSAGES, { keyPath: "id" })
|
||||
messageStore.createIndex("room_id", "room_id", { unique: false })
|
||||
messageStore.createIndex("created_at", "created_at", { unique: false })
|
||||
messageStore.createIndex("room_created", ["room_id", "created_at"], { unique: false })
|
||||
const messageStore = database.createObjectStore(STORES.MESSAGES, {
|
||||
keyPath: 'id',
|
||||
})
|
||||
messageStore.createIndex('room_id', 'room_id', { unique: false })
|
||||
messageStore.createIndex('created_at', 'created_at', { unique: false })
|
||||
messageStore.createIndex('room_created', ['room_id', 'created_at'], {
|
||||
unique: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -54,15 +58,14 @@ async function performTransaction<T>(
|
|||
const request = operation(store)
|
||||
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
request.onerror = () => reject(new Error(request.error?.message ?? "Database operation failed"))
|
||||
request.onerror = () =>
|
||||
reject(new Error(request.error?.message ?? 'Database operation failed'))
|
||||
})
|
||||
}
|
||||
|
||||
export async function saveDecryptedMessage(message: Message): Promise<void> {
|
||||
await performTransaction(
|
||||
STORES.MESSAGES,
|
||||
"readwrite",
|
||||
(store) => store.put(message)
|
||||
await performTransaction(STORES.MESSAGES, 'readwrite', (store) =>
|
||||
store.put(message)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -70,11 +73,12 @@ export async function saveDecryptedMessages(messages: Message[]): Promise<void>
|
|||
const database = await openDatabase()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = database.transaction(STORES.MESSAGES, "readwrite")
|
||||
const transaction = database.transaction(STORES.MESSAGES, 'readwrite')
|
||||
const store = transaction.objectStore(STORES.MESSAGES)
|
||||
|
||||
transaction.oncomplete = () => resolve()
|
||||
transaction.onerror = () => reject(new Error(transaction.error?.message ?? "Failed to save messages"))
|
||||
transaction.onerror = () =>
|
||||
reject(new Error(transaction.error?.message ?? 'Failed to save messages'))
|
||||
|
||||
for (const message of messages) {
|
||||
store.put(message)
|
||||
|
|
@ -82,29 +86,35 @@ export async function saveDecryptedMessages(messages: Message[]): Promise<void>
|
|||
})
|
||||
}
|
||||
|
||||
export async function getDecryptedMessage(messageId: string): Promise<Message | null> {
|
||||
export async function getDecryptedMessage(
|
||||
messageId: string
|
||||
): Promise<Message | null> {
|
||||
const result = await performTransaction<Message | undefined>(
|
||||
STORES.MESSAGES,
|
||||
"readonly",
|
||||
'readonly',
|
||||
(store) => store.get(messageId) as IDBRequest<Message | undefined>
|
||||
)
|
||||
|
||||
return result ?? null
|
||||
}
|
||||
|
||||
export async function getDecryptedMessages(roomId: string, limit?: number): Promise<Message[]> {
|
||||
export async function getDecryptedMessages(
|
||||
roomId: string,
|
||||
limit?: number
|
||||
): Promise<Message[]> {
|
||||
const database = await openDatabase()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = database.transaction(STORES.MESSAGES, "readonly")
|
||||
const transaction = database.transaction(STORES.MESSAGES, 'readonly')
|
||||
const store = transaction.objectStore(STORES.MESSAGES)
|
||||
const index = store.index("room_id")
|
||||
const index = store.index('room_id')
|
||||
const request = index.getAll(roomId)
|
||||
|
||||
request.onsuccess = () => {
|
||||
let results = request.result as Message[]
|
||||
results = results.sort(
|
||||
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
|
||||
(a, b) =>
|
||||
new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
|
||||
)
|
||||
|
||||
if (limit !== undefined && limit > 0) {
|
||||
|
|
@ -114,19 +124,22 @@ export async function getDecryptedMessages(roomId: string, limit?: number): Prom
|
|||
resolve(results)
|
||||
}
|
||||
|
||||
request.onerror = () => reject(new Error(request.error?.message ?? "Failed to get messages"))
|
||||
request.onerror = () =>
|
||||
reject(new Error(request.error?.message ?? 'Failed to get messages'))
|
||||
})
|
||||
}
|
||||
|
||||
export async function getLatestMessageTimestamp(roomId: string): Promise<string | null> {
|
||||
export async function getLatestMessageTimestamp(
|
||||
roomId: string
|
||||
): Promise<string | null> {
|
||||
const database = await openDatabase()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = database.transaction(STORES.MESSAGES, "readonly")
|
||||
const transaction = database.transaction(STORES.MESSAGES, 'readonly')
|
||||
const store = transaction.objectStore(STORES.MESSAGES)
|
||||
const index = store.index("room_created")
|
||||
const range = IDBKeyRange.bound([roomId, ""], [roomId, "\uffff"])
|
||||
const request = index.openCursor(range, "prev")
|
||||
const index = store.index('room_created')
|
||||
const range = IDBKeyRange.bound([roomId, ''], [roomId, '\uffff'])
|
||||
const request = index.openCursor(range, 'prev')
|
||||
|
||||
request.onsuccess = () => {
|
||||
const cursor = request.result
|
||||
|
|
@ -138,19 +151,23 @@ export async function getLatestMessageTimestamp(roomId: string): Promise<string
|
|||
}
|
||||
}
|
||||
|
||||
request.onerror = () => reject(new Error(request.error?.message ?? "Failed to get latest timestamp"))
|
||||
request.onerror = () =>
|
||||
reject(
|
||||
new Error(request.error?.message ?? 'Failed to get latest timestamp')
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteMessage(messageId: string): Promise<void> {
|
||||
await performTransaction(
|
||||
STORES.MESSAGES,
|
||||
"readwrite",
|
||||
(store) => store.delete(messageId)
|
||||
await performTransaction(STORES.MESSAGES, 'readwrite', (store) =>
|
||||
store.delete(messageId)
|
||||
)
|
||||
}
|
||||
|
||||
export async function updateMessageId(oldId: string, newId: string): Promise<void> {
|
||||
export async function updateMessageId(
|
||||
oldId: string,
|
||||
newId: string
|
||||
): Promise<void> {
|
||||
const message = await getDecryptedMessage(oldId)
|
||||
if (message) {
|
||||
await deleteMessage(oldId)
|
||||
|
|
@ -163,9 +180,9 @@ export async function clearRoomMessages(roomId: string): Promise<void> {
|
|||
const database = await openDatabase()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = database.transaction(STORES.MESSAGES, "readwrite")
|
||||
const transaction = database.transaction(STORES.MESSAGES, 'readwrite')
|
||||
const store = transaction.objectStore(STORES.MESSAGES)
|
||||
const index = store.index("room_id")
|
||||
const index = store.index('room_id')
|
||||
const request = index.openCursor(IDBKeyRange.only(roomId))
|
||||
|
||||
request.onsuccess = () => {
|
||||
|
|
@ -177,29 +194,29 @@ export async function clearRoomMessages(roomId: string): Promise<void> {
|
|||
}
|
||||
|
||||
transaction.oncomplete = () => resolve()
|
||||
transaction.onerror = () => reject(new Error(transaction.error?.message ?? "Failed to clear room messages"))
|
||||
transaction.onerror = () =>
|
||||
reject(
|
||||
new Error(transaction.error?.message ?? 'Failed to clear room messages')
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export async function clearAllMessages(): Promise<void> {
|
||||
await performTransaction(
|
||||
STORES.MESSAGES,
|
||||
"readwrite",
|
||||
(store) => store.clear()
|
||||
)
|
||||
await performTransaction(STORES.MESSAGES, 'readwrite', (store) => store.clear())
|
||||
}
|
||||
|
||||
export async function getMessageCount(roomId: string): Promise<number> {
|
||||
const database = await openDatabase()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = database.transaction(STORES.MESSAGES, "readonly")
|
||||
const transaction = database.transaction(STORES.MESSAGES, 'readonly')
|
||||
const store = transaction.objectStore(STORES.MESSAGES)
|
||||
const index = store.index("room_id")
|
||||
const index = store.index('room_id')
|
||||
const request = index.count(IDBKeyRange.only(roomId))
|
||||
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
request.onerror = () => reject(new Error(request.error?.message ?? "Failed to count messages"))
|
||||
request.onerror = () =>
|
||||
reject(new Error(request.error?.message ?? 'Failed to count messages'))
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,23 +3,23 @@
|
|||
// primitives.ts
|
||||
// ===================
|
||||
import {
|
||||
X25519_KEY_SIZE,
|
||||
AES_GCM_KEY_SIZE,
|
||||
AES_GCM_NONCE_SIZE,
|
||||
HKDF_OUTPUT_SIZE,
|
||||
} from "../types"
|
||||
X25519_KEY_SIZE,
|
||||
} from '../types'
|
||||
|
||||
const crypto = globalThis.crypto
|
||||
const subtle = crypto.subtle
|
||||
|
||||
export async function generateX25519KeyPair(): Promise<CryptoKeyPair> {
|
||||
const keyPair = await subtle.generateKey(
|
||||
const keyPair = (await subtle.generateKey(
|
||||
{
|
||||
name: "X25519",
|
||||
name: 'X25519',
|
||||
},
|
||||
true,
|
||||
["deriveBits"]
|
||||
) as CryptoKeyPair
|
||||
['deriveBits']
|
||||
)) as CryptoKeyPair
|
||||
return keyPair
|
||||
}
|
||||
|
||||
|
|
@ -29,7 +29,7 @@ export async function x25519DeriveSharedSecret(
|
|||
): Promise<Uint8Array> {
|
||||
const sharedBits = await subtle.deriveBits(
|
||||
{
|
||||
name: "X25519",
|
||||
name: 'X25519',
|
||||
public: publicKey,
|
||||
},
|
||||
privateKey,
|
||||
|
|
@ -38,61 +38,65 @@ export async function x25519DeriveSharedSecret(
|
|||
return new Uint8Array(sharedBits)
|
||||
}
|
||||
|
||||
export async function importX25519PublicKey(keyBytes: Uint8Array): Promise<CryptoKey> {
|
||||
export async function importX25519PublicKey(
|
||||
keyBytes: Uint8Array
|
||||
): Promise<CryptoKey> {
|
||||
if (keyBytes.length === 32) {
|
||||
return await subtle.importKey(
|
||||
"raw",
|
||||
'raw',
|
||||
keyBytes.buffer as ArrayBuffer,
|
||||
{ name: "X25519" },
|
||||
{ name: 'X25519' },
|
||||
true,
|
||||
[]
|
||||
)
|
||||
}
|
||||
return await subtle.importKey(
|
||||
"spki",
|
||||
'spki',
|
||||
keyBytes.buffer as ArrayBuffer,
|
||||
{ name: "X25519" },
|
||||
{ name: 'X25519' },
|
||||
true,
|
||||
[]
|
||||
)
|
||||
}
|
||||
|
||||
export async function importX25519PrivateKey(keyBytes: Uint8Array): Promise<CryptoKey> {
|
||||
export async function importX25519PrivateKey(
|
||||
keyBytes: Uint8Array
|
||||
): Promise<CryptoKey> {
|
||||
if (keyBytes.length === 32) {
|
||||
return await subtle.importKey(
|
||||
"raw",
|
||||
'raw',
|
||||
keyBytes.buffer as ArrayBuffer,
|
||||
{ name: "X25519" },
|
||||
{ name: 'X25519' },
|
||||
true,
|
||||
["deriveBits"]
|
||||
['deriveBits']
|
||||
)
|
||||
}
|
||||
return await subtle.importKey(
|
||||
"pkcs8",
|
||||
'pkcs8',
|
||||
keyBytes.buffer as ArrayBuffer,
|
||||
{ name: "X25519" },
|
||||
{ name: 'X25519' },
|
||||
true,
|
||||
["deriveBits"]
|
||||
['deriveBits']
|
||||
)
|
||||
}
|
||||
|
||||
export async function exportPublicKey(key: CryptoKey): Promise<Uint8Array> {
|
||||
const exported = await subtle.exportKey("spki", key)
|
||||
const exported = await subtle.exportKey('spki', key)
|
||||
return new Uint8Array(exported)
|
||||
}
|
||||
|
||||
export async function exportPrivateKey(key: CryptoKey): Promise<Uint8Array> {
|
||||
const exported = await subtle.exportKey("pkcs8", key)
|
||||
const exported = await subtle.exportKey('pkcs8', key)
|
||||
return new Uint8Array(exported)
|
||||
}
|
||||
|
||||
export async function generateEd25519KeyPair(): Promise<CryptoKeyPair> {
|
||||
return await subtle.generateKey(
|
||||
{
|
||||
name: "Ed25519",
|
||||
name: 'Ed25519',
|
||||
},
|
||||
true,
|
||||
["sign", "verify"]
|
||||
['sign', 'verify']
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -102,7 +106,7 @@ export async function ed25519Sign(
|
|||
): Promise<Uint8Array> {
|
||||
const signature = await subtle.sign(
|
||||
{
|
||||
name: "Ed25519",
|
||||
name: 'Ed25519',
|
||||
},
|
||||
privateKey,
|
||||
data.buffer as ArrayBuffer
|
||||
|
|
@ -117,7 +121,7 @@ export async function ed25519Verify(
|
|||
): Promise<boolean> {
|
||||
return await subtle.verify(
|
||||
{
|
||||
name: "Ed25519",
|
||||
name: 'Ed25519',
|
||||
},
|
||||
publicKey,
|
||||
signature.buffer as ArrayBuffer,
|
||||
|
|
@ -125,41 +129,45 @@ export async function ed25519Verify(
|
|||
)
|
||||
}
|
||||
|
||||
export async function importEd25519PublicKey(keyBytes: Uint8Array): Promise<CryptoKey> {
|
||||
export async function importEd25519PublicKey(
|
||||
keyBytes: Uint8Array
|
||||
): Promise<CryptoKey> {
|
||||
if (keyBytes.length === 32) {
|
||||
return await subtle.importKey(
|
||||
"raw",
|
||||
'raw',
|
||||
keyBytes.buffer as ArrayBuffer,
|
||||
{ name: "Ed25519" },
|
||||
{ name: 'Ed25519' },
|
||||
true,
|
||||
["verify"]
|
||||
['verify']
|
||||
)
|
||||
}
|
||||
return await subtle.importKey(
|
||||
"spki",
|
||||
'spki',
|
||||
keyBytes.buffer as ArrayBuffer,
|
||||
{ name: "Ed25519" },
|
||||
{ name: 'Ed25519' },
|
||||
true,
|
||||
["verify"]
|
||||
['verify']
|
||||
)
|
||||
}
|
||||
|
||||
export async function importEd25519PrivateKey(keyBytes: Uint8Array): Promise<CryptoKey> {
|
||||
export async function importEd25519PrivateKey(
|
||||
keyBytes: Uint8Array
|
||||
): Promise<CryptoKey> {
|
||||
if (keyBytes.length === 32) {
|
||||
return await subtle.importKey(
|
||||
"raw",
|
||||
'raw',
|
||||
keyBytes.buffer as ArrayBuffer,
|
||||
{ name: "Ed25519" },
|
||||
{ name: 'Ed25519' },
|
||||
true,
|
||||
["sign"]
|
||||
['sign']
|
||||
)
|
||||
}
|
||||
return await subtle.importKey(
|
||||
"pkcs8",
|
||||
'pkcs8',
|
||||
keyBytes.buffer as ArrayBuffer,
|
||||
{ name: "Ed25519" },
|
||||
{ name: 'Ed25519' },
|
||||
true,
|
||||
["sign"]
|
||||
['sign']
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -170,17 +178,17 @@ export async function hkdfDerive(
|
|||
outputLength: number = HKDF_OUTPUT_SIZE
|
||||
): Promise<Uint8Array> {
|
||||
const baseKey = await subtle.importKey(
|
||||
"raw",
|
||||
'raw',
|
||||
inputKeyMaterial.buffer as ArrayBuffer,
|
||||
{ name: "HKDF" },
|
||||
{ name: 'HKDF' },
|
||||
false,
|
||||
["deriveBits"]
|
||||
['deriveBits']
|
||||
)
|
||||
|
||||
const derivedBits = await subtle.deriveBits(
|
||||
{
|
||||
name: "HKDF",
|
||||
hash: "SHA-256",
|
||||
name: 'HKDF',
|
||||
hash: 'SHA-256',
|
||||
salt: salt.buffer as ArrayBuffer,
|
||||
info: info.buffer as ArrayBuffer,
|
||||
},
|
||||
|
|
@ -197,27 +205,27 @@ export async function hkdfDeriveKey(
|
|||
info: Uint8Array
|
||||
): Promise<CryptoKey> {
|
||||
const keyMaterial = await subtle.importKey(
|
||||
"raw",
|
||||
'raw',
|
||||
inputKeyMaterial.buffer as ArrayBuffer,
|
||||
{ name: "HKDF" },
|
||||
{ name: 'HKDF' },
|
||||
false,
|
||||
["deriveKey"]
|
||||
['deriveKey']
|
||||
)
|
||||
|
||||
return await subtle.deriveKey(
|
||||
{
|
||||
name: "HKDF",
|
||||
hash: "SHA-256",
|
||||
name: 'HKDF',
|
||||
hash: 'SHA-256',
|
||||
salt: salt.buffer as ArrayBuffer,
|
||||
info: info.buffer as ArrayBuffer,
|
||||
},
|
||||
keyMaterial,
|
||||
{
|
||||
name: "AES-GCM",
|
||||
name: 'AES-GCM',
|
||||
length: AES_GCM_KEY_SIZE * 8,
|
||||
},
|
||||
true,
|
||||
["encrypt", "decrypt"]
|
||||
['encrypt', 'decrypt']
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -230,11 +238,11 @@ export async function aesGcmEncrypt(
|
|||
|
||||
if (key instanceof Uint8Array) {
|
||||
cryptoKey = await subtle.importKey(
|
||||
"raw",
|
||||
'raw',
|
||||
key.buffer as ArrayBuffer,
|
||||
{ name: "AES-GCM", length: AES_GCM_KEY_SIZE * 8 },
|
||||
{ name: 'AES-GCM', length: AES_GCM_KEY_SIZE * 8 },
|
||||
false,
|
||||
["encrypt"]
|
||||
['encrypt']
|
||||
)
|
||||
} else {
|
||||
cryptoKey = key
|
||||
|
|
@ -244,7 +252,7 @@ export async function aesGcmEncrypt(
|
|||
|
||||
const ciphertext = await subtle.encrypt(
|
||||
{
|
||||
name: "AES-GCM",
|
||||
name: 'AES-GCM',
|
||||
iv: nonce.buffer as ArrayBuffer,
|
||||
additionalData: associatedData?.buffer as ArrayBuffer | undefined,
|
||||
},
|
||||
|
|
@ -268,11 +276,11 @@ export async function aesGcmDecrypt(
|
|||
|
||||
if (key instanceof Uint8Array) {
|
||||
cryptoKey = await subtle.importKey(
|
||||
"raw",
|
||||
'raw',
|
||||
key.buffer as ArrayBuffer,
|
||||
{ name: "AES-GCM", length: AES_GCM_KEY_SIZE * 8 },
|
||||
{ name: 'AES-GCM', length: AES_GCM_KEY_SIZE * 8 },
|
||||
false,
|
||||
["decrypt"]
|
||||
['decrypt']
|
||||
)
|
||||
} else {
|
||||
cryptoKey = key
|
||||
|
|
@ -280,7 +288,7 @@ export async function aesGcmDecrypt(
|
|||
|
||||
const plaintext = await subtle.decrypt(
|
||||
{
|
||||
name: "AES-GCM",
|
||||
name: 'AES-GCM',
|
||||
iv: nonce.buffer as ArrayBuffer,
|
||||
additionalData: associatedData?.buffer as ArrayBuffer | undefined,
|
||||
},
|
||||
|
|
@ -298,12 +306,12 @@ export function generateRandomBytes(length: number): Uint8Array {
|
|||
}
|
||||
|
||||
export async function sha256(data: Uint8Array): Promise<Uint8Array> {
|
||||
const hash = await subtle.digest("SHA-256", data.buffer as ArrayBuffer)
|
||||
const hash = await subtle.digest('SHA-256', data.buffer as ArrayBuffer)
|
||||
return new Uint8Array(hash)
|
||||
}
|
||||
|
||||
export async function sha512(data: Uint8Array): Promise<Uint8Array> {
|
||||
const hash = await subtle.digest("SHA-512", data.buffer as ArrayBuffer)
|
||||
const hash = await subtle.digest('SHA-512', data.buffer as ArrayBuffer)
|
||||
return new Uint8Array(hash)
|
||||
}
|
||||
|
||||
|
|
@ -312,17 +320,21 @@ export async function hmacSha256(
|
|||
data: Uint8Array
|
||||
): Promise<Uint8Array> {
|
||||
const cryptoKey = await subtle.importKey(
|
||||
"raw",
|
||||
'raw',
|
||||
key.buffer as ArrayBuffer,
|
||||
{
|
||||
name: "HMAC",
|
||||
hash: "SHA-256",
|
||||
name: 'HMAC',
|
||||
hash: 'SHA-256',
|
||||
},
|
||||
false,
|
||||
["sign"]
|
||||
['sign']
|
||||
)
|
||||
|
||||
const signature = await subtle.sign("HMAC", cryptoKey, data.buffer as ArrayBuffer)
|
||||
const signature = await subtle.sign(
|
||||
'HMAC',
|
||||
cryptoKey,
|
||||
data.buffer as ArrayBuffer
|
||||
)
|
||||
return new Uint8Array(signature)
|
||||
}
|
||||
|
||||
|
|
@ -332,17 +344,22 @@ export async function hmacSha256Verify(
|
|||
data: Uint8Array
|
||||
): Promise<boolean> {
|
||||
const cryptoKey = await subtle.importKey(
|
||||
"raw",
|
||||
'raw',
|
||||
key.buffer as ArrayBuffer,
|
||||
{
|
||||
name: "HMAC",
|
||||
hash: "SHA-256",
|
||||
name: 'HMAC',
|
||||
hash: 'SHA-256',
|
||||
},
|
||||
false,
|
||||
["verify"]
|
||||
['verify']
|
||||
)
|
||||
|
||||
return await subtle.verify("HMAC", cryptoKey, signature.buffer as ArrayBuffer, data.buffer as ArrayBuffer)
|
||||
return await subtle.verify(
|
||||
'HMAC',
|
||||
cryptoKey,
|
||||
signature.buffer as ArrayBuffer,
|
||||
data.buffer as ArrayBuffer
|
||||
)
|
||||
}
|
||||
|
||||
export function concatBytes(...arrays: Uint8Array[]): Uint8Array {
|
||||
|
|
@ -373,8 +390,8 @@ export function base64ToBytes(base64: string): Uint8Array {
|
|||
|
||||
export function bytesToHex(bytes: Uint8Array): string {
|
||||
return Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("")
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
}
|
||||
|
||||
export function hexToBytes(hex: string): Uint8Array {
|
||||
|
|
|
|||
|
|
@ -3,34 +3,34 @@
|
|||
// x3dh.ts
|
||||
// ===================
|
||||
import type {
|
||||
PreKeyBundle,
|
||||
X3DHResult,
|
||||
X3DHHeader,
|
||||
IdentityKeyPair,
|
||||
SignedPreKey,
|
||||
OneTimePreKey,
|
||||
} from "../types"
|
||||
PreKeyBundle,
|
||||
SignedPreKey,
|
||||
X3DHHeader,
|
||||
X3DHResult,
|
||||
} from '../types'
|
||||
import { DEFAULT_ONE_TIME_PREKEY_COUNT, HKDF_OUTPUT_SIZE } from '../types'
|
||||
import {
|
||||
generateX25519KeyPair,
|
||||
x25519DeriveSharedSecret,
|
||||
importX25519PublicKey,
|
||||
importX25519PrivateKey,
|
||||
exportPublicKey,
|
||||
exportPrivateKey,
|
||||
generateEd25519KeyPair,
|
||||
base64ToBytes,
|
||||
bytesToBase64,
|
||||
concatBytes,
|
||||
ed25519Sign,
|
||||
ed25519Verify,
|
||||
importEd25519PublicKey,
|
||||
importEd25519PrivateKey,
|
||||
hkdfDerive,
|
||||
concatBytes,
|
||||
bytesToBase64,
|
||||
base64ToBytes,
|
||||
exportPrivateKey,
|
||||
exportPublicKey,
|
||||
generateEd25519KeyPair,
|
||||
generateRandomBytes,
|
||||
} from "./primitives"
|
||||
import { HKDF_OUTPUT_SIZE, DEFAULT_ONE_TIME_PREKEY_COUNT } from "../types"
|
||||
generateX25519KeyPair,
|
||||
hkdfDerive,
|
||||
importEd25519PrivateKey,
|
||||
importEd25519PublicKey,
|
||||
importX25519PrivateKey,
|
||||
importX25519PublicKey,
|
||||
x25519DeriveSharedSecret,
|
||||
} from './primitives'
|
||||
|
||||
const X3DH_INFO = new TextEncoder().encode("X3DH")
|
||||
const X3DH_INFO = new TextEncoder().encode('X3DH')
|
||||
const EMPTY_SALT = new Uint8Array(HKDF_OUTPUT_SIZE)
|
||||
|
||||
export async function generateIdentityKeyPair(): Promise<IdentityKeyPair> {
|
||||
|
|
@ -57,7 +57,9 @@ export async function generateSignedPreKey(
|
|||
const publicKey = await exportPublicKey(keyPair.publicKey)
|
||||
const privateKey = await exportPrivateKey(keyPair.privateKey)
|
||||
|
||||
const signingKey = await importEd25519PrivateKey(base64ToBytes(identityPrivateKey))
|
||||
const signingKey = await importEd25519PrivateKey(
|
||||
base64ToBytes(identityPrivateKey)
|
||||
)
|
||||
const signature = await ed25519Sign(signingKey, publicKey)
|
||||
|
||||
const id = bytesToBase64(generateRandomBytes(16))
|
||||
|
|
@ -102,7 +104,9 @@ export async function verifySignedPreKey(
|
|||
signature: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const verifyKey = await importEd25519PublicKey(base64ToBytes(identityPublicKey))
|
||||
const verifyKey = await importEd25519PublicKey(
|
||||
base64ToBytes(identityPublicKey)
|
||||
)
|
||||
const publicKeyBytes = base64ToBytes(signedPreKeyPublic)
|
||||
const signatureBytes = base64ToBytes(signature)
|
||||
|
||||
|
|
@ -123,7 +127,7 @@ export async function initiateX3DH(
|
|||
)
|
||||
|
||||
if (!signatureValid) {
|
||||
throw new Error("Invalid signed prekey signature")
|
||||
throw new Error('Invalid signed prekey signature')
|
||||
}
|
||||
|
||||
const ephemeralKeyPair = await generateX25519KeyPair()
|
||||
|
|
@ -139,9 +143,18 @@ export async function initiateX3DH(
|
|||
base64ToBytes(recipientBundle.signed_prekey)
|
||||
)
|
||||
|
||||
const dh1 = await x25519DeriveSharedSecret(senderIdentityPrivate, recipientSignedPreKeyPublic)
|
||||
const dh2 = await x25519DeriveSharedSecret(ephemeralKeyPair.privateKey, recipientIdentityPublic)
|
||||
const dh3 = await x25519DeriveSharedSecret(ephemeralKeyPair.privateKey, recipientSignedPreKeyPublic)
|
||||
const dh1 = await x25519DeriveSharedSecret(
|
||||
senderIdentityPrivate,
|
||||
recipientSignedPreKeyPublic
|
||||
)
|
||||
const dh2 = await x25519DeriveSharedSecret(
|
||||
ephemeralKeyPair.privateKey,
|
||||
recipientIdentityPublic
|
||||
)
|
||||
const dh3 = await x25519DeriveSharedSecret(
|
||||
ephemeralKeyPair.privateKey,
|
||||
recipientSignedPreKeyPublic
|
||||
)
|
||||
|
||||
let dhResults: Uint8Array[]
|
||||
let usedOneTimePreKey = false
|
||||
|
|
@ -150,7 +163,10 @@ export async function initiateX3DH(
|
|||
const recipientOneTimePreKeyPublic = await importX25519PublicKey(
|
||||
base64ToBytes(recipientBundle.one_time_prekey)
|
||||
)
|
||||
const dh4 = await x25519DeriveSharedSecret(ephemeralKeyPair.privateKey, recipientOneTimePreKeyPublic)
|
||||
const dh4 = await x25519DeriveSharedSecret(
|
||||
ephemeralKeyPair.privateKey,
|
||||
recipientOneTimePreKeyPublic
|
||||
)
|
||||
dhResults = [dh1, dh2, dh3, dh4]
|
||||
usedOneTimePreKey = true
|
||||
} else {
|
||||
|
|
@ -192,9 +208,18 @@ export async function receiveX3DH(
|
|||
base64ToBytes(senderEphemeralKey)
|
||||
)
|
||||
|
||||
const dh1 = await x25519DeriveSharedSecret(recipientSignedPreKeyPrivate, senderIdentityPublic)
|
||||
const dh2 = await x25519DeriveSharedSecret(recipientIdentityPrivate, senderEphemeralPublic)
|
||||
const dh3 = await x25519DeriveSharedSecret(recipientSignedPreKeyPrivate, senderEphemeralPublic)
|
||||
const dh1 = await x25519DeriveSharedSecret(
|
||||
recipientSignedPreKeyPrivate,
|
||||
senderIdentityPublic
|
||||
)
|
||||
const dh2 = await x25519DeriveSharedSecret(
|
||||
recipientIdentityPrivate,
|
||||
senderEphemeralPublic
|
||||
)
|
||||
const dh3 = await x25519DeriveSharedSecret(
|
||||
recipientSignedPreKeyPrivate,
|
||||
senderEphemeralPublic
|
||||
)
|
||||
|
||||
let dhResults: Uint8Array[]
|
||||
|
||||
|
|
@ -202,7 +227,10 @@ export async function receiveX3DH(
|
|||
const recipientOneTimePreKeyPrivate = await importX25519PrivateKey(
|
||||
base64ToBytes(oneTimePreKey.private_key)
|
||||
)
|
||||
const dh4 = await x25519DeriveSharedSecret(recipientOneTimePreKeyPrivate, senderEphemeralPublic)
|
||||
const dh4 = await x25519DeriveSharedSecret(
|
||||
recipientOneTimePreKeyPrivate,
|
||||
senderEphemeralPublic
|
||||
)
|
||||
dhResults = [dh1, dh2, dh3, dh4]
|
||||
} else {
|
||||
dhResults = [dh1, dh2, dh3]
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { render } from "solid-js/web";
|
||||
import { Router } from "@solidjs/router";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query";
|
||||
import App from "./App";
|
||||
import { ToastContainer } from "./components/UI/Toast";
|
||||
import "./index.css";
|
||||
import { Router } from '@solidjs/router'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/solid-query'
|
||||
import { render } from 'solid-js/web'
|
||||
import App from './App'
|
||||
import { ToastContainer } from './components/UI/Toast'
|
||||
import './index.css'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
|
|
@ -13,12 +13,12 @@ const queryClient = new QueryClient({
|
|||
staleTime: 5 * 60 * 1000,
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
const root = document.getElementById("root");
|
||||
const root = document.getElementById('root')
|
||||
|
||||
if (root === null) {
|
||||
throw new Error("Root element not found");
|
||||
throw new Error('Root element not found')
|
||||
}
|
||||
|
||||
render(
|
||||
|
|
@ -31,4 +31,4 @@ render(
|
|||
</QueryClientProvider>
|
||||
),
|
||||
root
|
||||
);
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,36 +2,33 @@
|
|||
// © AngelaMos | 2025
|
||||
// Chat.tsx
|
||||
// ===================
|
||||
import { Show, onMount, onCleanup, createEffect } from "solid-js"
|
||||
import type { JSX } from "solid-js"
|
||||
import type { Participant } from "../types"
|
||||
import { useStore } from "@nanostores/solid"
|
||||
import { AppShell, ProtectedRoute } from "../components/Layout"
|
||||
|
||||
import { useStore } from '@nanostores/solid'
|
||||
import type { JSX } from 'solid-js'
|
||||
import { createEffect, onCleanup, onMount, Show } from 'solid-js'
|
||||
import {
|
||||
MessageList,
|
||||
ChatHeader,
|
||||
ChatInput,
|
||||
MessageList,
|
||||
NewConversation,
|
||||
} from "../components/Chat"
|
||||
} from '../components/Chat'
|
||||
import { AppShell, ProtectedRoute } from '../components/Layout'
|
||||
import { cryptoService, saveDecryptedMessage } from '../crypto'
|
||||
import { roomService } from '../services'
|
||||
import {
|
||||
$activeModal,
|
||||
$activeRoom,
|
||||
$activeRoomId,
|
||||
$activeModal,
|
||||
$userId,
|
||||
$currentUser,
|
||||
showToast,
|
||||
setActiveRoom,
|
||||
openModal,
|
||||
closeModal,
|
||||
$userId,
|
||||
addMessage,
|
||||
} from "../stores"
|
||||
import {
|
||||
connectWebSocket,
|
||||
disconnectWebSocket,
|
||||
wsManager,
|
||||
} from "../websocket"
|
||||
import { roomService } from "../services"
|
||||
import { cryptoService, saveDecryptedMessage } from "../crypto"
|
||||
closeModal,
|
||||
openModal,
|
||||
setActiveRoom,
|
||||
showToast,
|
||||
} from '../stores'
|
||||
import type { Participant } from '../types'
|
||||
import { connectWebSocket, disconnectWebSocket, wsManager } from '../websocket'
|
||||
|
||||
export default function Chat(): JSX.Element {
|
||||
const activeRoom = useStore($activeRoom)
|
||||
|
|
@ -51,8 +48,7 @@ export default function Chat(): JSX.Element {
|
|||
if (currentUserId) {
|
||||
try {
|
||||
await cryptoService.initialize(currentUserId)
|
||||
} catch {
|
||||
}
|
||||
} catch {}
|
||||
connectWebSocket()
|
||||
await roomService.loadRooms(currentUserId)
|
||||
}
|
||||
|
|
@ -69,19 +65,21 @@ export default function Chat(): JSX.Element {
|
|||
const user = $currentUser.get()
|
||||
|
||||
if (roomId === null || room === null) {
|
||||
showToast("error", "SEND FAILED", "NO ACTIVE ROOM")
|
||||
showToast('error', 'SEND FAILED', 'NO ACTIVE ROOM')
|
||||
return
|
||||
}
|
||||
|
||||
if (currentUserId === null) {
|
||||
showToast("error", "SEND FAILED", "NOT AUTHENTICATED")
|
||||
showToast('error', 'SEND FAILED', 'NOT AUTHENTICATED')
|
||||
return
|
||||
}
|
||||
|
||||
const recipientId = room.participants.find((p: Participant) => p.user_id !== currentUserId)?.user_id
|
||||
const recipientId = room.participants.find(
|
||||
(p: Participant) => p.user_id !== currentUserId
|
||||
)?.user_id
|
||||
|
||||
if (recipientId === undefined) {
|
||||
showToast("error", "SEND FAILED", "NO RECIPIENT FOUND")
|
||||
showToast('error', 'SEND FAILED', 'NO RECIPIENT FOUND')
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -92,9 +90,9 @@ export default function Chat(): JSX.Element {
|
|||
id: tempId,
|
||||
room_id: roomId,
|
||||
sender_id: currentUserId,
|
||||
sender_username: user?.username ?? "me",
|
||||
sender_username: user?.username ?? 'me',
|
||||
content,
|
||||
status: "sending" as const,
|
||||
status: 'sending' as const,
|
||||
is_encrypted: true,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
|
|
@ -114,11 +112,10 @@ export default function Chat(): JSX.Element {
|
|||
if (sent) {
|
||||
void saveDecryptedMessage(messageToSend)
|
||||
} else {
|
||||
showToast("error", "SEND FAILED", "NOT CONNECTED")
|
||||
showToast('error', 'SEND FAILED', 'NOT CONNECTED')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Chat] Encryption failed:", error)
|
||||
showToast("error", "SEND FAILED", "ENCRYPTION ERROR")
|
||||
} catch (_error) {
|
||||
showToast('error', 'SEND FAILED', 'ENCRYPTION ERROR')
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -126,7 +123,7 @@ export default function Chat(): JSX.Element {
|
|||
const currentUserId = userId()
|
||||
|
||||
if (currentUserId === null) {
|
||||
showToast("error", "FAILED", "NOT AUTHENTICATED")
|
||||
showToast('error', 'FAILED', 'NOT AUTHENTICATED')
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -136,12 +133,12 @@ export default function Chat(): JSX.Element {
|
|||
setActiveRoom(room.id)
|
||||
closeModal()
|
||||
} else {
|
||||
showToast("error", "FAILED", "COULD NOT CREATE CONVERSATION")
|
||||
showToast('error', 'FAILED', 'COULD NOT CREATE CONVERSATION')
|
||||
}
|
||||
}
|
||||
|
||||
const handleNewChat = (): void => {
|
||||
openModal("new-conversation")
|
||||
openModal('new-conversation')
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -155,15 +152,15 @@ export default function Chat(): JSX.Element {
|
|||
>
|
||||
{(roomId) => (
|
||||
<>
|
||||
<ChatHeader
|
||||
room={activeRoom()}
|
||||
/>
|
||||
<MessageList
|
||||
roomId={roomId}
|
||||
/>
|
||||
<ChatHeader room={activeRoom()} />
|
||||
<MessageList roomId={roomId} />
|
||||
<ChatInput
|
||||
roomId={roomId}
|
||||
recipientId={activeRoom()?.participants.find((p: Participant) => p.user_id !== userId())?.user_id ?? ""}
|
||||
recipientId={
|
||||
activeRoom()?.participants.find(
|
||||
(p: Participant) => p.user_id !== userId()
|
||||
)?.user_id ?? ''
|
||||
}
|
||||
onSend={handleSendMessage}
|
||||
/>
|
||||
</>
|
||||
|
|
@ -172,7 +169,7 @@ export default function Chat(): JSX.Element {
|
|||
</div>
|
||||
|
||||
<NewConversation
|
||||
isOpen={activeModal() === "new-conversation"}
|
||||
isOpen={activeModal() === 'new-conversation'}
|
||||
onClose={closeModal}
|
||||
onCreateRoom={handleCreateRoom}
|
||||
/>
|
||||
|
|
@ -210,7 +207,14 @@ function EmptyState(props: EmptyStateProps): JSX.Element {
|
|||
|
||||
function ChatIcon(): JSX.Element {
|
||||
return (
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="currentColor" class="text-orange mx-auto">
|
||||
<svg
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 48 48"
|
||||
fill="currentColor"
|
||||
class="text-orange mx-auto"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="8" y="8" width="32" height="4" />
|
||||
<rect x="4" y="12" width="4" height="24" />
|
||||
<rect x="40" y="12" width="4" height="24" />
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@
|
|||
* Home page - landing or redirect to chat
|
||||
*/
|
||||
|
||||
import type { JSX } from "solid-js"
|
||||
import { Show } from "solid-js"
|
||||
import { A } from "@solidjs/router"
|
||||
import { useStore } from "@nanostores/solid"
|
||||
import { $isAuthenticated } from "../stores"
|
||||
import { Button } from "../components/UI"
|
||||
import { useStore } from '@nanostores/solid'
|
||||
import { A } from '@solidjs/router'
|
||||
import type { JSX } from 'solid-js'
|
||||
import { Show } from 'solid-js'
|
||||
import { Button } from '../components/UI'
|
||||
import { $isAuthenticated } from '../stores'
|
||||
|
||||
export default function Home(): JSX.Element {
|
||||
const isAuthenticated = useStore($isAuthenticated)
|
||||
|
|
@ -19,13 +19,11 @@ export default function Home(): JSX.Element {
|
|||
<LockIcon />
|
||||
</div>
|
||||
|
||||
<h1 class="font-pixel text-2xl text-orange mb-4">
|
||||
ENCRYPTED CHAT
|
||||
</h1>
|
||||
<h1 class="font-pixel text-2xl text-orange mb-4">ENCRYPTED CHAT</h1>
|
||||
|
||||
<p class="font-pixel text-[10px] text-gray mb-8 leading-relaxed">
|
||||
END-TO-END ENCRYPTED MESSAGING WITH DOUBLE RATCHET PROTOCOL.
|
||||
YOUR MESSAGES ARE SECURE AND PRIVATE.
|
||||
END-TO-END ENCRYPTED MESSAGING WITH DOUBLE RATCHET PROTOCOL. YOUR
|
||||
MESSAGES ARE SECURE AND PRIVATE.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-4 items-center">
|
||||
|
|
@ -80,20 +78,75 @@ function FeatureItem(props: FeatureItemProps): JSX.Element {
|
|||
|
||||
function LockIcon(): JSX.Element {
|
||||
return (
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" class="mx-auto">
|
||||
<rect x="20" y="12" width="24" height="4" fill="currentColor" class="text-orange" />
|
||||
<rect x="16" y="16" width="4" height="16" fill="currentColor" class="text-orange" />
|
||||
<rect x="44" y="16" width="4" height="16" fill="currentColor" class="text-orange" />
|
||||
<rect x="12" y="32" width="40" height="4" fill="currentColor" class="text-orange" />
|
||||
<rect x="12" y="36" width="40" height="20" fill="currentColor" class="text-orange" />
|
||||
<rect x="30" y="42" width="4" height="8" fill="currentColor" class="text-black" />
|
||||
<svg
|
||||
width="64"
|
||||
height="64"
|
||||
viewBox="0 0 64 64"
|
||||
fill="none"
|
||||
class="mx-auto"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect
|
||||
x="20"
|
||||
y="12"
|
||||
width="24"
|
||||
height="4"
|
||||
fill="currentColor"
|
||||
class="text-orange"
|
||||
/>
|
||||
<rect
|
||||
x="16"
|
||||
y="16"
|
||||
width="4"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
class="text-orange"
|
||||
/>
|
||||
<rect
|
||||
x="44"
|
||||
y="16"
|
||||
width="4"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
class="text-orange"
|
||||
/>
|
||||
<rect
|
||||
x="12"
|
||||
y="32"
|
||||
width="40"
|
||||
height="4"
|
||||
fill="currentColor"
|
||||
class="text-orange"
|
||||
/>
|
||||
<rect
|
||||
x="12"
|
||||
y="36"
|
||||
width="40"
|
||||
height="20"
|
||||
fill="currentColor"
|
||||
class="text-orange"
|
||||
/>
|
||||
<rect
|
||||
x="30"
|
||||
y="42"
|
||||
width="4"
|
||||
height="8"
|
||||
fill="currentColor"
|
||||
class="text-black"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function EncryptIcon(): JSX.Element {
|
||||
return (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="10" y="2" width="4" height="4" />
|
||||
<rect x="6" y="6" width="4" height="4" />
|
||||
<rect x="14" y="6" width="4" height="4" />
|
||||
|
|
@ -108,7 +161,13 @@ function EncryptIcon(): JSX.Element {
|
|||
|
||||
function KeyIcon(): JSX.Element {
|
||||
return (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="4" y="8" width="4" height="4" />
|
||||
<rect x="8" y="4" width="4" height="4" />
|
||||
<rect x="8" y="12" width="4" height="4" />
|
||||
|
|
@ -121,7 +180,13 @@ function KeyIcon(): JSX.Element {
|
|||
|
||||
function ShieldIcon(): JSX.Element {
|
||||
return (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="10" y="2" width="4" height="2" />
|
||||
<rect x="6" y="4" width="4" height="2" />
|
||||
<rect x="14" y="4" width="4" height="2" />
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
* Login page with WebAuthn passkey authentication
|
||||
*/
|
||||
|
||||
import type { JSX } from "solid-js"
|
||||
import { AuthForm } from "../components/Auth"
|
||||
import { GuestRoute } from "../components/Layout"
|
||||
import type { JSX } from 'solid-js'
|
||||
import { AuthForm } from '../components/Auth'
|
||||
import { GuestRoute } from '../components/Layout'
|
||||
|
||||
export default function Login(): JSX.Element {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -2,21 +2,17 @@
|
|||
* 404 Not Found page
|
||||
*/
|
||||
|
||||
import type { JSX } from "solid-js"
|
||||
import { A } from "@solidjs/router"
|
||||
import { Button } from "../components/UI"
|
||||
import { A } from '@solidjs/router'
|
||||
import type { JSX } from 'solid-js'
|
||||
import { Button } from '../components/UI'
|
||||
|
||||
export default function NotFound(): JSX.Element {
|
||||
return (
|
||||
<div class="min-h-screen flex flex-col items-center justify-center bg-black p-4">
|
||||
<div class="text-center">
|
||||
<h1 class="font-pixel text-6xl text-orange mb-4">
|
||||
404
|
||||
</h1>
|
||||
<h1 class="font-pixel text-6xl text-orange mb-4">404</h1>
|
||||
|
||||
<p class="font-pixel text-sm text-white mb-2">
|
||||
PAGE NOT FOUND
|
||||
</p>
|
||||
<p class="font-pixel text-sm text-white mb-2">PAGE NOT FOUND</p>
|
||||
|
||||
<p class="font-pixel text-[10px] text-gray mb-8">
|
||||
THE PAGE YOU ARE LOOKING FOR DOES NOT EXIST
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@
|
|||
// Registration page with WebAuthn passkey creation
|
||||
// ===================
|
||||
|
||||
import type { JSX } from "solid-js"
|
||||
import { AuthForm } from "../components/Auth"
|
||||
import { GuestRoute } from "../components/Layout"
|
||||
import type { JSX } from 'solid-js'
|
||||
import { AuthForm } from '../components/Auth'
|
||||
import { GuestRoute } from '../components/Layout'
|
||||
|
||||
export default function Register(): JSX.Element {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -3,20 +3,17 @@
|
|||
* Handles passkey registration and authentication flows
|
||||
*/
|
||||
|
||||
import { api } from "../lib/api-client"
|
||||
import {
|
||||
base64UrlEncode,
|
||||
base64UrlDecode,
|
||||
} from "../lib/base64"
|
||||
import { cryptoService } from '../crypto'
|
||||
import { api } from '../lib/api-client'
|
||||
import { base64UrlDecode, base64UrlEncode } from '../lib/base64'
|
||||
import { setCurrentUser, logout as storeLogout } from '../stores'
|
||||
import type {
|
||||
User,
|
||||
RegistrationBeginRequest,
|
||||
AuthenticationBeginRequest,
|
||||
PublicKeyCredentialJSON,
|
||||
} from "../types"
|
||||
import { isPublicKeyCredential } from "../types/guards"
|
||||
import { setCurrentUser, logout as storeLogout } from "../stores"
|
||||
import { cryptoService } from "../crypto"
|
||||
RegistrationBeginRequest,
|
||||
User,
|
||||
} from '../types'
|
||||
import { isPublicKeyCredential } from '../types/guards'
|
||||
|
||||
interface PublicKeyCredentialStatic {
|
||||
isUserVerifyingPlatformAuthenticatorAvailable(): Promise<boolean>
|
||||
|
|
@ -31,22 +28,32 @@ function publicKeyCredentialToJSON(
|
|||
const json: PublicKeyCredentialJSON = {
|
||||
id: credential.id,
|
||||
rawId: base64UrlEncode(credential.rawId),
|
||||
type: "public-key",
|
||||
type: 'public-key',
|
||||
response: {
|
||||
clientDataJSON: base64UrlEncode(response.clientDataJSON),
|
||||
},
|
||||
authenticatorAttachment: credential.authenticatorAttachment as "platform" | "cross-platform" | undefined,
|
||||
clientExtensionResults: credential.getClientExtensionResults() as Record<string, unknown>,
|
||||
authenticatorAttachment: credential.authenticatorAttachment as
|
||||
| 'platform'
|
||||
| 'cross-platform'
|
||||
| undefined,
|
||||
clientExtensionResults: credential.getClientExtensionResults() as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
}
|
||||
|
||||
if ("attestationObject" in response) {
|
||||
if ('attestationObject' in response) {
|
||||
const attestationResponse = response as AuthenticatorAttestationResponse
|
||||
json.response.attestationObject = base64UrlEncode(attestationResponse.attestationObject)
|
||||
json.response.attestationObject = base64UrlEncode(
|
||||
attestationResponse.attestationObject
|
||||
)
|
||||
}
|
||||
|
||||
if ("authenticatorData" in response) {
|
||||
if ('authenticatorData' in response) {
|
||||
const assertionResponse = response as AuthenticatorAssertionResponse
|
||||
json.response.authenticatorData = base64UrlEncode(assertionResponse.authenticatorData)
|
||||
json.response.authenticatorData = base64UrlEncode(
|
||||
assertionResponse.authenticatorData
|
||||
)
|
||||
json.response.signature = base64UrlEncode(assertionResponse.signature)
|
||||
if (assertionResponse.userHandle !== null) {
|
||||
json.response.userHandle = base64UrlEncode(assertionResponse.userHandle)
|
||||
|
|
@ -57,7 +64,10 @@ function publicKeyCredentialToJSON(
|
|||
}
|
||||
|
||||
function toBufferSource(data: Uint8Array): ArrayBuffer {
|
||||
return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer
|
||||
return data.buffer.slice(
|
||||
data.byteOffset,
|
||||
data.byteOffset + data.byteLength
|
||||
) as ArrayBuffer
|
||||
}
|
||||
|
||||
function preparePublicKeyOptions(
|
||||
|
|
@ -65,34 +75,48 @@ function preparePublicKeyOptions(
|
|||
): PublicKeyCredentialCreationOptions | PublicKeyCredentialRequestOptions {
|
||||
const prepared = { ...options }
|
||||
|
||||
if ("challenge" in prepared && typeof prepared.challenge === "string") {
|
||||
prepared.challenge = toBufferSource(base64UrlDecode(prepared.challenge as unknown as string))
|
||||
if ('challenge' in prepared && typeof prepared.challenge === 'string') {
|
||||
prepared.challenge = toBufferSource(
|
||||
base64UrlDecode(prepared.challenge as unknown as string)
|
||||
)
|
||||
}
|
||||
|
||||
if ("user" in prepared) {
|
||||
if ('user' in prepared) {
|
||||
const creationOptions = prepared as PublicKeyCredentialCreationOptions
|
||||
if (typeof creationOptions.user.id === "string") {
|
||||
creationOptions.user.id = toBufferSource(base64UrlDecode(creationOptions.user.id as unknown as string))
|
||||
if (typeof creationOptions.user.id === 'string') {
|
||||
creationOptions.user.id = toBufferSource(
|
||||
base64UrlDecode(creationOptions.user.id as unknown as string)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if ("excludeCredentials" in prepared) {
|
||||
if ('excludeCredentials' in prepared) {
|
||||
const creationOptions = prepared as PublicKeyCredentialCreationOptions
|
||||
if (creationOptions.excludeCredentials !== undefined) {
|
||||
creationOptions.excludeCredentials = creationOptions.excludeCredentials.map((cred) => ({
|
||||
...cred,
|
||||
id: typeof cred.id === "string" ? toBufferSource(base64UrlDecode(cred.id as unknown as string)) : cred.id,
|
||||
}))
|
||||
creationOptions.excludeCredentials = creationOptions.excludeCredentials.map(
|
||||
(cred) => ({
|
||||
...cred,
|
||||
id:
|
||||
typeof cred.id === 'string'
|
||||
? toBufferSource(base64UrlDecode(cred.id as unknown as string))
|
||||
: cred.id,
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if ("allowCredentials" in prepared) {
|
||||
if ('allowCredentials' in prepared) {
|
||||
const requestOptions = prepared as PublicKeyCredentialRequestOptions
|
||||
if (requestOptions.allowCredentials !== undefined) {
|
||||
requestOptions.allowCredentials = requestOptions.allowCredentials.map((cred) => ({
|
||||
...cred,
|
||||
id: typeof cred.id === "string" ? toBufferSource(base64UrlDecode(cred.id as unknown as string)) : cred.id,
|
||||
}))
|
||||
requestOptions.allowCredentials = requestOptions.allowCredentials.map(
|
||||
(cred) => ({
|
||||
...cred,
|
||||
id:
|
||||
typeof cred.id === 'string'
|
||||
? toBufferSource(base64UrlDecode(cred.id as unknown as string))
|
||||
: cred.id,
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -120,7 +144,7 @@ export async function register(
|
|||
})
|
||||
|
||||
if (!isPublicKeyCredential(credential)) {
|
||||
throw new Error("Failed to create credential")
|
||||
throw new Error('Failed to create credential')
|
||||
}
|
||||
|
||||
const credentialJSON = publicKeyCredentialToJSON(credential)
|
||||
|
|
@ -153,7 +177,7 @@ export async function login(username?: string): Promise<User> {
|
|||
})
|
||||
|
||||
if (!isPublicKeyCredential(credential)) {
|
||||
throw new Error("Failed to get credential")
|
||||
throw new Error('Failed to get credential')
|
||||
}
|
||||
|
||||
const credentialJSON = publicKeyCredentialToJSON(credential)
|
||||
|
|
@ -174,9 +198,9 @@ export function logout(): void {
|
|||
|
||||
export function isWebAuthnSupported(): boolean {
|
||||
return (
|
||||
typeof window !== "undefined" &&
|
||||
typeof window.PublicKeyCredential !== "undefined" &&
|
||||
typeof navigator.credentials !== "undefined"
|
||||
typeof window !== 'undefined' &&
|
||||
typeof window.PublicKeyCredential !== 'undefined' &&
|
||||
typeof navigator.credentials !== 'undefined'
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -199,7 +223,7 @@ export async function isConditionalUIAvailable(): Promise<boolean> {
|
|||
|
||||
try {
|
||||
const pkc = PublicKeyCredential as unknown as PublicKeyCredentialStatic
|
||||
if (typeof pkc.isConditionalMediationAvailable === "function") {
|
||||
if (typeof pkc.isConditionalMediationAvailable === 'function') {
|
||||
return await pkc.isConditionalMediationAvailable()
|
||||
}
|
||||
return false
|
||||
|
|
|
|||
|
|
@ -2,5 +2,5 @@
|
|||
// © AngelaMos | 2025
|
||||
// index.ts
|
||||
// ===================
|
||||
export * from "./auth.service"
|
||||
export * from "./room.service"
|
||||
export * from './auth.service'
|
||||
export * from './room.service'
|
||||
|
|
|
|||
|
|
@ -3,21 +3,21 @@
|
|||
* room.service.ts
|
||||
*/
|
||||
|
||||
import { api } from "../lib/api-client"
|
||||
import type { Message, Room } from "../types"
|
||||
import {
|
||||
setRooms,
|
||||
addRoom,
|
||||
setRoomMessages,
|
||||
setHasMore,
|
||||
$userId,
|
||||
} from "../stores"
|
||||
import {
|
||||
getDecryptedMessages,
|
||||
getDecryptedMessage,
|
||||
saveDecryptedMessage,
|
||||
cryptoService,
|
||||
} from "../crypto"
|
||||
getDecryptedMessage,
|
||||
getDecryptedMessages,
|
||||
saveDecryptedMessage,
|
||||
} from '../crypto'
|
||||
import { api } from '../lib/api-client'
|
||||
import {
|
||||
$userId,
|
||||
addRoom,
|
||||
setHasMore,
|
||||
setRoomMessages,
|
||||
setRooms,
|
||||
} from '../stores'
|
||||
import type { Message, Room } from '../types'
|
||||
|
||||
export async function loadRooms(userId: string): Promise<Room[]> {
|
||||
try {
|
||||
|
|
@ -32,7 +32,7 @@ export async function loadRooms(userId: string): Promise<Room[]> {
|
|||
export async function createRoom(
|
||||
creatorId: string,
|
||||
participantId: string,
|
||||
roomType: "direct" | "group" | "ephemeral" = "direct"
|
||||
roomType: 'direct' | 'group' | 'ephemeral' = 'direct'
|
||||
): Promise<Room | null> {
|
||||
try {
|
||||
const room = await api.rooms.create({
|
||||
|
|
@ -42,8 +42,7 @@ export async function createRoom(
|
|||
})
|
||||
addRoom(room)
|
||||
return room
|
||||
} catch (err) {
|
||||
console.error("[RoomService] Failed to create room:", err)
|
||||
} catch (_err) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
@ -73,7 +72,7 @@ export async function loadMessages(
|
|||
continue
|
||||
}
|
||||
|
||||
let content = "[Encrypted - from another session]"
|
||||
let content = '[Encrypted - from another session]'
|
||||
const isOwnMessage = msg.sender_id === currentUserId
|
||||
|
||||
if (isOwnMessage) {
|
||||
|
|
@ -81,7 +80,7 @@ export async function loadMessages(
|
|||
if (localCopy) {
|
||||
content = localCopy.content
|
||||
} else {
|
||||
content = "[Your message - not stored locally]"
|
||||
content = '[Your message - not stored locally]'
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
|
|
@ -92,7 +91,7 @@ export async function loadMessages(
|
|||
msg.header
|
||||
)
|
||||
} catch {
|
||||
content = "[Encrypted - from another session]"
|
||||
content = '[Encrypted - from another session]'
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -102,7 +101,7 @@ export async function loadMessages(
|
|||
sender_id: msg.sender_id,
|
||||
sender_username: msg.sender_username,
|
||||
content,
|
||||
status: "delivered" as const,
|
||||
status: 'delivered' as const,
|
||||
is_encrypted: true,
|
||||
encrypted_content: msg.ciphertext,
|
||||
nonce: msg.nonce,
|
||||
|
|
@ -111,7 +110,10 @@ export async function loadMessages(
|
|||
updated_at: msg.created_at,
|
||||
}
|
||||
|
||||
if (!content.startsWith("[Encrypted") && !content.startsWith("[Your message")) {
|
||||
if (
|
||||
!content.startsWith('[Encrypted') &&
|
||||
!content.startsWith('[Your message')
|
||||
) {
|
||||
void saveDecryptedMessage(decryptedMessage)
|
||||
}
|
||||
|
||||
|
|
@ -121,13 +123,15 @@ export async function loadMessages(
|
|||
const allMessages = [...localMessages, ...newMessages]
|
||||
const uniqueMessages = Array.from(
|
||||
new Map(allMessages.map((m) => [m.id, m])).values()
|
||||
).sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
|
||||
).sort(
|
||||
(a, b) =>
|
||||
new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
|
||||
)
|
||||
|
||||
setRoomMessages(roomId, uniqueMessages)
|
||||
setHasMore(roomId, response.has_more)
|
||||
return uniqueMessages
|
||||
} catch (err) {
|
||||
console.error("[RoomService] Failed to load messages:", err)
|
||||
} catch (_err) {
|
||||
const localMessages = await getDecryptedMessages(roomId, limit)
|
||||
if (localMessages.length > 0) {
|
||||
setRoomMessages(roomId, localMessages)
|
||||
|
|
|
|||
|
|
@ -3,18 +3,18 @@
|
|||
* Manages current user and authentication status
|
||||
*/
|
||||
|
||||
import { atom, computed } from "nanostores"
|
||||
import { persistentAtom } from "@nanostores/persistent"
|
||||
import type { User, Session } from "../types"
|
||||
import { persistentAtom } from '@nanostores/persistent'
|
||||
import { atom, computed } from 'nanostores'
|
||||
import type { Session, User } from '../types'
|
||||
|
||||
export const $currentUser = atom<User | null>(null)
|
||||
|
||||
const $userIdRaw = persistentAtom<string>("chat:user_id", "", {
|
||||
const $userIdRaw = persistentAtom<string>('chat:user_id', '', {
|
||||
encode: (value: string) => value,
|
||||
decode: (value: string) => value,
|
||||
})
|
||||
|
||||
export const $userId = computed($userIdRaw, (id) => id !== "" ? id : null)
|
||||
export const $userId = computed($userIdRaw, (id) => (id !== '' ? id : null))
|
||||
|
||||
export const $isAuthenticated = computed($userId, (id) => id !== null)
|
||||
|
||||
|
|
@ -22,7 +22,7 @@ export const $session = atom<Session | null>(null)
|
|||
|
||||
export function setCurrentUser(user: User | null): void {
|
||||
$currentUser.set(user)
|
||||
$userIdRaw.set(user?.id ?? "")
|
||||
$userIdRaw.set(user?.id ?? '')
|
||||
|
||||
if (user !== null) {
|
||||
$session.set({
|
||||
|
|
@ -39,7 +39,7 @@ export function setCurrentUser(user: User | null): void {
|
|||
|
||||
export function logout(): void {
|
||||
$currentUser.set(null)
|
||||
$userIdRaw.set("")
|
||||
$userIdRaw.set('')
|
||||
$session.set(null)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,11 +2,11 @@
|
|||
// © AngelaMos | 2025
|
||||
// index.ts
|
||||
// ===================
|
||||
export * from "./auth.store"
|
||||
export * from "./session.store"
|
||||
export * from "./settings.store"
|
||||
export * from "./ui.store"
|
||||
export * from "./rooms.store"
|
||||
export * from "./messages.store"
|
||||
export * from "./presence.store"
|
||||
export * from "./typing.store"
|
||||
export * from './auth.store'
|
||||
export * from './messages.store'
|
||||
export * from './presence.store'
|
||||
export * from './rooms.store'
|
||||
export * from './session.store'
|
||||
export * from './settings.store'
|
||||
export * from './typing.store'
|
||||
export * from './ui.store'
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@
|
|||
* Manages message cache per room
|
||||
*/
|
||||
|
||||
import { map, computed } from "nanostores"
|
||||
import type { Message, MessageStatus } from "../types"
|
||||
import { $activeRoomId } from "./rooms.store"
|
||||
import { computed, map } from 'nanostores'
|
||||
import type { Message, MessageStatus } from '../types'
|
||||
import { $activeRoomId } from './rooms.store'
|
||||
|
||||
export const $messagesByRoom = map<Record<string, Message[]>>({})
|
||||
|
||||
|
|
@ -17,12 +17,12 @@ export const $oldestMessageIdByRoom = map<Record<string, string | undefined>>({}
|
|||
|
||||
export const $activeRoomMessages = computed(
|
||||
[$messagesByRoom, $activeRoomId],
|
||||
(messages, roomId) => (roomId ? messages[roomId] ?? [] : [])
|
||||
(messages, roomId) => (roomId ? (messages[roomId] ?? []) : [])
|
||||
)
|
||||
|
||||
export const $activeRoomPendingMessages = computed(
|
||||
[$pendingMessages, $activeRoomId],
|
||||
(pending, roomId) => (roomId ? pending[roomId] ?? [] : [])
|
||||
(pending, roomId) => (roomId ? (pending[roomId] ?? []) : [])
|
||||
)
|
||||
|
||||
export function addMessage(roomId: string, message: Message): void {
|
||||
|
|
@ -142,7 +142,10 @@ export function clearAllMessages(): void {
|
|||
$oldestMessageIdByRoom.set({})
|
||||
}
|
||||
|
||||
export function getMessageById(roomId: string, messageId: string): Message | null {
|
||||
export function getMessageById(
|
||||
roomId: string,
|
||||
messageId: string
|
||||
): Message | null {
|
||||
const messages = $messagesByRoom.get()[roomId]
|
||||
return messages?.find((m) => m.id === messageId) ?? null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
* Manages user online/offline status
|
||||
*/
|
||||
|
||||
import { map } from "nanostores"
|
||||
import type { PresenceStatus, UserPresence } from "../types"
|
||||
import { map } from 'nanostores'
|
||||
import type { PresenceStatus, UserPresence } from '../types'
|
||||
|
||||
export const $presenceByUser = map<Record<string, UserPresence>>({})
|
||||
|
||||
|
|
@ -38,18 +38,18 @@ export function getUserPresence(userId: string): UserPresence | null {
|
|||
}
|
||||
|
||||
export function getUserStatus(userId: string): PresenceStatus {
|
||||
return $presenceByUser.get()[userId]?.status ?? "offline"
|
||||
return $presenceByUser.get()[userId]?.status ?? 'offline'
|
||||
}
|
||||
|
||||
export function isUserOnline(userId: string): boolean {
|
||||
const status = getUserStatus(userId)
|
||||
return status === "online"
|
||||
return status === 'online'
|
||||
}
|
||||
|
||||
export function getOnlineUserIds(): string[] {
|
||||
const presences = $presenceByUser.get()
|
||||
return Object.entries(presences)
|
||||
.filter(([_, p]) => p.status === "online")
|
||||
.filter(([_, p]) => p.status === 'online')
|
||||
.map(([id]) => id)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,16 +3,15 @@
|
|||
* Manages chat rooms and active conversation state
|
||||
*/
|
||||
|
||||
import { atom, computed, map } from "nanostores"
|
||||
import type { Room } from "../types"
|
||||
import { atom, computed, map } from 'nanostores'
|
||||
import type { Room } from '../types'
|
||||
|
||||
export const $rooms = map<Record<string, Room>>({})
|
||||
|
||||
export const $activeRoomId = atom<string | null>(null)
|
||||
|
||||
export const $activeRoom = computed(
|
||||
[$rooms, $activeRoomId],
|
||||
(rooms, activeId) => (activeId ? rooms[activeId] ?? null : null)
|
||||
export const $activeRoom = computed([$rooms, $activeRoomId], (rooms, activeId) =>
|
||||
activeId ? (rooms[activeId] ?? null) : null
|
||||
)
|
||||
|
||||
export const $roomList = computed($rooms, (rooms) =>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
* Stores authentication tokens in localStorage
|
||||
*/
|
||||
|
||||
import { persistentMap, persistentAtom } from "@nanostores/persistent"
|
||||
import { persistentAtom, persistentMap } from '@nanostores/persistent'
|
||||
|
||||
interface SessionTokens {
|
||||
accessToken: string
|
||||
|
|
@ -12,13 +12,13 @@ interface SessionTokens {
|
|||
}
|
||||
|
||||
const DEFAULT_TOKENS: SessionTokens = {
|
||||
accessToken: "",
|
||||
refreshToken: "",
|
||||
accessToken: '',
|
||||
refreshToken: '',
|
||||
expiresAt: 0,
|
||||
}
|
||||
|
||||
export const $sessionTokens = persistentMap<SessionTokens>(
|
||||
"chat:session:",
|
||||
'chat:session:',
|
||||
DEFAULT_TOKENS,
|
||||
{
|
||||
encode: JSON.stringify,
|
||||
|
|
@ -26,14 +26,10 @@ export const $sessionTokens = persistentMap<SessionTokens>(
|
|||
}
|
||||
)
|
||||
|
||||
export const $lastActivity = persistentAtom<string>(
|
||||
"chat:last_activity",
|
||||
"",
|
||||
{
|
||||
encode: String,
|
||||
decode: String,
|
||||
}
|
||||
)
|
||||
export const $lastActivity = persistentAtom<string>('chat:last_activity', '', {
|
||||
encode: String,
|
||||
decode: String,
|
||||
})
|
||||
|
||||
export function setSessionTokens(
|
||||
accessToken: string,
|
||||
|
|
@ -55,7 +51,7 @@ export function clearSessionTokens(): void {
|
|||
|
||||
export function isSessionValid(): boolean {
|
||||
const tokens = $sessionTokens.get()
|
||||
if (tokens.accessToken === "") {
|
||||
if (tokens.accessToken === '') {
|
||||
return false
|
||||
}
|
||||
return tokens.expiresAt > Date.now()
|
||||
|
|
@ -70,7 +66,7 @@ export function getAccessToken(): string | null {
|
|||
|
||||
export function getRefreshToken(): string | null {
|
||||
const tokens = $sessionTokens.get()
|
||||
return tokens.refreshToken !== "" ? tokens.refreshToken : null
|
||||
return tokens.refreshToken !== '' ? tokens.refreshToken : null
|
||||
}
|
||||
|
||||
export function updateLastActivity(): void {
|
||||
|
|
@ -79,5 +75,5 @@ export function updateLastActivity(): void {
|
|||
|
||||
export function getLastActivity(): Date | null {
|
||||
const activity = $lastActivity.get()
|
||||
return activity !== "" ? new Date(activity) : null
|
||||
return activity !== '' ? new Date(activity) : null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,12 @@
|
|||
* Stores user preferences in localStorage
|
||||
*/
|
||||
|
||||
import { persistentMap } from "@nanostores/persistent"
|
||||
import { computed } from "nanostores"
|
||||
import { persistentMap } from '@nanostores/persistent'
|
||||
import { computed } from 'nanostores'
|
||||
|
||||
type Theme = "dark" | "light"
|
||||
type FontSize = "small" | "medium" | "large"
|
||||
type NotificationSound = "retro" | "subtle" | "none"
|
||||
type Theme = 'dark' | 'light'
|
||||
type FontSize = 'small' | 'medium' | 'large'
|
||||
type NotificationSound = 'retro' | 'subtle' | 'none'
|
||||
|
||||
interface UserSettings {
|
||||
theme: Theme
|
||||
|
|
@ -24,10 +24,10 @@ interface UserSettings {
|
|||
}
|
||||
|
||||
const DEFAULT_SETTINGS: UserSettings = {
|
||||
theme: "dark",
|
||||
fontSize: "medium",
|
||||
theme: 'dark',
|
||||
fontSize: 'medium',
|
||||
soundEnabled: true,
|
||||
notificationSound: "retro",
|
||||
notificationSound: 'retro',
|
||||
desktopNotifications: true,
|
||||
showTypingIndicators: true,
|
||||
showReadReceipts: true,
|
||||
|
|
@ -37,7 +37,7 @@ const DEFAULT_SETTINGS: UserSettings = {
|
|||
}
|
||||
|
||||
export const $settings = persistentMap<UserSettings>(
|
||||
"chat:settings:",
|
||||
'chat:settings:',
|
||||
DEFAULT_SETTINGS,
|
||||
{
|
||||
encode: JSON.stringify,
|
||||
|
|
@ -50,43 +50,43 @@ export const $theme = computed($settings, (settings) => settings.theme)
|
|||
export const $fontSize = computed($settings, (settings) => settings.fontSize)
|
||||
|
||||
export function setTheme(theme: Theme): void {
|
||||
$settings.setKey("theme", theme)
|
||||
$settings.setKey('theme', theme)
|
||||
}
|
||||
|
||||
export function setFontSize(fontSize: FontSize): void {
|
||||
$settings.setKey("fontSize", fontSize)
|
||||
$settings.setKey('fontSize', fontSize)
|
||||
}
|
||||
|
||||
export function setSoundEnabled(enabled: boolean): void {
|
||||
$settings.setKey("soundEnabled", enabled)
|
||||
$settings.setKey('soundEnabled', enabled)
|
||||
}
|
||||
|
||||
export function setNotificationSound(sound: NotificationSound): void {
|
||||
$settings.setKey("notificationSound", sound)
|
||||
$settings.setKey('notificationSound', sound)
|
||||
}
|
||||
|
||||
export function setDesktopNotifications(enabled: boolean): void {
|
||||
$settings.setKey("desktopNotifications", enabled)
|
||||
$settings.setKey('desktopNotifications', enabled)
|
||||
}
|
||||
|
||||
export function setShowTypingIndicators(show: boolean): void {
|
||||
$settings.setKey("showTypingIndicators", show)
|
||||
$settings.setKey('showTypingIndicators', show)
|
||||
}
|
||||
|
||||
export function setShowReadReceipts(show: boolean): void {
|
||||
$settings.setKey("showReadReceipts", show)
|
||||
$settings.setKey('showReadReceipts', show)
|
||||
}
|
||||
|
||||
export function setShowOnlineStatus(show: boolean): void {
|
||||
$settings.setKey("showOnlineStatus", show)
|
||||
$settings.setKey('showOnlineStatus', show)
|
||||
}
|
||||
|
||||
export function setEnterToSend(enabled: boolean): void {
|
||||
$settings.setKey("enterToSend", enabled)
|
||||
$settings.setKey('enterToSend', enabled)
|
||||
}
|
||||
|
||||
export function setCompactMode(enabled: boolean): void {
|
||||
$settings.setKey("compactMode", enabled)
|
||||
$settings.setKey('compactMode', enabled)
|
||||
}
|
||||
|
||||
export function resetSettings(): void {
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@
|
|||
* Manages typing status per room
|
||||
*/
|
||||
|
||||
import { map, computed } from "nanostores"
|
||||
import type { TypingState } from "../types"
|
||||
import { $activeRoomId } from "./rooms.store"
|
||||
import { computed, map } from 'nanostores'
|
||||
import type { TypingState } from '../types'
|
||||
import { $activeRoomId } from './rooms.store'
|
||||
|
||||
const TYPING_TIMEOUT_MS = 5000
|
||||
|
||||
|
|
@ -15,7 +15,7 @@ const typingTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
|||
|
||||
export const $activeRoomTyping = computed(
|
||||
[$typingByRoom, $activeRoomId],
|
||||
(typing, roomId) => (roomId ? typing[roomId] ?? [] : [])
|
||||
(typing, roomId) => (roomId ? (typing[roomId] ?? []) : [])
|
||||
)
|
||||
|
||||
export const $activeRoomTypingUsernames = computed(
|
||||
|
|
|
|||
|
|
@ -3,20 +3,20 @@
|
|||
* Manages sidebar, modals, and other UI state
|
||||
*/
|
||||
|
||||
import { atom, computed } from "nanostores"
|
||||
import { atom, computed } from 'nanostores'
|
||||
|
||||
type ModalType =
|
||||
| "new-conversation"
|
||||
| "user-search"
|
||||
| "settings"
|
||||
| "profile"
|
||||
| "encryption-info"
|
||||
| "confirm-logout"
|
||||
| 'new-conversation'
|
||||
| 'user-search'
|
||||
| 'settings'
|
||||
| 'profile'
|
||||
| 'encryption-info'
|
||||
| 'confirm-logout'
|
||||
| null
|
||||
|
||||
interface ToastNotification {
|
||||
id: string
|
||||
variant: "info" | "success" | "warning" | "error"
|
||||
variant: 'info' | 'success' | 'warning' | 'error'
|
||||
title: string
|
||||
description?: string
|
||||
duration?: number
|
||||
|
|
@ -34,16 +34,13 @@ export const $toasts = atom<ToastNotification[]>([])
|
|||
|
||||
export const $isLoading = atom<boolean>(false)
|
||||
|
||||
export const $loadingMessage = atom<string>("")
|
||||
export const $loadingMessage = atom<string>('')
|
||||
|
||||
export const $isMobile = atom<boolean>(false)
|
||||
|
||||
export const $searchQuery = atom<string>("")
|
||||
export const $searchQuery = atom<string>('')
|
||||
|
||||
export const $hasActiveModal = computed(
|
||||
$activeModal,
|
||||
(modal) => modal !== null
|
||||
)
|
||||
export const $hasActiveModal = computed($activeModal, (modal) => modal !== null)
|
||||
|
||||
let toastIdCounter = 0
|
||||
|
||||
|
|
@ -74,14 +71,15 @@ export function closeModal(): void {
|
|||
}
|
||||
|
||||
export function showToast(
|
||||
variant: ToastNotification["variant"],
|
||||
variant: ToastNotification['variant'],
|
||||
title: string,
|
||||
description?: string,
|
||||
duration?: number
|
||||
): string {
|
||||
const existingToasts = $toasts.get()
|
||||
const duplicate = existingToasts.find(
|
||||
(t) => t.title === title && t.description === description && t.variant === variant
|
||||
(t) =>
|
||||
t.title === title && t.description === description && t.variant === variant
|
||||
)
|
||||
|
||||
if (duplicate !== undefined) {
|
||||
|
|
@ -119,7 +117,7 @@ export function clearAllToasts(): void {
|
|||
|
||||
export function setLoading(loading: boolean, message?: string): void {
|
||||
$isLoading.set(loading)
|
||||
$loadingMessage.set(message ?? "")
|
||||
$loadingMessage.set(message ?? '')
|
||||
}
|
||||
|
||||
export function setIsMobile(isMobile: boolean): void {
|
||||
|
|
@ -131,7 +129,7 @@ export function setSearchQuery(query: string): void {
|
|||
}
|
||||
|
||||
export function clearSearchQuery(): void {
|
||||
$searchQuery.set("")
|
||||
$searchQuery.set('')
|
||||
}
|
||||
|
||||
export type { ModalType, ToastNotification }
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export class ApiError extends Error {
|
|||
message?: string
|
||||
) {
|
||||
super(message ?? `API Error: ${status}`)
|
||||
this.name = "ApiError"
|
||||
this.name = 'ApiError'
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -41,13 +41,15 @@ export interface AuthenticationCompleteRequest {
|
|||
}
|
||||
|
||||
export interface WebAuthnOptions {
|
||||
publicKey: PublicKeyCredentialCreationOptions | PublicKeyCredentialRequestOptions
|
||||
publicKey:
|
||||
| PublicKeyCredentialCreationOptions
|
||||
| PublicKeyCredentialRequestOptions
|
||||
}
|
||||
|
||||
export interface PublicKeyCredentialJSON {
|
||||
id: string
|
||||
rawId: string
|
||||
type: "public-key"
|
||||
type: 'public-key'
|
||||
response: {
|
||||
clientDataJSON: string
|
||||
attestationObject?: string
|
||||
|
|
@ -55,7 +57,7 @@ export interface PublicKeyCredentialJSON {
|
|||
signature?: string
|
||||
userHandle?: string
|
||||
}
|
||||
authenticatorAttachment?: "platform" | "cross-platform"
|
||||
authenticatorAttachment?: 'platform' | 'cross-platform'
|
||||
clientExtensionResults: Record<string, unknown>
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,11 +2,11 @@
|
|||
// © AngelaMos | 2025
|
||||
// chat.ts
|
||||
// ===================
|
||||
export type MessageStatus = "sending" | "sent" | "delivered" | "read" | "failed"
|
||||
export type MessageStatus = 'sending' | 'sent' | 'delivered' | 'read' | 'failed'
|
||||
|
||||
export type PresenceStatus = "online" | "away" | "offline"
|
||||
export type PresenceStatus = 'online' | 'away' | 'offline'
|
||||
|
||||
export type RoomType = "direct" | "group" | "ephemeral"
|
||||
export type RoomType = 'direct' | 'group' | 'ephemeral'
|
||||
|
||||
export interface Message {
|
||||
id: string
|
||||
|
|
@ -41,7 +41,7 @@ export interface Participant {
|
|||
user_id: string
|
||||
username: string
|
||||
display_name: string
|
||||
role: "owner" | "admin" | "member"
|
||||
role: 'owner' | 'admin' | 'member'
|
||||
joined_at: string
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,14 +2,14 @@
|
|||
// © AngelaMos | 2025
|
||||
// components.ts
|
||||
// ===================
|
||||
import type { JSX } from "solid-js"
|
||||
import type { MessageStatus, PresenceStatus } from "./chat"
|
||||
import type { JSX } from 'solid-js'
|
||||
import type { MessageStatus, PresenceStatus } from './chat'
|
||||
|
||||
export type Size = "xs" | "sm" | "md" | "lg" | "xl"
|
||||
export type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
||||
|
||||
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger"
|
||||
export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger'
|
||||
|
||||
export type BadgeVariant = "default" | "primary" | "success" | "warning" | "error"
|
||||
export type BadgeVariant = 'default' | 'primary' | 'success' | 'warning' | 'error'
|
||||
|
||||
export interface ButtonProps {
|
||||
variant?: ButtonVariant
|
||||
|
|
@ -19,14 +19,14 @@ export interface ButtonProps {
|
|||
loading?: boolean
|
||||
leftIcon?: JSX.Element
|
||||
rightIcon?: JSX.Element
|
||||
type?: "button" | "submit" | "reset"
|
||||
type?: 'button' | 'submit' | 'reset'
|
||||
onClick?: () => void
|
||||
class?: string
|
||||
children: JSX.Element
|
||||
}
|
||||
|
||||
export interface InputProps {
|
||||
type?: "text" | "email" | "password" | "search"
|
||||
type?: 'text' | 'email' | 'password' | 'search'
|
||||
name?: string
|
||||
placeholder?: string
|
||||
value?: string
|
||||
|
|
@ -93,7 +93,7 @@ export interface ModalProps {
|
|||
|
||||
export interface ToastProps {
|
||||
id: string
|
||||
variant: "info" | "success" | "warning" | "error"
|
||||
variant: 'info' | 'success' | 'warning' | 'error'
|
||||
title: string
|
||||
description?: string
|
||||
duration?: number
|
||||
|
|
@ -109,7 +109,7 @@ export interface SpinnerProps {
|
|||
}
|
||||
|
||||
export interface SkeletonProps {
|
||||
variant?: "text" | "circular" | "rectangular"
|
||||
variant?: 'text' | 'circular' | 'rectangular'
|
||||
width?: string
|
||||
height?: string
|
||||
lines?: number
|
||||
|
|
@ -118,7 +118,7 @@ export interface SkeletonProps {
|
|||
|
||||
export interface TooltipProps {
|
||||
content: string
|
||||
position?: "top" | "bottom" | "left" | "right"
|
||||
position?: 'top' | 'bottom' | 'left' | 'right'
|
||||
delay?: number
|
||||
children: JSX.Element
|
||||
}
|
||||
|
|
@ -135,7 +135,7 @@ export interface DropdownProps {
|
|||
items: DropdownItem[]
|
||||
onSelect: (value: string) => void
|
||||
trigger: JSX.Element
|
||||
align?: "left" | "right"
|
||||
align?: 'left' | 'right'
|
||||
class?: string
|
||||
}
|
||||
|
||||
|
|
@ -143,7 +143,7 @@ export interface IconButtonProps {
|
|||
icon: JSX.Element
|
||||
onClick?: () => void
|
||||
size?: Size
|
||||
variant?: "ghost" | "subtle"
|
||||
variant?: 'ghost' | 'subtle'
|
||||
ariaLabel: string
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
|
|
|
|||
|
|
@ -3,40 +3,40 @@
|
|||
*/
|
||||
|
||||
import type {
|
||||
User,
|
||||
Message,
|
||||
Room,
|
||||
MessageStatus,
|
||||
PresenceStatus,
|
||||
RoomType,
|
||||
PreKeyBundle,
|
||||
ApiErrorResponse,
|
||||
ValidationError,
|
||||
WSMessage,
|
||||
EncryptedMessageWS,
|
||||
TypingIndicatorWS,
|
||||
ErrorMessageWS,
|
||||
HeartbeatWS,
|
||||
Message,
|
||||
MessageSentWS,
|
||||
MessageStatus,
|
||||
PreKeyBundle,
|
||||
PresenceStatus,
|
||||
PresenceUpdateWS,
|
||||
ReadReceiptWS,
|
||||
HeartbeatWS,
|
||||
ErrorMessageWS,
|
||||
Room,
|
||||
RoomCreatedWS,
|
||||
MessageSentWS,
|
||||
} from "./index"
|
||||
RoomType,
|
||||
TypingIndicatorWS,
|
||||
User,
|
||||
ValidationError,
|
||||
WSMessage,
|
||||
} from './index'
|
||||
|
||||
export function isString(value: unknown): value is string {
|
||||
return typeof value === "string"
|
||||
return typeof value === 'string'
|
||||
}
|
||||
|
||||
export function isNumber(value: unknown): value is number {
|
||||
return typeof value === "number" && !Number.isNaN(value)
|
||||
return typeof value === 'number' && !Number.isNaN(value)
|
||||
}
|
||||
|
||||
export function isBoolean(value: unknown): value is boolean {
|
||||
return typeof value === "boolean"
|
||||
return typeof value === 'boolean'
|
||||
}
|
||||
|
||||
export function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
export function isArray(value: unknown): value is unknown[] {
|
||||
|
|
@ -62,20 +62,20 @@ export function isUser(value: unknown): value is User {
|
|||
|
||||
export function isMessageStatus(value: unknown): value is MessageStatus {
|
||||
return (
|
||||
value === "sending" ||
|
||||
value === "sent" ||
|
||||
value === "delivered" ||
|
||||
value === "read" ||
|
||||
value === "failed"
|
||||
value === 'sending' ||
|
||||
value === 'sent' ||
|
||||
value === 'delivered' ||
|
||||
value === 'read' ||
|
||||
value === 'failed'
|
||||
)
|
||||
}
|
||||
|
||||
export function isPresenceStatus(value: unknown): value is PresenceStatus {
|
||||
return value === "online" || value === "away" || value === "offline"
|
||||
return value === 'online' || value === 'away' || value === 'offline'
|
||||
}
|
||||
|
||||
export function isRoomType(value: unknown): value is RoomType {
|
||||
return value === "direct" || value === "group" || value === "ephemeral"
|
||||
return value === 'direct' || value === 'group' || value === 'ephemeral'
|
||||
}
|
||||
|
||||
export function isMessage(value: unknown): value is Message {
|
||||
|
|
@ -124,7 +124,9 @@ export function isValidationError(value: unknown): value is ValidationError {
|
|||
return isNonEmptyString(value.field) && isNonEmptyString(value.message)
|
||||
}
|
||||
|
||||
export function isValidationErrorArray(value: unknown): value is ValidationError[] {
|
||||
export function isValidationErrorArray(
|
||||
value: unknown
|
||||
): value is ValidationError[] {
|
||||
return isArray(value) && value.every(isValidationError)
|
||||
}
|
||||
|
||||
|
|
@ -134,11 +136,13 @@ export function isApiErrorResponse(value: unknown): value is ApiErrorResponse {
|
|||
return isString(value.detail) || isValidationErrorArray(value.detail)
|
||||
}
|
||||
|
||||
export function isEncryptedMessageWS(value: unknown): value is EncryptedMessageWS {
|
||||
export function isEncryptedMessageWS(
|
||||
value: unknown
|
||||
): value is EncryptedMessageWS {
|
||||
if (!isObject(value)) return false
|
||||
|
||||
return (
|
||||
value.type === "encrypted_message" &&
|
||||
value.type === 'encrypted_message' &&
|
||||
isNonEmptyString(value.message_id) &&
|
||||
isNonEmptyString(value.sender_id) &&
|
||||
isNonEmptyString(value.recipient_id) &&
|
||||
|
|
@ -155,7 +159,7 @@ export function isTypingIndicatorWS(value: unknown): value is TypingIndicatorWS
|
|||
if (!isObject(value)) return false
|
||||
|
||||
return (
|
||||
value.type === "typing" &&
|
||||
value.type === 'typing' &&
|
||||
isNonEmptyString(value.user_id) &&
|
||||
isNonEmptyString(value.room_id) &&
|
||||
isBoolean(value.is_typing) &&
|
||||
|
|
@ -167,7 +171,7 @@ export function isPresenceUpdateWS(value: unknown): value is PresenceUpdateWS {
|
|||
if (!isObject(value)) return false
|
||||
|
||||
return (
|
||||
value.type === "presence" &&
|
||||
value.type === 'presence' &&
|
||||
isNonEmptyString(value.user_id) &&
|
||||
isPresenceStatus(value.status) &&
|
||||
isNonEmptyString(value.last_seen)
|
||||
|
|
@ -178,7 +182,7 @@ export function isReadReceiptWS(value: unknown): value is ReadReceiptWS {
|
|||
if (!isObject(value)) return false
|
||||
|
||||
return (
|
||||
value.type === "receipt" &&
|
||||
value.type === 'receipt' &&
|
||||
isNonEmptyString(value.message_id) &&
|
||||
isNonEmptyString(value.room_id) &&
|
||||
isNonEmptyString(value.user_id) &&
|
||||
|
|
@ -189,14 +193,14 @@ export function isReadReceiptWS(value: unknown): value is ReadReceiptWS {
|
|||
export function isHeartbeatWS(value: unknown): value is HeartbeatWS {
|
||||
if (!isObject(value)) return false
|
||||
|
||||
return value.type === "heartbeat"
|
||||
return value.type === 'heartbeat'
|
||||
}
|
||||
|
||||
export function isErrorMessageWS(value: unknown): value is ErrorMessageWS {
|
||||
if (!isObject(value)) return false
|
||||
|
||||
return (
|
||||
value.type === "error" &&
|
||||
value.type === 'error' &&
|
||||
isNonEmptyString(value.error_code) &&
|
||||
isNonEmptyString(value.error_message)
|
||||
)
|
||||
|
|
@ -206,7 +210,7 @@ export function isRoomCreatedWS(value: unknown): value is RoomCreatedWS {
|
|||
if (!isObject(value)) return false
|
||||
|
||||
return (
|
||||
value.type === "room_created" &&
|
||||
value.type === 'room_created' &&
|
||||
isNonEmptyString(value.room_id) &&
|
||||
isNonEmptyString(value.room_type) &&
|
||||
isArray(value.participants) &&
|
||||
|
|
@ -220,7 +224,7 @@ export function isMessageSentWS(value: unknown): value is MessageSentWS {
|
|||
if (!isObject(value)) return false
|
||||
|
||||
return (
|
||||
value.type === "message_sent" &&
|
||||
value.type === 'message_sent' &&
|
||||
isNonEmptyString(value.temp_id) &&
|
||||
isNonEmptyString(value.message_id) &&
|
||||
isNonEmptyString(value.room_id) &&
|
||||
|
|
@ -250,6 +254,8 @@ export function isDefined<T>(value: T | null | undefined): value is T {
|
|||
return value !== null && value !== undefined
|
||||
}
|
||||
|
||||
export function isPublicKeyCredential(value: Credential | null): value is PublicKeyCredential {
|
||||
return value !== null && value.type === "public-key"
|
||||
export function isPublicKeyCredential(
|
||||
value: Credential | null
|
||||
): value is PublicKeyCredential {
|
||||
return value !== null && value.type === 'public-key'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@
|
|||
// © AngelaMos | 2025
|
||||
// index.ts
|
||||
// ===================
|
||||
export * from "./api"
|
||||
export * from "./auth"
|
||||
export * from "./chat"
|
||||
export * from "./websocket"
|
||||
export * from "./encryption"
|
||||
export * from "./components"
|
||||
export * from "./guards"
|
||||
export * from './api'
|
||||
export * from './auth'
|
||||
export * from './chat'
|
||||
export * from './components'
|
||||
export * from './encryption'
|
||||
export * from './guards'
|
||||
export * from './websocket'
|
||||
|
|
|
|||
|
|
@ -2,17 +2,17 @@
|
|||
// © AngelaMos | 2025
|
||||
// websockets.ts
|
||||
// ===================
|
||||
import type { PresenceStatus } from "./chat"
|
||||
import type { PresenceStatus } from './chat'
|
||||
|
||||
export type WSMessageType =
|
||||
| "encrypted_message"
|
||||
| "typing"
|
||||
| "presence"
|
||||
| "receipt"
|
||||
| "heartbeat"
|
||||
| "error"
|
||||
| "room_created"
|
||||
| "message_sent"
|
||||
| 'encrypted_message'
|
||||
| 'typing'
|
||||
| 'presence'
|
||||
| 'receipt'
|
||||
| 'heartbeat'
|
||||
| 'error'
|
||||
| 'room_created'
|
||||
| 'message_sent'
|
||||
|
||||
export interface BaseWSMessage {
|
||||
type: WSMessageType
|
||||
|
|
@ -20,7 +20,7 @@ export interface BaseWSMessage {
|
|||
}
|
||||
|
||||
export interface EncryptedMessageWS extends BaseWSMessage {
|
||||
type: "encrypted_message"
|
||||
type: 'encrypted_message'
|
||||
message_id: string
|
||||
sender_id: string
|
||||
recipient_id: string
|
||||
|
|
@ -33,7 +33,7 @@ export interface EncryptedMessageWS extends BaseWSMessage {
|
|||
}
|
||||
|
||||
export interface TypingIndicatorWS extends BaseWSMessage {
|
||||
type: "typing"
|
||||
type: 'typing'
|
||||
user_id: string
|
||||
room_id: string
|
||||
is_typing: boolean
|
||||
|
|
@ -41,14 +41,14 @@ export interface TypingIndicatorWS extends BaseWSMessage {
|
|||
}
|
||||
|
||||
export interface PresenceUpdateWS extends BaseWSMessage {
|
||||
type: "presence"
|
||||
type: 'presence'
|
||||
user_id: string
|
||||
status: PresenceStatus
|
||||
last_seen: string
|
||||
}
|
||||
|
||||
export interface ReadReceiptWS extends BaseWSMessage {
|
||||
type: "receipt"
|
||||
type: 'receipt'
|
||||
message_id: string
|
||||
room_id: string
|
||||
user_id: string
|
||||
|
|
@ -56,18 +56,18 @@ export interface ReadReceiptWS extends BaseWSMessage {
|
|||
}
|
||||
|
||||
export interface HeartbeatWS extends BaseWSMessage {
|
||||
type: "heartbeat"
|
||||
type: 'heartbeat'
|
||||
}
|
||||
|
||||
export interface ErrorMessageWS extends BaseWSMessage {
|
||||
type: "error"
|
||||
type: 'error'
|
||||
error_code: string
|
||||
error_message: string
|
||||
details?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface RoomCreatedWS extends BaseWSMessage {
|
||||
type: "room_created"
|
||||
type: 'room_created'
|
||||
room_id: string
|
||||
room_type: string
|
||||
name: string | null
|
||||
|
|
@ -84,7 +84,7 @@ export interface RoomCreatedWS extends BaseWSMessage {
|
|||
}
|
||||
|
||||
export interface MessageSentWS extends BaseWSMessage {
|
||||
type: "message_sent"
|
||||
type: 'message_sent'
|
||||
temp_id: string
|
||||
message_id: string
|
||||
room_id: string
|
||||
|
|
@ -103,7 +103,7 @@ export type WSMessage =
|
|||
| MessageSentWS
|
||||
|
||||
export interface WSOutgoingEncryptedMessage {
|
||||
type: "encrypted_message"
|
||||
type: 'encrypted_message'
|
||||
recipient_id: string
|
||||
room_id: string
|
||||
ciphertext: string
|
||||
|
|
@ -113,24 +113,24 @@ export interface WSOutgoingEncryptedMessage {
|
|||
}
|
||||
|
||||
export interface WSOutgoingTyping {
|
||||
type: "typing"
|
||||
type: 'typing'
|
||||
room_id: string
|
||||
is_typing: boolean
|
||||
}
|
||||
|
||||
export interface WSOutgoingPresence {
|
||||
type: "presence"
|
||||
type: 'presence'
|
||||
status: PresenceStatus
|
||||
}
|
||||
|
||||
export interface WSOutgoingReceipt {
|
||||
type: "receipt"
|
||||
type: 'receipt'
|
||||
message_id: string
|
||||
room_id: string
|
||||
}
|
||||
|
||||
export interface WSOutgoingHeartbeat {
|
||||
type: "heartbeat"
|
||||
type: 'heartbeat'
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,5 +2,6 @@
|
|||
// © AngelaMos | 2025
|
||||
// index.ts
|
||||
// ===================
|
||||
export * from "./websocket-manager"
|
||||
export * from "./message-handlers"
|
||||
|
||||
export * from './message-handlers'
|
||||
export * from './websocket-manager'
|
||||
|
|
|
|||
|
|
@ -2,47 +2,44 @@
|
|||
// © AngelaMos | 2025
|
||||
// message-handlers.ts
|
||||
// ===================
|
||||
import type {
|
||||
WSMessage,
|
||||
EncryptedMessageWS,
|
||||
TypingIndicatorWS,
|
||||
PresenceUpdateWS,
|
||||
ReadReceiptWS,
|
||||
ErrorMessageWS,
|
||||
RoomCreatedWS,
|
||||
MessageSentWS,
|
||||
Message,
|
||||
Room,
|
||||
} from "../types"
|
||||
import {
|
||||
isEncryptedMessageWS,
|
||||
isTypingIndicatorWS,
|
||||
isPresenceUpdateWS,
|
||||
isReadReceiptWS,
|
||||
isErrorMessageWS,
|
||||
isRoomCreatedWS,
|
||||
isMessageSentWS,
|
||||
} from "../types/guards"
|
||||
|
||||
import { cryptoService, saveDecryptedMessage, updateMessageId } from '../crypto'
|
||||
import {
|
||||
addMessage,
|
||||
updateMessageStatus,
|
||||
confirmPendingMessage,
|
||||
} from "../stores/messages.store"
|
||||
updateMessageStatus,
|
||||
} from '../stores/messages.store'
|
||||
import { setUserPresence } from '../stores/presence.store'
|
||||
import { addRoom } from '../stores/rooms.store'
|
||||
import { setUserTyping } from '../stores/typing.store'
|
||||
import { showToast } from '../stores/ui.store'
|
||||
import type {
|
||||
EncryptedMessageWS,
|
||||
ErrorMessageWS,
|
||||
Message,
|
||||
MessageSentWS,
|
||||
PresenceUpdateWS,
|
||||
ReadReceiptWS,
|
||||
Room,
|
||||
RoomCreatedWS,
|
||||
TypingIndicatorWS,
|
||||
WSMessage,
|
||||
} from '../types'
|
||||
import {
|
||||
addRoom,
|
||||
} from "../stores/rooms.store"
|
||||
import {
|
||||
setUserPresence,
|
||||
} from "../stores/presence.store"
|
||||
import {
|
||||
setUserTyping,
|
||||
} from "../stores/typing.store"
|
||||
import { showToast } from "../stores/ui.store"
|
||||
import { cryptoService, saveDecryptedMessage, updateMessageId } from "../crypto"
|
||||
isEncryptedMessageWS,
|
||||
isErrorMessageWS,
|
||||
isMessageSentWS,
|
||||
isPresenceUpdateWS,
|
||||
isReadReceiptWS,
|
||||
isRoomCreatedWS,
|
||||
isTypingIndicatorWS,
|
||||
} from '../types/guards'
|
||||
|
||||
type MessageHandler<T extends WSMessage> = (message: T) => void
|
||||
|
||||
async function encryptedMessageHandler(message: EncryptedMessageWS): Promise<void> {
|
||||
async function encryptedMessageHandler(
|
||||
message: EncryptedMessageWS
|
||||
): Promise<void> {
|
||||
let decryptedContent: string
|
||||
|
||||
try {
|
||||
|
|
@ -53,7 +50,7 @@ async function encryptedMessageHandler(message: EncryptedMessageWS): Promise<voi
|
|||
message.header
|
||||
)
|
||||
} catch {
|
||||
decryptedContent = "[Encrypted message - decryption failed]"
|
||||
decryptedContent = '[Encrypted message - decryption failed]'
|
||||
}
|
||||
|
||||
const chatMessage: Message = {
|
||||
|
|
@ -62,7 +59,7 @@ async function encryptedMessageHandler(message: EncryptedMessageWS): Promise<voi
|
|||
sender_id: message.sender_id,
|
||||
sender_username: message.sender_username,
|
||||
content: decryptedContent,
|
||||
status: "delivered",
|
||||
status: 'delivered',
|
||||
is_encrypted: true,
|
||||
encrypted_content: message.ciphertext,
|
||||
nonce: message.nonce,
|
||||
|
|
@ -85,25 +82,21 @@ const typingIndicatorHandler: MessageHandler<TypingIndicatorWS> = (message) => {
|
|||
}
|
||||
|
||||
const presenceUpdateHandler: MessageHandler<PresenceUpdateWS> = (message) => {
|
||||
setUserPresence(
|
||||
message.user_id,
|
||||
message.status,
|
||||
message.last_seen
|
||||
)
|
||||
setUserPresence(message.user_id, message.status, message.last_seen)
|
||||
}
|
||||
|
||||
const readReceiptHandler: MessageHandler<ReadReceiptWS> = (message) => {
|
||||
updateMessageStatus(message.room_id, message.message_id, "read")
|
||||
updateMessageStatus(message.room_id, message.message_id, 'read')
|
||||
}
|
||||
|
||||
const errorMessageHandler: MessageHandler<ErrorMessageWS> = (message) => {
|
||||
showToast("error", "CONNECTION ERROR", message.error_message.toUpperCase())
|
||||
showToast('error', 'CONNECTION ERROR', message.error_message.toUpperCase())
|
||||
}
|
||||
|
||||
const roomCreatedHandler: MessageHandler<RoomCreatedWS> = (message) => {
|
||||
const room: Room = {
|
||||
id: message.room_id,
|
||||
type: message.room_type as "direct" | "group" | "ephemeral",
|
||||
type: message.room_type as 'direct' | 'group' | 'ephemeral',
|
||||
name: message.name,
|
||||
participants: message.participants,
|
||||
unread_count: 0,
|
||||
|
|
@ -113,17 +106,17 @@ const roomCreatedHandler: MessageHandler<RoomCreatedWS> = (message) => {
|
|||
}
|
||||
|
||||
addRoom(room)
|
||||
showToast("info", "NEW CHAT", `NEW CONVERSATION STARTED`)
|
||||
showToast('info', 'NEW CHAT', `NEW CONVERSATION STARTED`)
|
||||
}
|
||||
|
||||
const messageSentHandler: MessageHandler<MessageSentWS> = (message) => {
|
||||
const confirmedMessage: Message = {
|
||||
id: message.message_id,
|
||||
room_id: message.room_id,
|
||||
sender_id: "",
|
||||
sender_username: "",
|
||||
content: "",
|
||||
status: "sent",
|
||||
sender_id: '',
|
||||
sender_username: '',
|
||||
content: '',
|
||||
status: 'sent',
|
||||
is_encrypted: true,
|
||||
created_at: message.created_at,
|
||||
updated_at: message.created_at,
|
||||
|
|
@ -151,7 +144,9 @@ export function handleWSMessage(message: WSMessage): void {
|
|||
}
|
||||
}
|
||||
|
||||
export async function handleEncryptedMessage(message: EncryptedMessageWS): Promise<void> {
|
||||
export async function handleEncryptedMessage(
|
||||
message: EncryptedMessageWS
|
||||
): Promise<void> {
|
||||
await encryptedMessageHandler(message)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,49 +2,54 @@
|
|||
// ©AngelaMos | 2025
|
||||
// websocket-manager.ts
|
||||
// ===================
|
||||
import { atom, computed } from "nanostores"
|
||||
import {
|
||||
WS_URL,
|
||||
WS_HEARTBEAT_INTERVAL,
|
||||
WS_RECONNECT_DELAY,
|
||||
} from "../config"
|
||||
import { $userId } from "../stores"
|
||||
import { atom, computed } from 'nanostores'
|
||||
import { WS_HEARTBEAT_INTERVAL, WS_RECONNECT_DELAY, WS_URL } from '../config'
|
||||
import { $userId } from '../stores'
|
||||
import type {
|
||||
WSMessage,
|
||||
WSOutgoingMessage,
|
||||
WSOutgoingEncryptedMessage,
|
||||
WSOutgoingHeartbeat,
|
||||
WSOutgoingTyping,
|
||||
WSOutgoingMessage,
|
||||
WSOutgoingPresence,
|
||||
WSOutgoingReceipt,
|
||||
WSOutgoingEncryptedMessage,
|
||||
} from "../types"
|
||||
import { handleWSMessage } from "./message-handlers"
|
||||
WSOutgoingTyping,
|
||||
} from '../types'
|
||||
import {
|
||||
isWSMessage,
|
||||
isEncryptedMessageWS,
|
||||
isTypingIndicatorWS,
|
||||
isErrorMessageWS,
|
||||
isHeartbeatWS,
|
||||
isMessageSentWS,
|
||||
isPresenceUpdateWS,
|
||||
isReadReceiptWS,
|
||||
isHeartbeatWS,
|
||||
isErrorMessageWS,
|
||||
isRoomCreatedWS,
|
||||
isMessageSentWS,
|
||||
} from "../types/guards"
|
||||
isTypingIndicatorWS,
|
||||
isWSMessage,
|
||||
} from '../types/guards'
|
||||
import { handleWSMessage } from './message-handlers'
|
||||
|
||||
export type ConnectionStatus = "disconnected" | "connecting" | "connected" | "reconnecting"
|
||||
export type ConnectionStatus =
|
||||
| 'disconnected'
|
||||
| 'connecting'
|
||||
| 'connected'
|
||||
| 'reconnecting'
|
||||
|
||||
export const $connectionStatus = atom<ConnectionStatus>("disconnected")
|
||||
export const $connectionStatus = atom<ConnectionStatus>('disconnected')
|
||||
export const $reconnectAttempts = atom<number>(0)
|
||||
export const $lastError = atom<string | null>(null)
|
||||
|
||||
export const $isConnected = computed(
|
||||
$connectionStatus,
|
||||
(status) => status === "connected"
|
||||
(status) => status === 'connected'
|
||||
)
|
||||
|
||||
const MAX_RECONNECT_ATTEMPTS = 10
|
||||
const MAX_RECONNECT_DELAY = 30000
|
||||
const FATAL_ERROR_CODES = ["max_connections", "unauthorized", "invalid_user", "database_error"]
|
||||
const FATAL_ERROR_CODES = [
|
||||
'max_connections',
|
||||
'unauthorized',
|
||||
'invalid_user',
|
||||
'database_error',
|
||||
]
|
||||
|
||||
class WebSocketManager {
|
||||
private ws: WebSocket | null = null
|
||||
|
|
@ -57,7 +62,7 @@ class WebSocketManager {
|
|||
connect(): void {
|
||||
const userId = $userId.get()
|
||||
if (!userId) {
|
||||
$lastError.set("Cannot connect: User not authenticated")
|
||||
$lastError.set('Cannot connect: User not authenticated')
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -70,7 +75,7 @@ class WebSocketManager {
|
|||
}
|
||||
|
||||
this.intentionalClose = false
|
||||
$connectionStatus.set("connecting")
|
||||
$connectionStatus.set('connecting')
|
||||
$lastError.set(null)
|
||||
|
||||
const wsUrl = `${WS_URL}/ws?user_id=${userId}`
|
||||
|
|
@ -86,7 +91,7 @@ class WebSocketManager {
|
|||
this.intentionalClose = true
|
||||
this.fatalError = false
|
||||
this.cleanup()
|
||||
$connectionStatus.set("disconnected")
|
||||
$connectionStatus.set('disconnected')
|
||||
$reconnectAttempts.set(0)
|
||||
}
|
||||
|
||||
|
|
@ -112,7 +117,7 @@ class WebSocketManager {
|
|||
tempId: string
|
||||
): boolean {
|
||||
const message: WSOutgoingEncryptedMessage = {
|
||||
type: "encrypted_message",
|
||||
type: 'encrypted_message',
|
||||
recipient_id: recipientId,
|
||||
room_id: roomId,
|
||||
ciphertext: encrypted.ciphertext,
|
||||
|
|
@ -125,16 +130,16 @@ class WebSocketManager {
|
|||
|
||||
sendTypingIndicator(roomId: string, isTyping: boolean): boolean {
|
||||
const message: WSOutgoingTyping = {
|
||||
type: "typing",
|
||||
type: 'typing',
|
||||
room_id: roomId,
|
||||
is_typing: isTyping,
|
||||
}
|
||||
return this.send(message)
|
||||
}
|
||||
|
||||
sendPresenceUpdate(status: "online" | "away" | "offline"): boolean {
|
||||
sendPresenceUpdate(status: 'online' | 'away' | 'offline'): boolean {
|
||||
const message: WSOutgoingPresence = {
|
||||
type: "presence",
|
||||
type: 'presence',
|
||||
status,
|
||||
}
|
||||
return this.send(message)
|
||||
|
|
@ -142,7 +147,7 @@ class WebSocketManager {
|
|||
|
||||
sendReadReceipt(messageId: string, roomId: string): boolean {
|
||||
const message: WSOutgoingReceipt = {
|
||||
type: "receipt",
|
||||
type: 'receipt',
|
||||
message_id: messageId,
|
||||
room_id: roomId,
|
||||
}
|
||||
|
|
@ -154,12 +159,12 @@ class WebSocketManager {
|
|||
}
|
||||
|
||||
private handleOpen(): void {
|
||||
$connectionStatus.set("connected")
|
||||
$connectionStatus.set('connected')
|
||||
$reconnectAttempts.set(0)
|
||||
$lastError.set(null)
|
||||
|
||||
this.startHeartbeat()
|
||||
this.sendPresenceUpdate("online")
|
||||
this.sendPresenceUpdate('online')
|
||||
this.flushMessageQueue()
|
||||
}
|
||||
|
||||
|
|
@ -205,21 +210,23 @@ class WebSocketManager {
|
|||
this.cleanup()
|
||||
|
||||
if (this.intentionalClose) {
|
||||
$connectionStatus.set("disconnected")
|
||||
$connectionStatus.set('disconnected')
|
||||
return
|
||||
}
|
||||
|
||||
if (this.fatalError) {
|
||||
$connectionStatus.set("disconnected")
|
||||
$connectionStatus.set('disconnected')
|
||||
return
|
||||
}
|
||||
|
||||
const attempts = $reconnectAttempts.get()
|
||||
|
||||
if (attempts >= MAX_RECONNECT_ATTEMPTS) {
|
||||
$connectionStatus.set("disconnected")
|
||||
const reason = event.reason !== "" ? event.reason : "Unknown reason"
|
||||
$lastError.set(`Connection failed after maximum retry attempts (code: ${event.code}, reason: ${reason})`)
|
||||
$connectionStatus.set('disconnected')
|
||||
const reason = event.reason !== '' ? event.reason : 'Unknown reason'
|
||||
$lastError.set(
|
||||
`Connection failed after maximum retry attempts (code: ${event.code}, reason: ${reason})`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -227,11 +234,11 @@ class WebSocketManager {
|
|||
$lastError.set(`Connection closed unexpectedly (code: ${event.code})`)
|
||||
}
|
||||
|
||||
$connectionStatus.set("reconnecting")
|
||||
$connectionStatus.set('reconnecting')
|
||||
$reconnectAttempts.set(attempts + 1)
|
||||
|
||||
const delay = Math.min(
|
||||
WS_RECONNECT_DELAY * Math.pow(2, attempts),
|
||||
WS_RECONNECT_DELAY * 2 ** attempts,
|
||||
MAX_RECONNECT_DELAY
|
||||
)
|
||||
|
||||
|
|
@ -241,7 +248,7 @@ class WebSocketManager {
|
|||
}
|
||||
|
||||
private handleError(_event: Event): void {
|
||||
$lastError.set("WebSocket connection error")
|
||||
$lastError.set('WebSocket connection error')
|
||||
}
|
||||
|
||||
private startHeartbeat(): void {
|
||||
|
|
@ -250,7 +257,7 @@ class WebSocketManager {
|
|||
this.heartbeatInterval = setInterval(() => {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
const heartbeat: WSOutgoingHeartbeat = {
|
||||
type: "heartbeat",
|
||||
type: 'heartbeat',
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
this.ws.send(JSON.stringify(heartbeat))
|
||||
|
|
@ -266,7 +273,10 @@ class WebSocketManager {
|
|||
}
|
||||
|
||||
private flushMessageQueue(): void {
|
||||
while (this.messageQueue.length > 0 && this.ws?.readyState === WebSocket.OPEN) {
|
||||
while (
|
||||
this.messageQueue.length > 0 &&
|
||||
this.ws?.readyState === WebSocket.OPEN
|
||||
) {
|
||||
const message = this.messageQueue.shift()
|
||||
if (message !== undefined) {
|
||||
try {
|
||||
|
|
@ -293,7 +303,10 @@ class WebSocketManager {
|
|||
this.ws.onclose = null
|
||||
this.ws.onerror = null
|
||||
|
||||
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
|
||||
if (
|
||||
this.ws.readyState === WebSocket.OPEN ||
|
||||
this.ws.readyState === WebSocket.CONNECTING
|
||||
) {
|
||||
this.ws.close()
|
||||
}
|
||||
this.ws = null
|
||||
|
|
|
|||
|
|
@ -2,45 +2,46 @@
|
|||
// © AngelaMos | 2025
|
||||
// vite.config.ts
|
||||
// ===================
|
||||
import { defineConfig } from "vite";
|
||||
import solid from "vite-plugin-solid";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import path from "path";
|
||||
|
||||
import path from 'node:path'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { defineConfig } from 'vite'
|
||||
import solid from 'vite-plugin-solid'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [solid(), tailwindcss()],
|
||||
envDir: "../",
|
||||
envDir: '../',
|
||||
resolve: {
|
||||
alias: {
|
||||
"~": path.resolve(__dirname, "./src"),
|
||||
'~': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
host: true,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://backend:8000",
|
||||
'/api': {
|
||||
target: 'http://backend:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
"/ws": {
|
||||
target: "ws://backend:8000",
|
||||
'/ws': {
|
||||
target: 'ws://backend:8000',
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
target: "esnext",
|
||||
outDir: "dist",
|
||||
target: 'esnext',
|
||||
outDir: 'dist',
|
||||
sourcemap: true,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
"solid-js": ["solid-js"],
|
||||
"solid-router": ["@solidjs/router"],
|
||||
"solid-query": ["@tanstack/solid-query"],
|
||||
'solid-js': ['solid-js'],
|
||||
'solid-router': ['@solidjs/router'],
|
||||
'solid-query': ['@tanstack/solid-query'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.3.15/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.2/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@
|
|||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.3.15",
|
||||
"@biomejs/biome": "^2.4.2",
|
||||
"@types/node": "^25.2.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
|
|
|
|||
|
|
@ -37,8 +37,8 @@ importers:
|
|||
version: 5.0.11(@types/react@19.2.14)(react@19.2.4)
|
||||
devDependencies:
|
||||
'@biomejs/biome':
|
||||
specifier: ^2.3.15
|
||||
version: 2.3.15
|
||||
specifier: ^2.4.2
|
||||
version: 2.4.2
|
||||
'@types/node':
|
||||
specifier: ^25.2.3
|
||||
version: 25.2.3
|
||||
|
|
@ -162,59 +162,59 @@ packages:
|
|||
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@biomejs/biome@2.3.15':
|
||||
resolution: {integrity: sha512-u+jlPBAU2B45LDkjjNNYpc1PvqrM/co4loNommS9/sl9oSxsAQKsNZejYuUztvToB5oXi1tN/e62iNd6ESiY3g==}
|
||||
'@biomejs/biome@2.4.2':
|
||||
resolution: {integrity: sha512-vVE/FqLxNLbvYnFDYg3Xfrh1UdFhmPT5i+yPT9GE2nTUgI4rkqo5krw5wK19YHBd7aE7J6r91RRmb8RWwkjy6w==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
hasBin: true
|
||||
|
||||
'@biomejs/cli-darwin-arm64@2.3.15':
|
||||
resolution: {integrity: sha512-SDCdrJ4COim1r8SNHg19oqT50JfkI/xGZHSyC6mGzMfKrpNe/217Eq6y98XhNTc0vGWDjznSDNXdUc6Kg24jbw==}
|
||||
'@biomejs/cli-darwin-arm64@2.4.2':
|
||||
resolution: {integrity: sha512-3pEcKCP/1POKyaZZhXcxFl3+d9njmeAihZ17k8lL/1vk+6e0Cbf0yPzKItFiT+5Yh6TQA4uKvnlqe0oVZwRxCA==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@biomejs/cli-darwin-x64@2.3.15':
|
||||
resolution: {integrity: sha512-RkyeSosBtn3C3Un8zQnl9upX0Qbq4E3QmBa0qjpOh1MebRbHhNlRC16jk8HdTe/9ym5zlfnpbb8cKXzW+vlTxw==}
|
||||
'@biomejs/cli-darwin-x64@2.4.2':
|
||||
resolution: {integrity: sha512-P7hK1jLVny+0R9UwyGcECxO6sjETxfPyBm/1dmFjnDOHgdDPjPqozByunrwh4xPKld8sxOr5eAsSqal5uKgeBg==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@biomejs/cli-linux-arm64-musl@2.3.15':
|
||||
resolution: {integrity: sha512-SSSIj2yMkFdSkXqASzIBdjySBXOe65RJlhKEDlri7MN19RC4cpez+C0kEwPrhXOTgJbwQR9QH1F4+VnHkC35pg==}
|
||||
'@biomejs/cli-linux-arm64-musl@2.4.2':
|
||||
resolution: {integrity: sha512-/x04YK9+7erw6tYEcJv9WXoBHcULI/wMOvNdAyE9S3JStZZ9yJyV67sWAI+90UHuDo/BDhq0d96LDqGlSVv7WA==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@biomejs/cli-linux-arm64@2.3.15':
|
||||
resolution: {integrity: sha512-FN83KxrdVWANOn5tDmW6UBC0grojchbGmcEz6JkRs2YY6DY63sTZhwkQ56x6YtKhDVV1Unz7FJexy8o7KwuIhg==}
|
||||
'@biomejs/cli-linux-arm64@2.4.2':
|
||||
resolution: {integrity: sha512-DI3Mi7GT2zYNgUTDEbSjl3e1KhoP76OjQdm8JpvZYZWtVDRyLd3w8llSr2TWk1z+U3P44kUBWY3X7H9MD1/DGQ==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@biomejs/cli-linux-x64-musl@2.3.15':
|
||||
resolution: {integrity: sha512-dbjPzTh+ijmmNwojFYbQNMFp332019ZDioBYAMMJj5Ux9d8MkM+u+J68SBJGVwVeSHMYj+T9504CoxEzQxrdNw==}
|
||||
'@biomejs/cli-linux-x64-musl@2.4.2':
|
||||
resolution: {integrity: sha512-wbBmTkeAoAYbOQ33f6sfKG7pcRSydQiF+dTYOBjJsnXO2mWEOQHllKlC2YVnedqZFERp2WZhFUoO7TNRwnwEHQ==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@biomejs/cli-linux-x64@2.3.15':
|
||||
resolution: {integrity: sha512-T8n9p8aiIKOrAD7SwC7opiBM1LYGrE5G3OQRXWgbeo/merBk8m+uxJ1nOXMPzfYyFLfPlKF92QS06KN1UW+Zbg==}
|
||||
'@biomejs/cli-linux-x64@2.4.2':
|
||||
resolution: {integrity: sha512-GK2ErnrKpWFigYP68cXiCHK4RTL4IUWhK92AFS3U28X/nuAL5+hTuy6hyobc8JZRSt+upXt1nXChK+tuHHx4mA==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@biomejs/cli-win32-arm64@2.3.15':
|
||||
resolution: {integrity: sha512-puMuenu/2brQdgqtQ7geNwQlNVxiABKEZJhMRX6AGWcmrMO8EObMXniFQywy2b81qmC+q+SDvlOpspNwz0WiOA==}
|
||||
'@biomejs/cli-win32-arm64@2.4.2':
|
||||
resolution: {integrity: sha512-k2uqwLYrNNxnaoiW3RJxoMGnbKda8FuCmtYG3cOtVljs3CzWxaTR+AoXwKGHscC9thax9R4kOrtWqWN0+KdPTw==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@biomejs/cli-win32-x64@2.3.15':
|
||||
resolution: {integrity: sha512-kDZr/hgg+igo5Emi0LcjlgfkoGZtgIpJKhnvKTRmMBv6FF/3SDyEV4khBwqNebZIyMZTzvpca9sQNSXJ39pI2A==}
|
||||
'@biomejs/cli-win32-x64@2.4.2':
|
||||
resolution: {integrity: sha512-9ma7C4g8Sq3cBlRJD2yrsHXB1mnnEBdpy7PhvFrylQWQb4PoyCmPucdX7frvsSBQuFtIiKCrolPl/8tCZrKvgQ==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
|
@ -1423,39 +1423,39 @@ snapshots:
|
|||
'@babel/helper-string-parser': 7.27.1
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
|
||||
'@biomejs/biome@2.3.15':
|
||||
'@biomejs/biome@2.4.2':
|
||||
optionalDependencies:
|
||||
'@biomejs/cli-darwin-arm64': 2.3.15
|
||||
'@biomejs/cli-darwin-x64': 2.3.15
|
||||
'@biomejs/cli-linux-arm64': 2.3.15
|
||||
'@biomejs/cli-linux-arm64-musl': 2.3.15
|
||||
'@biomejs/cli-linux-x64': 2.3.15
|
||||
'@biomejs/cli-linux-x64-musl': 2.3.15
|
||||
'@biomejs/cli-win32-arm64': 2.3.15
|
||||
'@biomejs/cli-win32-x64': 2.3.15
|
||||
'@biomejs/cli-darwin-arm64': 2.4.2
|
||||
'@biomejs/cli-darwin-x64': 2.4.2
|
||||
'@biomejs/cli-linux-arm64': 2.4.2
|
||||
'@biomejs/cli-linux-arm64-musl': 2.4.2
|
||||
'@biomejs/cli-linux-x64': 2.4.2
|
||||
'@biomejs/cli-linux-x64-musl': 2.4.2
|
||||
'@biomejs/cli-win32-arm64': 2.4.2
|
||||
'@biomejs/cli-win32-x64': 2.4.2
|
||||
|
||||
'@biomejs/cli-darwin-arm64@2.3.15':
|
||||
'@biomejs/cli-darwin-arm64@2.4.2':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-darwin-x64@2.3.15':
|
||||
'@biomejs/cli-darwin-x64@2.4.2':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-linux-arm64-musl@2.3.15':
|
||||
'@biomejs/cli-linux-arm64-musl@2.4.2':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-linux-arm64@2.3.15':
|
||||
'@biomejs/cli-linux-arm64@2.4.2':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-linux-x64-musl@2.3.15':
|
||||
'@biomejs/cli-linux-x64-musl@2.4.2':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-linux-x64@2.3.15':
|
||||
'@biomejs/cli-linux-x64@2.4.2':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-win32-arm64@2.3.15':
|
||||
'@biomejs/cli-win32-arm64@2.4.2':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-win32-x64@2.3.15':
|
||||
'@biomejs/cli-win32-x64@2.4.2':
|
||||
optional: true
|
||||
|
||||
'@cacheable/memory@2.0.7':
|
||||
|
|
|
|||
|
|
@ -469,3 +469,19 @@ def main() -> None:
|
|||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
|
||||
"""
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡀⠀⠀⠀⠀⠀⠀⡀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
⢸⠉⣹⠋⠉⢉⡟⢩⢋⠋⣽⡻⠭⢽⢉⠯⠭⠭⠭⢽⡍⢹⡍⠙⣯⠉⠉⠉⠉⠉⣿⢫⠉⠉⠉⢉⡟⠉⢿⢹⠉⢉⣉⢿⡝⡉⢩⢿⣻⢍⠉⠉⠩⢹⣟⡏⠉⠹⡉⢻⡍⡇
|
||||
⢸⢠⢹⠀⠀⢸⠁⣼⠀⣼⡝⠀⠀⢸⠘⠀⠀⠀⠀⠈⢿⠀⡟⡄⠹⣣⠀⠀⠐⠀⢸⡘⡄⣤⠀⡼⠁⠀⢺⡘⠉⠀⠀⠀⠫⣪⣌⡌⢳⡻⣦⠀⠀⢃⡽⡼⡀⠀⢣⢸⠸⡇
|
||||
⢸⡸⢸⠀⠀⣿⠀⣇⢠⡿⠀⠀⠀⠸⡇⠀⠀⠀⠀⠀⠘⢇⠸⠘⡀⠻⣇⠀⠀⠄⠀⡇⢣⢛⠀⡇⠀⠀⣸⠇⠀⠀⠀⠀⠀⠘⠄⢻⡀⠻⣻⣧⠀⠀⠃⢧⡇⠀⢸⢸⡇⡇
|
||||
⢸⡇⢸⣠⠀⣿⢠⣿⡾⠁⠀⢀⡀⠤⢇⣀⣐⣀⠀⠤⢀⠈⠢⡡⡈⢦⡙⣷⡀⠀⠀⢿⠈⢻⣡⠁⠀⢀⠏⠀⠀⠀⢀⠀⠄⣀⣐⣀⣙⠢⡌⣻⣷⡀⢹⢸⡅⠀⢸⠸⡇⡇
|
||||
⢸⡇⢸⣟⠀⢿⢸⡿⠀⣀⣶⣷⣾⡿⠿⣿⣿⣿⣿⣿⣶⣬⡀⠐⠰⣄⠙⠪⣻⣦⡀⠘⣧⠀⠙⠄⠀⠀⠀⠀⠀⣨⣴⣾⣿⠿⣿⣿⣿⣿⣿⣶⣯⣿⣼⢼⡇⠀⢸⡇⡇⠇
|
||||
⢸⢧⠀⣿⡅⢸⣼⡷⣾⣿⡟⠋⣿⠓⢲⣿⣿⣿⡟⠙⣿⠛⢯⡳⡀⠈⠓⠄⡈⠚⠿⣧⣌⢧⠀⠀⠀⠀⠀⣠⣺⠟⢫⡿⠓⢺⣿⣿⣿⠏⠙⣏⠛⣿⣿⣾⡇⢀⡿⢠⠀⡇
|
||||
⢸⢸⠀⢹⣷⡀⢿⡁⠀⠻⣇⠀⣇⠀⠘⣿⣿⡿⠁⠐⣉⡀⠀⠁⠀⠀⠀⠀⠀⠀⠀⠀⠉⠓⠳⠄⠀⠀⠀⠀⠋⠀⠘⡇⠀⠸⣿⣿⠟⠀⢈⣉⢠⡿⠁⣼⠁⣼⠃⣼⠀⡇
|
||||
⢸⠸⣀⠈⣯⢳⡘⣇⠀⠀⠈⡂⣜⣆⡀⠀⠀⢀⣀⡴⠇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢽⣆⣀⠀⠀⠀⣀⣜⠕⡊⠀⣸⠇⣼⡟⢠⠏⠀⡇
|
||||
⢸⠀⡟⠀⢸⡆⢹⡜⡆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⠋⣾⡏⡇⡎⡇⠀⡇
|
||||
⢸⠀⢃⡆⠀⢿⡄⠑⢽⣄⠀⠀⠀⢀⠂⠠⢁⠈⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠠⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡀⠀⠄⡐⢀⠂⠀⠀⣠⣮⡟⢹⣯⣸⣱⠁⠀⡇
|
||||
⠈⠉⠉⠉⠉⠉⠉⠉⠉⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠉⠉⠉⠉⠉⠉⠉⠉⠁
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
{
|
||||
"extends": [
|
||||
"stylelint-config-standard-scss",
|
||||
"stylelint-config-prettier-scss"
|
||||
],
|
||||
"extends": ["stylelint-config-standard-scss", "stylelint-config-prettier-scss"],
|
||||
"rules": {
|
||||
"scss/at-rule-no-unknown": null,
|
||||
"selector-class-pattern": null
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.2/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"includes": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "**/*.json"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 82,
|
||||
"lineEnding": "lf"
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "single",
|
||||
"jsxQuoteStyle": "double",
|
||||
"semicolons": "asNeeded",
|
||||
"trailingCommas": "es5",
|
||||
"arrowParentheses": "always"
|
||||
}
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"complexity": {
|
||||
"noExcessiveCognitiveComplexity": {
|
||||
"level": "error",
|
||||
"options": { "maxAllowedComplexity": 25 }
|
||||
},
|
||||
"noForEach": "off",
|
||||
"useLiteralKeys": "off"
|
||||
},
|
||||
"correctness": {
|
||||
"noUnusedVariables": "error",
|
||||
"noUnusedImports": "error",
|
||||
"useExhaustiveDependencies": "warn",
|
||||
"useHookAtTopLevel": "error",
|
||||
"noUndeclaredVariables": "error"
|
||||
},
|
||||
"style": {
|
||||
"useImportType": "error",
|
||||
"useConst": "error",
|
||||
"useTemplate": "error",
|
||||
"useSelfClosingElements": "error",
|
||||
"useFragmentSyntax": "error",
|
||||
"noNonNullAssertion": "error",
|
||||
"useConsistentArrayType": {
|
||||
"level": "error",
|
||||
"options": { "syntax": "shorthand" }
|
||||
},
|
||||
"useNamingConvention": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noExplicitAny": "error",
|
||||
"noDebugger": "error",
|
||||
"noConsole": "warn",
|
||||
"noArrayIndexKey": "warn",
|
||||
"noAssignInExpressions": "error",
|
||||
"noDoubleEquals": "error",
|
||||
"noRedeclare": "error",
|
||||
"noVar": "error"
|
||||
},
|
||||
"security": {
|
||||
"noDangerouslySetInnerHtml": "error"
|
||||
},
|
||||
"a11y": {
|
||||
"useAltText": "error",
|
||||
"useAnchorContent": "error",
|
||||
"useKeyWithClickEvents": "error",
|
||||
"noStaticElementInteractions": "error",
|
||||
"useButtonType": "error",
|
||||
"useValidAnchor": "error"
|
||||
}
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"includes": ["src/main.tsx"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"style": {
|
||||
"noNonNullAssertion": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -2,14 +2,14 @@
|
|||
// © AngelaMos | 2025
|
||||
// eslint.config.js
|
||||
// ===================
|
||||
import js from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import react from 'eslint-plugin-react';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||
import jsxA11y from 'eslint-plugin-jsx-a11y';
|
||||
import prettierConfig from 'eslint-config-prettier';
|
||||
import globals from 'globals';
|
||||
import js from '@eslint/js'
|
||||
import prettierConfig from 'eslint-config-prettier'
|
||||
import jsxA11y from 'eslint-plugin-jsx-a11y'
|
||||
import react from 'eslint-plugin-react'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import globals from 'globals'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
|
|
@ -47,15 +47,28 @@ export default tseslint.config(
|
|||
},
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }],
|
||||
'@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports', fixStyle: 'inline-type-imports' }],
|
||||
'@typescript-eslint/explicit-function-return-type': ['error', {
|
||||
allowExpressions: true,
|
||||
allowTypedFunctionExpressions: true,
|
||||
allowHigherOrderFunctions: true,
|
||||
allowDirectConstAssertionInArrowFunctions: true,
|
||||
allowedNames: ['Component']
|
||||
}],
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/consistent-type-imports': [
|
||||
'error',
|
||||
{ prefer: 'type-imports', fixStyle: 'inline-type-imports' },
|
||||
],
|
||||
'@typescript-eslint/explicit-function-return-type': [
|
||||
'error',
|
||||
{
|
||||
allowExpressions: true,
|
||||
allowTypedFunctionExpressions: true,
|
||||
allowHigherOrderFunctions: true,
|
||||
allowDirectConstAssertionInArrowFunctions: true,
|
||||
allowedNames: ['Component'],
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/naming-convention': 'off',
|
||||
'@typescript-eslint/no-non-null-assertion': 'error',
|
||||
'@typescript-eslint/array-type': ['error', { default: 'array' }],
|
||||
|
|
@ -63,13 +76,16 @@ export default tseslint.config(
|
|||
'@typescript-eslint/no-confusing-void-expression': 'off',
|
||||
'@typescript-eslint/no-unnecessary-condition': 'off',
|
||||
'@typescript-eslint/no-floating-promises': 'error',
|
||||
'@typescript-eslint/strict-boolean-expressions': ['error', {
|
||||
allowString: false,
|
||||
allowNumber: false,
|
||||
allowNullableObject: false,
|
||||
allowNullableString: true,
|
||||
allowAny: true
|
||||
}],
|
||||
'@typescript-eslint/strict-boolean-expressions': [
|
||||
'error',
|
||||
{
|
||||
allowString: false,
|
||||
allowNumber: false,
|
||||
allowNullableObject: false,
|
||||
allowNullableString: true,
|
||||
allowAny: true,
|
||||
},
|
||||
],
|
||||
|
||||
'@typescript-eslint/prefer-as-const': 'error',
|
||||
'@typescript-eslint/consistent-type-definitions': ['error', 'interface'],
|
||||
|
|
@ -78,7 +94,14 @@ export default tseslint.config(
|
|||
'react/jsx-uses-react': 'off',
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'react/jsx-no-leaked-render': ['error', { validStrategies: ['ternary'] }],
|
||||
'react/jsx-key': ['error', { checkFragmentShorthand: true, checkKeyMustBeforeSpread: true, warnOnDuplicates: true }],
|
||||
'react/jsx-key': [
|
||||
'error',
|
||||
{
|
||||
checkFragmentShorthand: true,
|
||||
checkKeyMustBeforeSpread: true,
|
||||
warnOnDuplicates: true,
|
||||
},
|
||||
],
|
||||
'react/jsx-no-useless-fragment': ['error', { allowExpressions: false }],
|
||||
'react/jsx-pascal-case': ['error', { allowAllCaps: false }],
|
||||
'react/no-array-index-key': 'warn',
|
||||
|
|
@ -106,8 +129,11 @@ export default tseslint.config(
|
|||
'object-shorthand': 'error',
|
||||
'no-nested-ternary': 'error',
|
||||
'max-depth': ['error', 6],
|
||||
'max-lines': ['error', { max: 2000, skipBlankLines: true, skipComments: true }],
|
||||
'complexity': ['error', 55],
|
||||
'max-lines': [
|
||||
'error',
|
||||
{ max: 2000, skipBlankLines: true, skipComments: true },
|
||||
],
|
||||
complexity: ['error', 55],
|
||||
},
|
||||
},
|
||||
|
||||
|
|
@ -118,5 +144,5 @@ export default tseslint.config(
|
|||
},
|
||||
},
|
||||
|
||||
prettierConfig,
|
||||
);
|
||||
prettierConfig
|
||||
)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -4,62 +4,66 @@
|
|||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"dev": "vite",
|
||||
"format": "prettier --write \"**/*.{ts,tsx,scss}\"",
|
||||
"format:check": "prettier --check \"**/*.{ts,tsx,scss}\"",
|
||||
"lint": "npm run lint:eslint && npm run lint:scss && npm run lint:types",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "biome check .",
|
||||
"lint:fix": "biome check --write .",
|
||||
"lint:eslint": "eslint . --ext .ts,.tsx --max-warnings 0",
|
||||
"format": "biome format --write .",
|
||||
"format:prettier": "prettier --write \"**/*.{ts,tsx,scss}\"",
|
||||
"format:check": "prettier --check \"**/*.{ts,tsx,scss}\"",
|
||||
"typecheck": "tsc -b",
|
||||
"lint:scss": "stylelint \"src/**/*.css\" --max-warnings 0",
|
||||
"lint:scss:fix": "stylelint \"src/**/*.css\" --fix",
|
||||
"lint:types": "tsc --project tsconfig.app.json --noEmit"
|
||||
"lint:scss:fix": "stylelint \"src/**/*.css\" --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toast": "^1.2.15",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-query": "^5.90.19",
|
||||
"@tanstack/react-query-devtools": "^5.91.2",
|
||||
"axios": "^1.12.2",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@tanstack/react-query-devtools": "^5.91.3",
|
||||
"axios": "^1.13.5",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"immer": "^11.1.3",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"immer": "^11.1.4",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-hook-form": "^7.71.1",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.12.0",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"recharts": "^3.7.0",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"sonner": "^2.0.7",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.10"
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.2",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@types/react": "^19.2.9",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.26",
|
||||
"globals": "^17.1.0",
|
||||
"globals": "^17.3.0",
|
||||
"husky": "^9.1.7",
|
||||
"prettier": "^3.8.1",
|
||||
"stylelint": "^17.0.0",
|
||||
"stylelint": "^17.3.0",
|
||||
"stylelint-config-prettier-scss": "^1.0.0",
|
||||
"stylelint-config-standard-scss": "^17.0.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.53.1",
|
||||
"typescript-eslint": "^8.56.0",
|
||||
"vite": "^7.3.1",
|
||||
"vite-tsconfig-paths": "^6.0.4"
|
||||
"vite-tsconfig-paths": "^6.1.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -3,41 +3,34 @@
|
|||
* Main application component
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
import { Toaster } from 'sonner';
|
||||
import { queryClient } from '@/lib/queryClient';
|
||||
import { router } from '@/router';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||
import { useEffect } from 'react'
|
||||
import { RouterProvider } from 'react-router-dom'
|
||||
import { Toaster } from 'sonner'
|
||||
import { queryClient } from '@/lib/queryClient'
|
||||
import { router } from '@/router'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
|
||||
const AuthInitializer = (): null => {
|
||||
const loadUserFromStorage = useAuthStore(
|
||||
(state) => state.loadUserFromStorage,
|
||||
);
|
||||
const loadUserFromStorage = useAuthStore((state) => state.loadUserFromStorage)
|
||||
|
||||
useEffect(() => {
|
||||
loadUserFromStorage();
|
||||
}, [loadUserFromStorage]);
|
||||
loadUserFromStorage()
|
||||
}, [loadUserFromStorage])
|
||||
|
||||
return null;
|
||||
};
|
||||
return null
|
||||
}
|
||||
|
||||
function App(): React.ReactElement {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthInitializer />
|
||||
<RouterProvider router={router} />
|
||||
<Toaster
|
||||
position="top-right"
|
||||
closeButton
|
||||
duration={4500}
|
||||
theme="dark"
|
||||
/>
|
||||
<Toaster position="top-right" closeButton duration={4500} theme="dark" />
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default App;
|
||||
export default App
|
||||
|
|
|
|||
|
|
@ -3,133 +3,125 @@
|
|||
// ©AngelaMos | 2025
|
||||
// ===========================
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { isAxiosError } from 'axios';
|
||||
import { Button } from '@/components/common/Button';
|
||||
import { Input } from '@/components/common/Input';
|
||||
import { useLogin } from '@/hooks/useAuth';
|
||||
import { useUIStore } from '@/store/uiStore';
|
||||
import { loginSchema } from '@/lib/validation';
|
||||
import './AuthForm.css';
|
||||
import { isAxiosError } from 'axios'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Button } from '@/components/common/Button'
|
||||
import { Input } from '@/components/common/Input'
|
||||
import { useLogin } from '@/hooks/useAuth'
|
||||
import { loginSchema } from '@/lib/validation'
|
||||
import { useUIStore } from '@/store/uiStore'
|
||||
import './AuthForm.css'
|
||||
|
||||
export const LoginForm = (): React.ReactElement => {
|
||||
const loginFormState = useUIStore((state) => state.loginForm);
|
||||
const setLoginFormField = useUIStore((state) => state.setLoginFormField);
|
||||
const clearLoginForm = useUIStore((state) => state.clearLoginForm);
|
||||
const clearExpiredData = useUIStore((state) => state.clearExpiredData);
|
||||
const loginFormState = useUIStore((state) => state.loginForm)
|
||||
const setLoginFormField = useUIStore((state) => state.setLoginFormField)
|
||||
const clearLoginForm = useUIStore((state) => state.clearLoginForm)
|
||||
const clearExpiredData = useUIStore((state) => state.clearExpiredData)
|
||||
|
||||
const [email, setEmail] = useState<string>('');
|
||||
const [password, setPassword] = useState<string>('');
|
||||
const [email, setEmail] = useState<string>('')
|
||||
const [password, setPassword] = useState<string>('')
|
||||
const [errors, setErrors] = useState<{
|
||||
email?: string;
|
||||
password?: string;
|
||||
}>({});
|
||||
email?: string
|
||||
password?: string
|
||||
}>({})
|
||||
|
||||
const { mutate: login, isPending, error } = useLogin();
|
||||
const { mutate: login, isPending, error } = useLogin()
|
||||
|
||||
useEffect(() => {
|
||||
clearExpiredData();
|
||||
setEmail(loginFormState.email);
|
||||
setPassword(loginFormState.password);
|
||||
}, [clearExpiredData, loginFormState.email, loginFormState.password]);
|
||||
clearExpiredData()
|
||||
setEmail(loginFormState.email)
|
||||
setPassword(loginFormState.password)
|
||||
}, [clearExpiredData, loginFormState.email, loginFormState.password])
|
||||
|
||||
const handleEmailChange = (value: string): void => {
|
||||
setEmail(value);
|
||||
setLoginFormField('email', value);
|
||||
setEmail(value)
|
||||
setLoginFormField('email', value)
|
||||
if (errors.email !== null && errors.email !== undefined) {
|
||||
validateField('email', value);
|
||||
validateField('email', value)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handlePasswordChange = (value: string): void => {
|
||||
setPassword(value);
|
||||
setLoginFormField('password', value);
|
||||
setPassword(value)
|
||||
setLoginFormField('password', value)
|
||||
if (errors.password !== null && errors.password !== undefined) {
|
||||
validateField('password', value);
|
||||
validateField('password', value)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const validateField = (
|
||||
field: 'email' | 'password',
|
||||
value: string,
|
||||
): void => {
|
||||
const validateField = (field: 'email' | 'password', value: string): void => {
|
||||
const result = loginSchema.safeParse({
|
||||
email: field === 'email' ? value : email,
|
||||
password: field === 'password' ? value : password,
|
||||
});
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
const fieldError = result.error.issues.find(
|
||||
(err) => err.path[0] === field,
|
||||
);
|
||||
const fieldError = result.error.issues.find((err) => err.path[0] === field)
|
||||
if (fieldError !== null && fieldError !== undefined) {
|
||||
setErrors((prev) => ({ ...prev, [field]: fieldError.message }));
|
||||
setErrors((prev) => ({ ...prev, [field]: fieldError.message }))
|
||||
} else {
|
||||
setErrors((prev) => {
|
||||
const { [field]: _, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
const { [field]: _, ...rest } = prev
|
||||
return rest
|
||||
})
|
||||
}
|
||||
} else {
|
||||
setErrors((prev) => {
|
||||
const { [field]: _, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
const { [field]: _, ...rest } = prev
|
||||
return rest
|
||||
})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleBlur = (field: 'email' | 'password'): void => {
|
||||
const value = field === 'email' ? email : password;
|
||||
validateField(field, value);
|
||||
};
|
||||
const value = field === 'email' ? email : password
|
||||
validateField(field, value)
|
||||
}
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const result = loginSchema.safeParse({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
const newErrors: { email?: string; password?: string } = {};
|
||||
const newErrors: { email?: string; password?: string } = {}
|
||||
|
||||
result.error.issues.forEach((err) => {
|
||||
const field = err.path[0] as keyof typeof newErrors;
|
||||
const field = err.path[0] as keyof typeof newErrors
|
||||
if (field !== null && field !== undefined) {
|
||||
newErrors[field] = err.message;
|
||||
newErrors[field] = err.message
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
setErrors(newErrors);
|
||||
return false;
|
||||
setErrors(newErrors)
|
||||
return false
|
||||
}
|
||||
|
||||
setErrors({});
|
||||
return true;
|
||||
};
|
||||
setErrors({})
|
||||
return true
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>): void => {
|
||||
e.preventDefault();
|
||||
e.preventDefault()
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
login(
|
||||
{ email, password },
|
||||
{
|
||||
onSuccess: () => {
|
||||
clearLoginForm();
|
||||
clearLoginForm()
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className="auth-form"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<form className="auth-form" onSubmit={handleSubmit}>
|
||||
<div className="auth-form__header">
|
||||
<h1 className="auth-form__title">Welcome Back</h1>
|
||||
<p className="auth-form__subtitle">Sign in to your account</p>
|
||||
|
|
@ -162,32 +154,22 @@ export const LoginForm = (): React.ReactElement => {
|
|||
</div>
|
||||
|
||||
{error !== null && error !== undefined && isAxiosError(error) ? (
|
||||
<div
|
||||
className="auth-form__error-message"
|
||||
role="alert"
|
||||
>
|
||||
{(error.response?.data as { detail?: string } | undefined)
|
||||
?.detail ?? 'Login failed. Please try again.'}
|
||||
<div className="auth-form__error-message" role="alert">
|
||||
{(error.response?.data as { detail?: string } | undefined)?.detail ??
|
||||
'Login failed. Please try again.'}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={isPending}
|
||||
disabled={isPending}
|
||||
>
|
||||
<Button type="submit" isLoading={isPending} disabled={isPending}>
|
||||
Sign In
|
||||
</Button>
|
||||
|
||||
<p className="auth-form__link">
|
||||
Don't have an account?{' '}
|
||||
<Link
|
||||
to="/register"
|
||||
className="auth-form__link-text"
|
||||
>
|
||||
<Link to="/register" className="auth-form__link-text">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,169 +3,157 @@
|
|||
// ©AngelaMos | 2025
|
||||
// ===========================
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { isAxiosError } from 'axios';
|
||||
import { Button } from '@/components/common/Button';
|
||||
import { Input } from '@/components/common/Input';
|
||||
import { useRegister } from '@/hooks/useAuth';
|
||||
import { useUIStore } from '@/store/uiStore';
|
||||
import { registerSchema } from '@/lib/validation';
|
||||
import './AuthForm.css';
|
||||
import { isAxiosError } from 'axios'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Button } from '@/components/common/Button'
|
||||
import { Input } from '@/components/common/Input'
|
||||
import { useRegister } from '@/hooks/useAuth'
|
||||
import { registerSchema } from '@/lib/validation'
|
||||
import { useUIStore } from '@/store/uiStore'
|
||||
import './AuthForm.css'
|
||||
|
||||
export const RegisterForm = (): React.ReactElement => {
|
||||
const registerFormState = useUIStore((state) => state.registerForm);
|
||||
const setRegisterFormField = useUIStore(
|
||||
(state) => state.setRegisterFormField,
|
||||
);
|
||||
const clearRegisterForm = useUIStore((state) => state.clearRegisterForm);
|
||||
const clearExpiredData = useUIStore((state) => state.clearExpiredData);
|
||||
const registerFormState = useUIStore((state) => state.registerForm)
|
||||
const setRegisterFormField = useUIStore((state) => state.setRegisterFormField)
|
||||
const clearRegisterForm = useUIStore((state) => state.clearRegisterForm)
|
||||
const clearExpiredData = useUIStore((state) => state.clearExpiredData)
|
||||
|
||||
const [email, setEmail] = useState<string>('');
|
||||
const [password, setPassword] = useState<string>('');
|
||||
const [confirmPassword, setConfirmPassword] = useState<string>('');
|
||||
const [email, setEmail] = useState<string>('')
|
||||
const [password, setPassword] = useState<string>('')
|
||||
const [confirmPassword, setConfirmPassword] = useState<string>('')
|
||||
const [errors, setErrors] = useState<{
|
||||
email?: string;
|
||||
password?: string;
|
||||
confirmPassword?: string;
|
||||
}>({});
|
||||
email?: string
|
||||
password?: string
|
||||
confirmPassword?: string
|
||||
}>({})
|
||||
|
||||
const { mutate: register, isPending, error } = useRegister();
|
||||
const { mutate: register, isPending, error } = useRegister()
|
||||
|
||||
useEffect(() => {
|
||||
clearExpiredData();
|
||||
setEmail(registerFormState.email);
|
||||
setPassword(registerFormState.password);
|
||||
setConfirmPassword(registerFormState.confirmPassword);
|
||||
clearExpiredData()
|
||||
setEmail(registerFormState.email)
|
||||
setPassword(registerFormState.password)
|
||||
setConfirmPassword(registerFormState.confirmPassword)
|
||||
}, [
|
||||
clearExpiredData,
|
||||
registerFormState.email,
|
||||
registerFormState.password,
|
||||
registerFormState.confirmPassword,
|
||||
]);
|
||||
])
|
||||
|
||||
const handleEmailChange = (value: string): void => {
|
||||
setEmail(value);
|
||||
setRegisterFormField('email', value);
|
||||
setEmail(value)
|
||||
setRegisterFormField('email', value)
|
||||
if (errors.email !== null && errors.email !== undefined) {
|
||||
validateField('email', value);
|
||||
validateField('email', value)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handlePasswordChange = (value: string): void => {
|
||||
setPassword(value);
|
||||
setRegisterFormField('password', value);
|
||||
setPassword(value)
|
||||
setRegisterFormField('password', value)
|
||||
if (errors.password !== null && errors.password !== undefined) {
|
||||
validateField('password', value);
|
||||
validateField('password', value)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleConfirmPasswordChange = (value: string): void => {
|
||||
setConfirmPassword(value);
|
||||
setRegisterFormField('confirmPassword', value);
|
||||
if (
|
||||
errors.confirmPassword !== null &&
|
||||
errors.confirmPassword !== undefined
|
||||
) {
|
||||
validateField('confirmPassword', value);
|
||||
setConfirmPassword(value)
|
||||
setRegisterFormField('confirmPassword', value)
|
||||
if (errors.confirmPassword !== null && errors.confirmPassword !== undefined) {
|
||||
validateField('confirmPassword', value)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const validateField = (
|
||||
field: 'email' | 'password' | 'confirmPassword',
|
||||
value: string,
|
||||
value: string
|
||||
): void => {
|
||||
const result = registerSchema.safeParse({
|
||||
email: field === 'email' ? value : email,
|
||||
password: field === 'password' ? value : password,
|
||||
confirmPassword: field === 'confirmPassword' ? value : confirmPassword,
|
||||
});
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
const fieldError = result.error.issues.find(
|
||||
(err) => err.path[0] === field,
|
||||
);
|
||||
const fieldError = result.error.issues.find((err) => err.path[0] === field)
|
||||
if (fieldError !== null && fieldError !== undefined) {
|
||||
setErrors((prev) => ({ ...prev, [field]: fieldError.message }));
|
||||
setErrors((prev) => ({ ...prev, [field]: fieldError.message }))
|
||||
} else {
|
||||
setErrors((prev) => {
|
||||
const { [field]: _, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
const { [field]: _, ...rest } = prev
|
||||
return rest
|
||||
})
|
||||
}
|
||||
} else {
|
||||
setErrors((prev) => {
|
||||
const { [field]: _, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
const { [field]: _, ...rest } = prev
|
||||
return rest
|
||||
})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleBlur = (
|
||||
field: 'email' | 'password' | 'confirmPassword',
|
||||
): void => {
|
||||
let value: string;
|
||||
const handleBlur = (field: 'email' | 'password' | 'confirmPassword'): void => {
|
||||
let value: string
|
||||
if (field === 'email') {
|
||||
value = email;
|
||||
value = email
|
||||
} else if (field === 'password') {
|
||||
value = password;
|
||||
value = password
|
||||
} else {
|
||||
value = confirmPassword;
|
||||
value = confirmPassword
|
||||
}
|
||||
validateField(field, value);
|
||||
};
|
||||
validateField(field, value)
|
||||
}
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const result = registerSchema.safeParse({
|
||||
email,
|
||||
password,
|
||||
confirmPassword,
|
||||
});
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
const newErrors: {
|
||||
email?: string;
|
||||
password?: string;
|
||||
confirmPassword?: string;
|
||||
} = {};
|
||||
email?: string
|
||||
password?: string
|
||||
confirmPassword?: string
|
||||
} = {}
|
||||
|
||||
result.error.issues.forEach((err) => {
|
||||
const field = err.path[0] as keyof typeof newErrors;
|
||||
const field = err.path[0] as keyof typeof newErrors
|
||||
if (field !== null && field !== undefined) {
|
||||
newErrors[field] = err.message;
|
||||
newErrors[field] = err.message
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
setErrors(newErrors);
|
||||
return false;
|
||||
setErrors(newErrors)
|
||||
return false
|
||||
}
|
||||
|
||||
setErrors({});
|
||||
return true;
|
||||
};
|
||||
setErrors({})
|
||||
return true
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>): void => {
|
||||
e.preventDefault();
|
||||
e.preventDefault()
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
register(
|
||||
{ email, password },
|
||||
{
|
||||
onSuccess: () => {
|
||||
clearRegisterForm();
|
||||
clearRegisterForm()
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className="auth-form"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<form className="auth-form" onSubmit={handleSubmit}>
|
||||
<div className="auth-form__header">
|
||||
<h1 className="auth-form__title">Create Account</h1>
|
||||
<p className="auth-form__subtitle">
|
||||
|
|
@ -212,32 +200,22 @@ export const RegisterForm = (): React.ReactElement => {
|
|||
</div>
|
||||
|
||||
{error !== null && error !== undefined && isAxiosError(error) ? (
|
||||
<div
|
||||
className="auth-form__error-message"
|
||||
role="alert"
|
||||
>
|
||||
{(error.response?.data as { detail?: string } | undefined)
|
||||
?.detail ?? 'Registration failed. Please try again.'}
|
||||
<div className="auth-form__error-message" role="alert">
|
||||
{(error.response?.data as { detail?: string } | undefined)?.detail ??
|
||||
'Registration failed. Please try again.'}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={isPending}
|
||||
disabled={isPending}
|
||||
>
|
||||
<Button type="submit" isLoading={isPending} disabled={isPending}>
|
||||
Create Account
|
||||
</Button>
|
||||
|
||||
<p className="auth-form__link">
|
||||
Already have an account?{' '}
|
||||
<Link
|
||||
to="/login"
|
||||
className="auth-form__link-text"
|
||||
>
|
||||
<Link to="/login" className="auth-form__link-text">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,14 +3,14 @@
|
|||
// ©AngelaMos | 2025
|
||||
// ===========================
|
||||
|
||||
import { type ButtonHTMLAttributes } from 'react';
|
||||
import './Button.css';
|
||||
import type { ButtonHTMLAttributes } from 'react'
|
||||
import './Button.css'
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'ghost';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
isLoading?: boolean;
|
||||
children: React.ReactNode;
|
||||
variant?: 'primary' | 'secondary' | 'ghost'
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
isLoading?: boolean
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export const Button = ({
|
||||
|
|
@ -30,5 +30,5 @@ export const Button = ({
|
|||
>
|
||||
{isLoading ? 'Loading...' : children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,50 +3,42 @@
|
|||
// ©AngelaMos | 2025
|
||||
// ===========================
|
||||
|
||||
import { type InputHTMLAttributes, forwardRef } from 'react';
|
||||
import './Input.css';
|
||||
import { forwardRef, type InputHTMLAttributes } from 'react'
|
||||
import './Input.css'
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label: string;
|
||||
error?: string | undefined;
|
||||
label: string
|
||||
error?: string | undefined
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ label, error, id, ...props }, ref) => {
|
||||
const inputId =
|
||||
id ?? `input-${label.toLowerCase().replace(/\s+/g, '-')}`;
|
||||
const errorId = `${inputId}-error`;
|
||||
const inputId = id ?? `input-${label.toLowerCase().replace(/\s+/g, '-')}`
|
||||
const errorId = `${inputId}-error`
|
||||
|
||||
return (
|
||||
<div className="input-wrapper">
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="input-label"
|
||||
>
|
||||
<label htmlFor={inputId} className="input-label">
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
className={`input ${error !== null && error !== undefined ? 'input--error' : ''}`}
|
||||
aria-invalid={error !== null && error !== undefined ? true : false}
|
||||
aria-invalid={!!(error !== null && error !== undefined)}
|
||||
aria-describedby={
|
||||
error !== null && error !== undefined ? errorId : undefined
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
{error !== null && error !== undefined ? (
|
||||
<p
|
||||
id={errorId}
|
||||
className="input-error"
|
||||
role="alert"
|
||||
>
|
||||
<p id={errorId} className="input-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Input.displayName = 'Input';
|
||||
Input.displayName = 'Input'
|
||||
|
|
|
|||
|
|
@ -3,25 +3,21 @@
|
|||
// ©AngelaMos | 2025
|
||||
// ===========================
|
||||
|
||||
import { TEST_TYPE_LABELS, type ScanTestType } from '@/config/constants';
|
||||
import './LoadingOverlay.css';
|
||||
import { type ScanTestType, TEST_TYPE_LABELS } from '@/config/constants'
|
||||
import './LoadingOverlay.css'
|
||||
|
||||
interface LoadingOverlayProps {
|
||||
tests: ScanTestType[];
|
||||
tests: ScanTestType[]
|
||||
}
|
||||
|
||||
export const LoadingOverlay = ({
|
||||
tests,
|
||||
}: LoadingOverlayProps): React.ReactElement => {
|
||||
return (
|
||||
<div
|
||||
className="loading-overlay"
|
||||
role="dialog"
|
||||
aria-label="Scan in progress"
|
||||
>
|
||||
<div className="loading-overlay" role="dialog" aria-label="Scan in progress">
|
||||
<div className="loading-overlay__content">
|
||||
<div className="loading-overlay__spinner">
|
||||
<div className="spinner"></div>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
<h2 className="loading-overlay__title">Running Security Scan</h2>
|
||||
<p className="loading-overlay__subtitle">
|
||||
|
|
@ -30,15 +26,12 @@ export const LoadingOverlay = ({
|
|||
</p>
|
||||
<div className="loading-overlay__tests">
|
||||
{tests.map((test) => (
|
||||
<div
|
||||
key={test}
|
||||
className="loading-overlay__test"
|
||||
>
|
||||
<div key={test} className="loading-overlay__test">
|
||||
{TEST_TYPE_LABELS[test]}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue