This commit is contained in:
CarterPerez-dev 2026-02-18 18:53:15 -05:00
parent 7c11ee50da
commit 95cc222302
131 changed files with 13823 additions and 17855 deletions

137
.github/workflows/lint.yml vendored Executable file → Normal file
View File

@ -23,19 +23,34 @@ jobs:
include: include:
- name: api-rate-limiter - name: api-rate-limiter
type: python type: python
path: PROJECTS/api-rate-limiter path: PROJECTS/advanced/api-rate-limiter
- name: dns-lookup - name: dns-lookup
type: python type: python
path: PROJECTS/dns-lookup path: PROJECTS/beginner/dns-lookup
- name: keylogger - name: keylogger
type: python type: python
path: PROJECTS/keylogger path: PROJECTS/beginner/keylogger
- name: api-security-scanner
type: typescript
path: PROJECTS/api-security-scanner/frontend
- name: docker-security-audit - name: docker-security-audit
type: go 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: defaults:
run: run:
@ -68,18 +83,32 @@ jobs:
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install -e ".[dev]" pip install -e ".[dev]"
# TypeScript Setup # Biome Setup
- name: Setup Node.js - name: Setup Node.js
if: matrix.type == 'typescript' if: matrix.type == 'biome'
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '20' node-version: '20'
cache: 'npm'
cache-dependency-path: ${{ matrix.path }}/package-lock.json
- name: Install TypeScript dependencies - name: Setup pnpm
if: matrix.type == 'typescript' if: matrix.type == 'biome'
run: npm install 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 # Go Setup
- name: Setup Go - name: Setup Go
@ -135,35 +164,20 @@ jobs:
cat mypy-output.txt cat mypy-output.txt
continue-on-error: true continue-on-error: true
# TypeScript Linting # Biome Linting
- name: Run ESLint - name: Run Biome
if: matrix.type == 'typescript' if: matrix.type == 'biome'
id: eslint id: biome
run: | run: |
echo "Running ESLint..." echo "Running Biome check..."
if npm run lint:eslint > eslint-output.txt 2>&1; then if npx @biomejs/biome check . > biome-output.txt 2>&1; then
echo "ESLINT_PASSED=true" >> $GITHUB_ENV echo "BIOME_PASSED=true" >> $GITHUB_ENV
echo "No ESLint errors found!" echo "No Biome errors found!"
else else
echo "ESLINT_PASSED=false" >> $GITHUB_ENV echo "BIOME_PASSED=false" >> $GITHUB_ENV
echo "ESLint found issues!" echo "Biome found issues!"
fi fi
cat eslint-output.txt cat biome-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
continue-on-error: true continue-on-error: true
# Go Linting # Go Linting
@ -247,46 +261,31 @@ jobs:
echo "<!-- lint-check-${{ matrix.name }}-marker -->" echo "<!-- lint-check-${{ matrix.name }}-marker -->"
} > lint-report.md } > lint-report.md
# Create Summary for TypeScript # Create Summary for Biome
- name: Create TypeScript Lint Summary - name: Create Biome Lint Summary
if: matrix.type == 'typescript' && github.event_name == 'pull_request' if: matrix.type == 'biome' && github.event_name == 'pull_request'
run: | run: |
{ {
echo "## Lint Results: ${{ matrix.name }}" echo "## Lint Results: ${{ matrix.name }}"
echo '' echo ''
# ESLint Status # Biome Status
if [[ "${{ env.ESLINT_PASSED }}" == "true" ]]; then if [[ "${{ env.BIOME_PASSED }}" == "true" ]]; then
echo '### ESLint: **Passed**' echo '### Biome: **Passed**'
echo 'No ESLint issues found.' echo 'No Biome issues found.'
else else
echo '### ESLint: **Issues Found**' echo '### Biome: **Issues Found**'
echo '<details><summary>View ESLint output</summary>' echo '<details><summary>View Biome output</summary>'
echo '' echo ''
echo '```' echo '```'
head -100 eslint-output.txt head -100 biome-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
echo '```' echo '```'
echo '</details>' echo '</details>'
fi fi
echo '' echo ''
# Overall Summary # Overall Summary
if [[ "${{ env.ESLINT_PASSED }}" == "true" ]] && [[ "${{ env.TSC_PASSED }}" == "true" ]]; then if [[ "${{ env.BIOME_PASSED }}" == "true" ]]; then
echo '---' echo '---'
echo '### All checks passed!' echo '### All checks passed!'
else else
@ -350,9 +349,9 @@ jobs:
echo "Python lint checks failed" echo "Python lint checks failed"
exit 1 exit 1
fi fi
elif [[ "${{ matrix.type }}" == "typescript" ]]; then elif [[ "${{ matrix.type }}" == "biome" ]]; then
if [[ "${{ env.ESLINT_PASSED }}" == "false" ]] || [[ "${{ env.TSC_PASSED }}" == "false" ]]; then if [[ "${{ env.BIOME_PASSED }}" == "false" ]]; then
echo "TypeScript lint checks failed" echo "Biome lint checks failed"
exit 1 exit 1
fi fi
elif [[ "${{ matrix.type }}" == "go" ]]; then elif [[ "${{ matrix.type }}" == "go" ]]; then

View File

@ -16,8 +16,50 @@ repos:
# TODO: add golangci checks for GO projects # 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 - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0 rev: v6.0.0

View File

@ -23,10 +23,10 @@ classifiers = [
] ]
dependencies = [ dependencies = [
"fastapi[standard]>=0.128.7", "fastapi[standard]>=0.129.0",
"pydantic>=2.12.5,<3.0.0", "pydantic>=2.12.5,<3.0.0",
"pydantic-settings>=2.12.0,<3.0.0", "pydantic-settings>=2.13.0",
"redis>=7.1.1", "redis>=7.2.0",
"pyjwt>=2.11.0", "pyjwt>=2.11.0",
] ]
@ -36,12 +36,12 @@ dev = [
"pytest-asyncio>=1.3.0", "pytest-asyncio>=1.3.0",
"pytest-cov>=7.0.0", "pytest-cov>=7.0.0",
"httpx>=0.28.1", "httpx>=0.28.1",
"fakeredis>=2.33.0", "fakeredis>=2.34.0",
"time-machine>=3.2.0", "time-machine>=3.2.0",
"asgi-lifespan>=2.1.0", "asgi-lifespan>=2.1.0",
"mypy>=1.19.1", "mypy>=1.19.1",
"types-redis>=4.6.0.20241004", "types-redis>=4.6.0.20241004",
"ruff>=0.15.0", "ruff>=0.15.1",
"pylint>=4.0.4", "pylint>=4.0.4",
"pylint-pydantic>=0.4.1", "pylint-pydantic>=0.4.1",
"pylint-per-file-ignores>=3.2.0", "pylint-per-file-ignores>=3.2.0",

View File

@ -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": { "vcs": {
"enabled": true, "enabled": true,
"clientKind": "git", "clientKind": "git",

View File

@ -10,7 +10,7 @@
"lint": "biome check .", "lint": "biome check .",
"lint:fix": "biome check --write .", "lint:fix": "biome check --write .",
"format": "biome format --write .", "format": "biome format --write .",
"typecheck": "tsc --noEmit", "typecheck": "tsc -b",
"lint:scss": "stylelint '**/*.scss'", "lint:scss": "stylelint '**/*.scss'",
"lint:scss:fix": "stylelint '**/*.scss' --fix" "lint:scss:fix": "stylelint '**/*.scss' --fix"
}, },
@ -28,7 +28,7 @@
"zustand": "^5.0.9" "zustand": "^5.0.9"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.3.8", "@biomejs/biome": "^2.4.2",
"@tanstack/react-query-devtools": "^5.91.1", "@tanstack/react-query-devtools": "^5.91.1",
"@types/node": "^24.10.2", "@types/node": "^24.10.2",
"@types/react": "^19.2.7", "@types/react": "^19.2.7",

View File

@ -46,8 +46,8 @@ importers:
version: 5.0.9(@types/react@19.2.7)(react@19.2.3) version: 5.0.9(@types/react@19.2.7)(react@19.2.3)
devDependencies: devDependencies:
'@biomejs/biome': '@biomejs/biome':
specifier: ^2.3.8 specifier: ^2.4.2
version: 2.3.11 version: 2.4.2
'@tanstack/react-query-devtools': '@tanstack/react-query-devtools':
specifier: ^5.91.1 specifier: ^5.91.1
version: 5.91.2(@tanstack/react-query@5.90.16(react@19.2.3))(react@19.2.3) 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==} resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@biomejs/biome@2.3.11': '@biomejs/biome@2.4.2':
resolution: {integrity: sha512-/zt+6qazBWguPG6+eWmiELqO+9jRsMZ/DBU3lfuU2ngtIQYzymocHhKiZRyrbra4aCOoyTg/BmY+6WH5mv9xmQ==} resolution: {integrity: sha512-vVE/FqLxNLbvYnFDYg3Xfrh1UdFhmPT5i+yPT9GE2nTUgI4rkqo5krw5wK19YHBd7aE7J6r91RRmb8RWwkjy6w==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
hasBin: true hasBin: true
'@biomejs/cli-darwin-arm64@2.3.11': '@biomejs/cli-darwin-arm64@2.4.2':
resolution: {integrity: sha512-/uXXkBcPKVQY7rc9Ys2CrlirBJYbpESEDme7RKiBD6MmqR2w3j0+ZZXRIL2xiaNPsIMMNhP1YnA+jRRxoOAFrA==} resolution: {integrity: sha512-3pEcKCP/1POKyaZZhXcxFl3+d9njmeAihZ17k8lL/1vk+6e0Cbf0yPzKItFiT+5Yh6TQA4uKvnlqe0oVZwRxCA==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@biomejs/cli-darwin-x64@2.3.11': '@biomejs/cli-darwin-x64@2.4.2':
resolution: {integrity: sha512-fh7nnvbweDPm2xEmFjfmq7zSUiox88plgdHF9OIW4i99WnXrAC3o2P3ag9judoUMv8FCSUnlwJCM1B64nO5Fbg==} resolution: {integrity: sha512-P7hK1jLVny+0R9UwyGcECxO6sjETxfPyBm/1dmFjnDOHgdDPjPqozByunrwh4xPKld8sxOr5eAsSqal5uKgeBg==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@biomejs/cli-linux-arm64-musl@2.3.11': '@biomejs/cli-linux-arm64-musl@2.4.2':
resolution: {integrity: sha512-XPSQ+XIPZMLaZ6zveQdwNjbX+QdROEd1zPgMwD47zvHV+tCGB88VH+aynyGxAHdzL+Tm/+DtKST5SECs4iwCLg==} resolution: {integrity: sha512-/x04YK9+7erw6tYEcJv9WXoBHcULI/wMOvNdAyE9S3JStZZ9yJyV67sWAI+90UHuDo/BDhq0d96LDqGlSVv7WA==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@biomejs/cli-linux-arm64@2.3.11': '@biomejs/cli-linux-arm64@2.4.2':
resolution: {integrity: sha512-l4xkGa9E7Uc0/05qU2lMYfN1H+fzzkHgaJoy98wO+b/7Gl78srbCRRgwYSW+BTLixTBrM6Ede5NSBwt7rd/i6g==} resolution: {integrity: sha512-DI3Mi7GT2zYNgUTDEbSjl3e1KhoP76OjQdm8JpvZYZWtVDRyLd3w8llSr2TWk1z+U3P44kUBWY3X7H9MD1/DGQ==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@biomejs/cli-linux-x64-musl@2.3.11': '@biomejs/cli-linux-x64-musl@2.4.2':
resolution: {integrity: sha512-vU7a8wLs5C9yJ4CB8a44r12aXYb8yYgBn+WeyzbMjaCMklzCv1oXr8x+VEyWodgJt9bDmhiaW/I0RHbn7rsNmw==} resolution: {integrity: sha512-wbBmTkeAoAYbOQ33f6sfKG7pcRSydQiF+dTYOBjJsnXO2mWEOQHllKlC2YVnedqZFERp2WZhFUoO7TNRwnwEHQ==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@biomejs/cli-linux-x64@2.3.11': '@biomejs/cli-linux-x64@2.4.2':
resolution: {integrity: sha512-/1s9V/H3cSe0r0Mv/Z8JryF5x9ywRxywomqZVLHAoa/uN0eY7F8gEngWKNS5vbbN/BsfpCG5yeBT5ENh50Frxg==} resolution: {integrity: sha512-GK2ErnrKpWFigYP68cXiCHK4RTL4IUWhK92AFS3U28X/nuAL5+hTuy6hyobc8JZRSt+upXt1nXChK+tuHHx4mA==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@biomejs/cli-win32-arm64@2.3.11': '@biomejs/cli-win32-arm64@2.4.2':
resolution: {integrity: sha512-PZQ6ElCOnkYapSsysiTy0+fYX+agXPlWugh6+eQ6uPKI3vKAqNp6TnMhoM3oY2NltSB89hz59o8xIfOdyhi9Iw==} resolution: {integrity: sha512-k2uqwLYrNNxnaoiW3RJxoMGnbKda8FuCmtYG3cOtVljs3CzWxaTR+AoXwKGHscC9thax9R4kOrtWqWN0+KdPTw==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
'@biomejs/cli-win32-x64@2.3.11': '@biomejs/cli-win32-x64@2.4.2':
resolution: {integrity: sha512-43VrG813EW+b5+YbDbz31uUsheX+qFKCpXeY9kfdAx+ww3naKxeVkTD9zLIWxUPfJquANMHrmW3wbe/037G0Qg==} resolution: {integrity: sha512-9ma7C4g8Sq3cBlRJD2yrsHXB1mnnEBdpy7PhvFrylQWQb4PoyCmPucdX7frvsSBQuFtIiKCrolPl/8tCZrKvgQ==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
@ -344,36 +348,42 @@ packages:
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm-musl@2.5.1': '@parcel/watcher-linux-arm-musl@2.5.1':
resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==} resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==}
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [musl]
'@parcel/watcher-linux-arm64-glibc@2.5.1': '@parcel/watcher-linux-arm64-glibc@2.5.1':
resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==} resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==}
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm64-musl@2.5.1': '@parcel/watcher-linux-arm64-musl@2.5.1':
resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==} resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==}
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@parcel/watcher-linux-x64-glibc@2.5.1': '@parcel/watcher-linux-x64-glibc@2.5.1':
resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==} resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==}
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@parcel/watcher-linux-x64-musl@2.5.1': '@parcel/watcher-linux-x64-musl@2.5.1':
resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==} resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==}
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@parcel/watcher-win32-arm64@2.5.1': '@parcel/watcher-win32-arm64@2.5.1':
resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==} resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==}
@ -432,24 +442,28 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.0-beta.50': '@rolldown/binding-linux-arm64-musl@1.0.0-beta.50':
resolution: {integrity: sha512-L0zRdH2oDPkmB+wvuTl+dJbXCsx62SkqcEqdM+79LOcB+PxbAxxjzHU14BuZIQdXcAVDzfpMfaHWzZuwhhBTcw==} resolution: {integrity: sha512-L0zRdH2oDPkmB+wvuTl+dJbXCsx62SkqcEqdM+79LOcB+PxbAxxjzHU14BuZIQdXcAVDzfpMfaHWzZuwhhBTcw==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@rolldown/binding-linux-x64-gnu@1.0.0-beta.50': '@rolldown/binding-linux-x64-gnu@1.0.0-beta.50':
resolution: {integrity: sha512-gyoI8o/TGpQd3OzkJnh1M2kxy1Bisg8qJ5Gci0sXm9yLFzEXIFdtc4EAzepxGvrT2ri99ar5rdsmNG0zP0SbIg==} resolution: {integrity: sha512-gyoI8o/TGpQd3OzkJnh1M2kxy1Bisg8qJ5Gci0sXm9yLFzEXIFdtc4EAzepxGvrT2ri99ar5rdsmNG0zP0SbIg==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.0.0-beta.50': '@rolldown/binding-linux-x64-musl@1.0.0-beta.50':
resolution: {integrity: sha512-zti8A7M+xFDpKlghpcCAzyOi+e5nfUl3QhU023ce5NCgUxRG5zGP2GR9LTydQ1rnIPwZUVBWd4o7NjZDaQxaXA==} resolution: {integrity: sha512-zti8A7M+xFDpKlghpcCAzyOi+e5nfUl3QhU023ce5NCgUxRG5zGP2GR9LTydQ1rnIPwZUVBWd4o7NjZDaQxaXA==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.0.0-beta.50': '@rolldown/binding-openharmony-arm64@1.0.0-beta.50':
resolution: {integrity: sha512-eZUssog7qljrrRU9Mi0eqYEPm3Ch0UwB+qlWPMKSUXHNqhm3TvDZarJQdTevGEfu3EHAXJvBIe0YFYr0TPVaMA==} resolution: {integrity: sha512-eZUssog7qljrrRU9Mi0eqYEPm3Ch0UwB+qlWPMKSUXHNqhm3TvDZarJQdTevGEfu3EHAXJvBIe0YFYr0TPVaMA==}
@ -954,24 +968,28 @@ packages:
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.30.2: lightningcss-linux-arm64-musl@1.30.2:
resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.30.2: lightningcss-linux-x64-gnu@1.30.2:
resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.30.2: lightningcss-linux-x64-musl@1.30.2:
resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.30.2: lightningcss-win32-arm64-msvc@1.30.2:
resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} 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-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5 '@babel/helper-validator-identifier': 7.28.5
'@biomejs/biome@2.3.11': '@biomejs/biome@2.4.2':
optionalDependencies: optionalDependencies:
'@biomejs/cli-darwin-arm64': 2.3.11 '@biomejs/cli-darwin-arm64': 2.4.2
'@biomejs/cli-darwin-x64': 2.3.11 '@biomejs/cli-darwin-x64': 2.4.2
'@biomejs/cli-linux-arm64': 2.3.11 '@biomejs/cli-linux-arm64': 2.4.2
'@biomejs/cli-linux-arm64-musl': 2.3.11 '@biomejs/cli-linux-arm64-musl': 2.4.2
'@biomejs/cli-linux-x64': 2.3.11 '@biomejs/cli-linux-x64': 2.4.2
'@biomejs/cli-linux-x64-musl': 2.3.11 '@biomejs/cli-linux-x64-musl': 2.4.2
'@biomejs/cli-win32-arm64': 2.3.11 '@biomejs/cli-win32-arm64': 2.4.2
'@biomejs/cli-win32-x64': 2.3.11 '@biomejs/cli-win32-x64': 2.4.2
'@biomejs/cli-darwin-arm64@2.3.11': '@biomejs/cli-darwin-arm64@2.4.2':
optional: true optional: true
'@biomejs/cli-darwin-x64@2.3.11': '@biomejs/cli-darwin-x64@2.4.2':
optional: true optional: true
'@biomejs/cli-linux-arm64-musl@2.3.11': '@biomejs/cli-linux-arm64-musl@2.4.2':
optional: true optional: true
'@biomejs/cli-linux-arm64@2.3.11': '@biomejs/cli-linux-arm64@2.4.2':
optional: true optional: true
'@biomejs/cli-linux-x64-musl@2.3.11': '@biomejs/cli-linux-x64-musl@2.4.2':
optional: true optional: true
'@biomejs/cli-linux-x64@2.3.11': '@biomejs/cli-linux-x64@2.4.2':
optional: true optional: true
'@biomejs/cli-win32-arm64@2.3.11': '@biomejs/cli-win32-arm64@2.4.2':
optional: true optional: true
'@biomejs/cli-win32-x64@2.3.11': '@biomejs/cli-win32-x64@2.4.2':
optional: true optional: true
'@cacheable/memory@2.0.7': '@cacheable/memory@2.0.7':

View File

@ -165,11 +165,13 @@ export const REPORT_STATUS_COLORS: Record<ReportStatus, string> = {
} }
export const isOpenStatus = (status: ReportStatus): boolean => { export const isOpenStatus = (status: ReportStatus): boolean => {
return ([ return (
ReportStatus.NEW, [
ReportStatus.TRIAGING, ReportStatus.NEW,
ReportStatus.NEEDS_MORE_INFO, ReportStatus.TRIAGING,
] as ReportStatus[]).includes(status) ReportStatus.NEEDS_MORE_INFO,
] as ReportStatus[]
).includes(status)
} }
export const isClosedStatus = (status: ReportStatus): boolean => { export const isClosedStatus = (status: ReportStatus): boolean => {

View File

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

View File

@ -2,22 +2,16 @@
// © AngelaMos | 2025 // © AngelaMos | 2025
// eslint.config.js // eslint.config.js
// =================== // ===================
import js from "@eslint/js"; import js from '@eslint/js'
import tseslint from "typescript-eslint"; import prettierConfig from 'eslint-config-prettier'
import solid from "eslint-plugin-solid/configs/typescript"; import jsxA11y from 'eslint-plugin-jsx-a11y'
import jsxA11y from "eslint-plugin-jsx-a11y"; import solid from 'eslint-plugin-solid/configs/typescript'
import prettierConfig from "eslint-config-prettier"; import globals from 'globals'
import globals from "globals"; import tseslint from 'typescript-eslint'
export default tseslint.config( export default tseslint.config(
{ {
ignores: [ ignores: ['dist', 'node_modules', '*.config.js', '*.config.ts', '*.min.js'],
"dist",
"node_modules",
"*.config.js",
"*.config.ts",
"*.min.js",
],
}, },
js.configs.recommended, js.configs.recommended,
@ -26,16 +20,16 @@ export default tseslint.config(
...tseslint.configs.stylisticTypeChecked, ...tseslint.configs.stylisticTypeChecked,
{ {
files: ["**/*.{ts,tsx}"], files: ['**/*.{ts,tsx}'],
...solid, ...solid,
plugins: { plugins: {
...solid.plugins, ...solid.plugins,
"jsx-a11y": jsxA11y, 'jsx-a11y': jsxA11y,
}, },
languageOptions: { languageOptions: {
parser: tseslint.parser, parser: tseslint.parser,
parserOptions: { parserOptions: {
project: ["./tsconfig.json"], project: ['./tsconfig.json'],
tsconfigRootDir: import.meta.dirname, tsconfigRootDir: import.meta.dirname,
ecmaFeatures: { jsx: true }, ecmaFeatures: { jsx: true },
}, },
@ -47,23 +41,23 @@ export default tseslint.config(
rules: { rules: {
...solid.rules, ...solid.rules,
"@typescript-eslint/no-unused-vars": [ '@typescript-eslint/no-unused-vars': [
"error", 'error',
{ {
argsIgnorePattern: "^_", argsIgnorePattern: '^_',
varsIgnorePattern: "^_", varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: "^_", caughtErrorsIgnorePattern: '^_',
}, },
], ],
"@typescript-eslint/consistent-type-imports": [ '@typescript-eslint/consistent-type-imports': [
"error", 'error',
{ {
prefer: "type-imports", prefer: 'type-imports',
fixStyle: "inline-type-imports", fixStyle: 'inline-type-imports',
}, },
], ],
"@typescript-eslint/explicit-function-return-type": [ '@typescript-eslint/explicit-function-return-type': [
"error", 'error',
{ {
allowExpressions: true, allowExpressions: true,
allowTypedFunctionExpressions: true, allowTypedFunctionExpressions: true,
@ -71,15 +65,15 @@ export default tseslint.config(
allowDirectConstAssertionInArrowFunctions: true, allowDirectConstAssertionInArrowFunctions: true,
}, },
], ],
"@typescript-eslint/naming-convention": "off", '@typescript-eslint/naming-convention': 'off',
"@typescript-eslint/no-non-null-assertion": "error", '@typescript-eslint/no-non-null-assertion': 'error',
"@typescript-eslint/array-type": ["error", { default: "array" }], '@typescript-eslint/array-type': ['error', { default: 'array' }],
"@typescript-eslint/no-explicit-any": "error", '@typescript-eslint/no-explicit-any': 'error',
"@typescript-eslint/no-confusing-void-expression": "off", '@typescript-eslint/no-confusing-void-expression': 'off',
"@typescript-eslint/no-unnecessary-condition": "off", '@typescript-eslint/no-unnecessary-condition': 'off',
"@typescript-eslint/no-floating-promises": "error", '@typescript-eslint/no-floating-promises': 'error',
"@typescript-eslint/strict-boolean-expressions": [ '@typescript-eslint/strict-boolean-expressions': [
"error", 'error',
{ {
allowString: false, allowString: false,
allowNumber: false, allowNumber: false,
@ -88,11 +82,11 @@ export default tseslint.config(
allowAny: true, allowAny: true,
}, },
], ],
"@typescript-eslint/prefer-as-const": "error", '@typescript-eslint/prefer-as-const': 'error',
"@typescript-eslint/consistent-type-definitions": ["error", "interface"], '@typescript-eslint/consistent-type-definitions': ['error', 'interface'],
"@typescript-eslint/restrict-template-expressions": "off", '@typescript-eslint/restrict-template-expressions': 'off',
"@typescript-eslint/no-misused-promises": [ '@typescript-eslint/no-misused-promises': [
"error", 'error',
{ {
checksVoidReturn: { checksVoidReturn: {
attributes: false, attributes: false,
@ -100,57 +94,57 @@ export default tseslint.config(
}, },
], ],
"solid/reactivity": "warn", 'solid/reactivity': 'warn',
"solid/no-destructure": "warn", 'solid/no-destructure': 'warn',
"solid/jsx-no-undef": "error", 'solid/jsx-no-undef': 'error',
"solid/no-react-specific-props": "error", 'solid/no-react-specific-props': 'error',
"solid/prefer-for": "warn", 'solid/prefer-for': 'warn',
"solid/self-closing-comp": "error", 'solid/self-closing-comp': 'error',
"solid/style-prop": "error", 'solid/style-prop': 'error',
"solid/no-innerhtml": "error", 'solid/no-innerhtml': 'error',
"solid/no-unknown-namespaces": "error", 'solid/no-unknown-namespaces': 'error',
"solid/event-handlers": [ 'solid/event-handlers': [
"error", 'error',
{ {
ignoreCase: false, ignoreCase: false,
warnOnSpread: true, warnOnSpread: true,
}, },
], ],
"solid/imports": "error", 'solid/imports': 'error',
"solid/no-proxy-apis": "off", 'solid/no-proxy-apis': 'off',
"jsx-a11y/alt-text": "error", 'jsx-a11y/alt-text': 'error',
"jsx-a11y/anchor-has-content": "error", 'jsx-a11y/anchor-has-content': 'error',
"jsx-a11y/click-events-have-key-events": "error", 'jsx-a11y/click-events-have-key-events': 'error',
"jsx-a11y/no-static-element-interactions": "error", 'jsx-a11y/no-static-element-interactions': 'error',
"jsx-a11y/no-noninteractive-element-interactions": "warn", 'jsx-a11y/no-noninteractive-element-interactions': 'warn',
"jsx-a11y/aria-props": "error", 'jsx-a11y/aria-props': 'error',
"jsx-a11y/aria-role": "error", 'jsx-a11y/aria-role': 'error',
"jsx-a11y/role-has-required-aria-props": "error", 'jsx-a11y/role-has-required-aria-props': 'error',
"no-console": ["warn", { allow: ["warn", "error"] }], 'no-console': ['warn', { allow: ['warn', 'error'] }],
"no-debugger": "error", 'no-debugger': 'error',
"no-alert": "error", 'no-alert': 'error',
"no-var": "error", 'no-var': 'error',
"prefer-const": "error", 'prefer-const': 'error',
"prefer-template": "error", 'prefer-template': 'error',
"object-shorthand": "error", 'object-shorthand': 'error',
"no-nested-ternary": "error", 'no-nested-ternary': 'error',
"max-depth": ["error", 6], 'max-depth': ['error', 6],
"max-lines": [ 'max-lines': [
"error", 'error',
{ max: 2000, skipBlankLines: true, skipComments: true }, { max: 2000, skipBlankLines: true, skipComments: true },
], ],
complexity: ["error", 55], complexity: ['error', 55],
}, },
}, },
{ {
files: ["src/index.tsx"], files: ['src/index.tsx'],
rules: { rules: {
"@typescript-eslint/no-non-null-assertion": "off", '@typescript-eslint/no-non-null-assertion': 'off',
}, },
}, },
prettierConfig prettierConfig
); )

File diff suppressed because it is too large Load Diff

View File

@ -7,36 +7,38 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"typecheck": "tsc --noEmit", "lint": "biome check .",
"lint:fix": "biome check --write .",
"lint:eslint": "eslint .", "lint:eslint": "eslint .",
"lint:eslint:fix": "eslint . --fix", "lint:eslint:fix": "eslint . --fix",
"lint:types": "tsc --noEmit", "format": "biome format --write .",
"lint": "npm run lint:eslint && npm run lint:types", "format:prettier": "prettier --write \"src/**/*.{ts,tsx,css}\"",
"format": "prettier --write \"src/**/*.{ts,tsx,css}\"", "format:check": "prettier --check \"src/**/*.{ts,tsx,css}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,css}\"" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@nanostores/persistent": "^1.2.0", "@nanostores/persistent": "^1.3.3",
"@nanostores/solid": "^1.1.1", "@nanostores/solid": "^1.1.1",
"@solidjs/router": "^0.15.3", "@solidjs/router": "^0.15.4",
"@tanstack/solid-query": "^5.90.22", "@tanstack/solid-query": "^5.90.23",
"nanostores": "^1.1.0", "nanostores": "^1.1.0",
"solid-js": "^1.9.10" "solid-js": "^1.9.11"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.4.2",
"@eslint/js": "^9.39.2", "@eslint/js": "^9.39.2",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.2.0",
"@types/node": "^25.0.10", "@types/node": "^25.2.3",
"eslint": "^9.39.2", "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-jsx-a11y": "^6.10.2",
"eslint-plugin-solid": "^0.14.5", "eslint-plugin-solid": "^0.14.5",
"globals": "^17.1.0", "globals": "^17.3.0",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.7.2", "prettier-plugin-tailwindcss": "^0.7.2",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.2.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.53.1", "typescript-eslint": "^8.56.0",
"vite": "^7.3.1", "vite": "^7.3.1",
"vite-plugin-solid": "^2.11.10" "vite-plugin-solid": "^2.11.10"
} }

File diff suppressed because it is too large Load Diff

View File

@ -2,14 +2,14 @@
// © AngelaMos | 2025 // © AngelaMos | 2025
// App.tsx // App.tsx
// =================== // ===================
import { Route } from "@solidjs/router"; import { Route } from '@solidjs/router'
import { type Component, lazy } from "solid-js"; import { type Component, lazy } from 'solid-js'
const Home = lazy(() => import("./pages/Home")); const Home = lazy(() => import('./pages/Home'))
const Register = lazy(() => import("./pages/Register")); const Register = lazy(() => import('./pages/Register'))
const Login = lazy(() => import("./pages/Login")); const Login = lazy(() => import('./pages/Login'))
const Chat = lazy(() => import("./pages/Chat")); const Chat = lazy(() => import('./pages/Chat'))
const NotFound = lazy(() => import("./pages/NotFound")); const NotFound = lazy(() => import('./pages/NotFound'))
const App: Component = () => { const App: Component = () => {
return ( return (
@ -20,7 +20,7 @@ const App: Component = () => {
<Route path="/chat" component={Chat} /> <Route path="/chat" component={Chat} />
<Route path="*" component={NotFound} /> <Route path="*" component={NotFound} />
</> </>
); )
}; }
export default App; export default App

View File

@ -2,8 +2,8 @@
* 8-bit styled auth card container * 8-bit styled auth card container
*/ */
import type { ParentProps, JSX } from "solid-js" import type { JSX, ParentProps } from 'solid-js'
import { Show } from "solid-js" import { Show } from 'solid-js'
interface AuthCardProps extends ParentProps { interface AuthCardProps extends ParentProps {
title: string title: string
@ -39,9 +39,7 @@ export function AuthCard(props: AuthCardProps): JSX.Element {
</div> </div>
<div class="mt-4 flex justify-center"> <div class="mt-4 flex justify-center">
<span class="font-pixel text-[8px] text-gray"> <span class="font-pixel text-[8px] text-gray">END-TO-END ENCRYPTED</span>
END-TO-END ENCRYPTED
</span>
</div> </div>
</div> </div>
) )
@ -49,13 +47,61 @@ export function AuthCard(props: AuthCardProps): JSX.Element {
function LockIcon(): JSX.Element { function LockIcon(): JSX.Element {
return ( return (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"> <svg
<rect x="7" y="4" width="10" height="2" fill="currentColor" class="text-orange" /> width="24"
<rect x="5" y="6" width="2" height="5" fill="currentColor" class="text-orange" /> height="24"
<rect x="17" y="6" width="2" height="5" fill="currentColor" class="text-orange" /> viewBox="0 0 24 24"
<rect x="4" y="11" width="16" height="2" fill="currentColor" class="text-orange" /> fill="none"
<rect x="4" y="13" width="16" height="8" fill="currentColor" class="text-orange" /> aria-hidden="true"
<rect x="11" y="15" width="2" height="4" fill="currentColor" class="text-black" /> >
<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> </svg>
) )
} }

View File

@ -3,41 +3,40 @@
// AuthForm.tsx // AuthForm.tsx
// =================== // ===================
import { createSignal, Show } from "solid-js" import { A } from '@solidjs/router'
import type { JSX } from "solid-js" import type { JSX } from 'solid-js'
import { A } from "@solidjs/router" import { createSignal, Show } from 'solid-js'
import { Input } from "../UI/Input" import { validateDisplayName, validateUsername } from '../../lib/validators'
import { PasskeyButton } from "./PasskeyButton" import { authService } from '../../services'
import { AuthCard } from "./AuthCard" import { showToast } from '../../stores'
import { import { ApiError } from '../../types'
validateUsername, import { Input } from '../UI/Input'
validateDisplayName, import { AuthCard } from './AuthCard'
} from "../../lib/validators" import { PasskeyButton } from './PasskeyButton'
import { authService } from "../../services"
import { showToast } from "../../stores"
import { ApiError } from "../../types"
type AuthMode = "login" | "register" type AuthMode = 'login' | 'register'
interface AuthFormProps { interface AuthFormProps {
mode: AuthMode mode: AuthMode
} }
export function AuthForm(props: AuthFormProps): JSX.Element { export function AuthForm(props: AuthFormProps): JSX.Element {
const [username, setUsername] = createSignal("") const [username, setUsername] = createSignal('')
const [displayName, setDisplayName] = createSignal("") const [displayName, setDisplayName] = createSignal('')
const [loading, setLoading] = createSignal(false) const [loading, setLoading] = createSignal(false)
const [usernameError, setUsernameError] = createSignal<string | undefined>() 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 => const subtitle = (): string =>
isRegister() isRegister()
? "REGISTER WITH A PASSKEY FOR SECURE, PASSWORDLESS AUTHENTICATION" ? 'REGISTER WITH A PASSKEY FOR SECURE, PASSWORDLESS AUTHENTICATION'
: "SIGN IN WITH YOUR PASSKEY TO CONTINUE" : 'SIGN IN WITH YOUR PASSKEY TO CONTINUE'
const validateForm = (): boolean => { const validateForm = (): boolean => {
let valid = true let valid = true
@ -73,29 +72,37 @@ export function AuthForm(props: AuthFormProps): JSX.Element {
try { try {
if (isRegister()) { if (isRegister()) {
await authService.register(username(), displayName()) await authService.register(username(), displayName())
showToast("success", "REGISTRATION COMPLETE", "YOUR PASSKEY HAS BEEN CREATED") showToast(
'success',
'REGISTRATION COMPLETE',
'YOUR PASSKEY HAS BEEN CREATED'
)
} else { } else {
const trimmedUsername = username().trim() const trimmedUsername = username().trim()
const usernameValue = trimmedUsername === "" ? undefined : trimmedUsername const usernameValue = trimmedUsername === '' ? undefined : trimmedUsername
await authService.login(usernameValue) await authService.login(usernameValue)
showToast("success", "LOGIN SUCCESSFUL", "WELCOME BACK") showToast('success', 'LOGIN SUCCESSFUL', 'WELCOME BACK')
} }
} catch (error) { } catch (error) {
let message = "AN UNEXPECTED ERROR OCCURRED" let message = 'AN UNEXPECTED ERROR OCCURRED'
if (error instanceof ApiError) { if (error instanceof ApiError) {
message = error.message.toUpperCase() message = error.message.toUpperCase()
} else if (error instanceof Error) { } else if (error instanceof Error) {
if (error.name === "NotAllowedError") { if (error.name === 'NotAllowedError') {
message = "PASSKEY OPERATION CANCELLED" message = 'PASSKEY OPERATION CANCELLED'
} else if (error.name === "InvalidStateError") { } else if (error.name === 'InvalidStateError') {
message = "PASSKEY ALREADY REGISTERED" message = 'PASSKEY ALREADY REGISTERED'
} else { } else {
message = error.message.toUpperCase() message = error.message.toUpperCase()
} }
} }
showToast("error", isRegister() ? "REGISTRATION FAILED" : "LOGIN FAILED", message) showToast(
'error',
isRegister() ? 'REGISTRATION FAILED' : 'LOGIN FAILED',
message
)
} finally { } finally {
setLoading(false) setLoading(false)
} }
@ -158,14 +165,14 @@ export function AuthForm(props: AuthFormProps): JSX.Element {
when={isRegister()} when={isRegister()}
fallback={ fallback={
<> <>
DON'T HAVE AN ACCOUNT?{" "} DON'T HAVE AN ACCOUNT?{' '}
<A href="/register" class="text-orange hover:underline"> <A href="/register" class="text-orange hover:underline">
REGISTER REGISTER
</A> </A>
</> </>
} }
> >
ALREADY HAVE AN ACCOUNT?{" "} ALREADY HAVE AN ACCOUNT?{' '}
<A href="/login" class="text-orange hover:underline"> <A href="/login" class="text-orange hover:underline">
SIGN IN SIGN IN
</A> </A>

View File

@ -3,20 +3,16 @@
// PasskeyButton.tsx // PasskeyButton.tsx
// =================== // ===================
import type { JSX } from 'solid-js'
import { createSignal, onMount, Show } from 'solid-js'
import { import {
Show,
createSignal,
onMount,
} from "solid-js"
import type { JSX } from "solid-js"
import { Button } from "../UI/Button"
import {
isWebAuthnSupported,
isPlatformAuthenticatorAvailable, isPlatformAuthenticatorAvailable,
} from "../../services" isWebAuthnSupported,
} from '../../services'
import { Button } from '../UI/Button'
interface PasskeyButtonProps { interface PasskeyButtonProps {
mode: "register" | "login" mode: 'register' | 'login'
onClick: () => void | Promise<void> onClick: () => void | Promise<void>
loading?: boolean loading?: boolean
disabled?: boolean disabled?: boolean
@ -40,9 +36,11 @@ export function PasskeyButton(props: PasskeyButtonProps): JSX.Element {
const buttonText = (): string => { const buttonText = (): string => {
if (!webAuthnSupported()) { 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 => { const isDisabled = (): boolean => {
@ -50,7 +48,7 @@ export function PasskeyButton(props: PasskeyButtonProps): JSX.Element {
} }
return ( return (
<div class={`flex flex-col gap-2 ${props.class ?? ""}`}> <div class={`flex flex-col gap-2 ${props.class ?? ''}`}>
<Button <Button
variant="primary" variant="primary"
size="lg" size="lg"
@ -80,7 +78,13 @@ export function PasskeyButton(props: PasskeyButtonProps): JSX.Element {
function PasskeyIcon(): JSX.Element { function PasskeyIcon(): JSX.Element {
return ( 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="2" y="6" width="2" height="6" />
<rect x="4" y="4" width="2" height="2" /> <rect x="4" y="4" width="2" height="2" />
<rect x="6" y="2" width="4" height="2" /> <rect x="6" y="2" width="4" height="2" />

View File

@ -2,6 +2,7 @@
// © AngelaMos | 2025 // © AngelaMos | 2025
// index.ts // index.ts
// =================== // ===================
export { PasskeyButton } from "./PasskeyButton"
export { AuthCard } from "./AuthCard" export { AuthCard } from './AuthCard'
export { AuthForm } from "./AuthForm" export { AuthForm } from './AuthForm'
export { PasskeyButton } from './PasskeyButton'

View File

@ -2,13 +2,14 @@
// © AngelaMos | 2025 // © AngelaMos | 2025
// ChatHeader.tsx // ChatHeader.tsx
// =================== // ===================
import { Show } from "solid-js"
import type { JSX } from "solid-js" import type { JSX } from 'solid-js'
import type { Room, Participant } from "../../types" import { Show } from 'solid-js'
import { OnlineStatus } from "./OnlineStatus" import { getUserStatus } from '../../stores'
import { EncryptionBadge } from "./EncryptionBadge" import type { Participant, Room } from '../../types'
import { getUserStatus } from "../../stores" import { IconButton } from '../UI/IconButton'
import { IconButton } from "../UI/IconButton" import { EncryptionBadge } from './EncryptionBadge'
import { OnlineStatus } from './OnlineStatus'
interface ChatHeaderProps { interface ChatHeaderProps {
room: Room | null room: Room | null
@ -19,16 +20,16 @@ interface ChatHeaderProps {
export function ChatHeader(props: ChatHeaderProps): JSX.Element { export function ChatHeader(props: ChatHeaderProps): JSX.Element {
const otherParticipant = (): Participant | null => { const otherParticipant = (): Participant | null => {
if (props.room?.type !== "direct") return null if (props.room?.type !== 'direct') return null
return props.room.participants[0] ?? null return props.room.participants[0] ?? null
} }
const displayName = (): string => { const displayName = (): string => {
const room = props.room const room = props.room
if (room === null) return "CHAT" if (room === null) return 'CHAT'
if (room.name !== undefined && room.name !== null) return room.name if (room.name !== undefined && room.name !== null) return room.name
const other = otherParticipant() const other = otherParticipant()
return other?.display_name ?? other?.username ?? "CHAT" return other?.display_name ?? other?.username ?? 'CHAT'
} }
const initials = (): string => { const initials = (): string => {
@ -37,14 +38,14 @@ export function ChatHeader(props: ChatHeaderProps): JSX.Element {
} }
return ( 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 justify-between">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="relative"> <div class="relative">
<div class="w-10 h-10 bg-black border-2 border-orange flex items-center justify-center"> <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"> <span class="font-pixel text-[10px] text-orange">{initials()}</span>
{initials()}
</span>
</div> </div>
<Show when={otherParticipant()} keyed> <Show when={otherParticipant()} keyed>
{(participant) => ( {(participant) => (
@ -59,14 +60,12 @@ export function ChatHeader(props: ChatHeaderProps): JSX.Element {
</div> </div>
<div> <div>
<h2 class="font-pixel text-xs text-white"> <h2 class="font-pixel text-xs text-white">{displayName()}</h2>
{displayName()}
</h2>
<div class="flex items-center gap-2 mt-0.5"> <div class="flex items-center gap-2 mt-0.5">
<Show when={props.room?.is_encrypted}> <Show when={props.room?.is_encrypted}>
<EncryptionBadge isEncrypted showLabel size="sm" /> <EncryptionBadge isEncrypted showLabel size="sm" />
</Show> </Show>
<Show when={props.room?.type === "group"}> <Show when={props.room?.type === 'group'}>
<span class="font-pixel text-[8px] text-gray"> <span class="font-pixel text-[8px] text-gray">
{props.room?.participants.length ?? 0} MEMBERS {props.room?.participants.length ?? 0} MEMBERS
</span> </span>
@ -107,7 +106,13 @@ export function ChatHeader(props: ChatHeaderProps): JSX.Element {
function InfoIcon(): JSX.Element { function InfoIcon(): JSX.Element {
return ( 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="3" width="2" height="2" />
<rect x="7" y="7" width="2" height="6" /> <rect x="7" y="7" width="2" height="6" />
</svg> </svg>
@ -116,7 +121,13 @@ function InfoIcon(): JSX.Element {
function SettingsIcon(): JSX.Element { function SettingsIcon(): JSX.Element {
return ( 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="1" width="2" height="3" />
<rect x="7" y="12" width="2" height="3" /> <rect x="7" y="12" width="2" height="3" />
<rect x="1" y="7" width="3" height="2" /> <rect x="1" y="7" width="3" height="2" />

View File

@ -2,11 +2,12 @@
// © AngelaMos | 2025 // © AngelaMos | 2025
// ChatInput.tsx // ChatInput.tsx
// =================== // ===================
import { createSignal, Show, onCleanup } from "solid-js"
import type { JSX } from "solid-js" import type { JSX } from 'solid-js'
import { Button } from "../UI/Button" import { createSignal, onCleanup, Show } from 'solid-js'
import { wsManager } from "../../websocket" import { MESSAGE_MAX_LENGTH } from '../../types'
import { MESSAGE_MAX_LENGTH } from "../../types" import { wsManager } from '../../websocket'
import { Button } from '../UI/Button'
interface ChatInputProps { interface ChatInputProps {
roomId: string roomId: string
@ -17,14 +18,15 @@ interface ChatInputProps {
} }
export function ChatInput(props: ChatInputProps): JSX.Element { export function ChatInput(props: ChatInputProps): JSX.Element {
const [message, setMessage] = createSignal("") const [message, setMessage] = createSignal('')
const [isTyping, setIsTyping] = createSignal(false) const [isTyping, setIsTyping] = createSignal(false)
let typingTimeout: ReturnType<typeof setTimeout> | undefined let typingTimeout: ReturnType<typeof setTimeout> | undefined
let inputRef: HTMLInputElement | undefined let inputRef: HTMLInputElement | undefined
const charCount = (): number => message().length const charCount = (): number => message().length
const isOverLimit = (): boolean => charCount() > MESSAGE_MAX_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 handleInput = (e: Event): void => {
const target = e.target as HTMLInputElement const target = e.target as HTMLInputElement
@ -50,7 +52,7 @@ export function ChatInput(props: ChatInputProps): JSX.Element {
const content = message().trim() const content = message().trim()
props.onSend?.(content) props.onSend?.(content)
setMessage("") setMessage('')
if (isTyping()) { if (isTyping()) {
setIsTyping(false) setIsTyping(false)
@ -61,7 +63,7 @@ export function ChatInput(props: ChatInputProps): JSX.Element {
} }
const handleKeyDown = (e: KeyboardEvent): void => { const handleKeyDown = (e: KeyboardEvent): void => {
if (e.key === "Enter" && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault() e.preventDefault()
handleSend() handleSend()
} }
@ -77,10 +79,14 @@ export function ChatInput(props: ChatInputProps): JSX.Element {
}) })
return ( 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 gap-2">
<div class="flex-1 flex items-center bg-black border-2 border-orange"> <div class="flex-1 flex items-center bg-black border-2 border-orange">
<span class="font-pixel text-[10px] text-orange px-2">&gt;&gt;&gt;</span> <span class="font-pixel text-[10px] text-orange px-2">
&gt;&gt;&gt;
</span>
<input <input
ref={inputRef} ref={inputRef}
type="text" type="text"
@ -106,11 +112,11 @@ export function ChatInput(props: ChatInputProps): JSX.Element {
<div class="flex items-center justify-between mt-2"> <div class="flex items-center justify-between mt-2">
<Show when={isOverLimit()}> <Show when={isOverLimit()}>
<span class="font-pixel text-[8px] text-error"> <span class="font-pixel text-[8px] text-error">MESSAGE TOO LONG</span>
MESSAGE TOO LONG
</span>
</Show> </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} {charCount()}/{MESSAGE_MAX_LENGTH}
</span> </span>
</div> </div>
@ -120,7 +126,13 @@ export function ChatInput(props: ChatInputProps): JSX.Element {
function SendIcon(): JSX.Element { function SendIcon(): JSX.Element {
return ( 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="0" y="5" width="8" height="2" />
<rect x="6" y="3" width="2" height="2" /> <rect x="6" y="3" width="2" height="2" />
<rect x="6" y="7" width="2" height="2" /> <rect x="6" y="7" width="2" height="2" />

View File

@ -2,13 +2,14 @@
// © AngelaMos | 2025 // © AngelaMos | 2025
// ConversationItem.tsx // ConversationItem.tsx
// =================== // ===================
import { Show } from "solid-js"
import type { JSX } from "solid-js" import type { JSX } from 'solid-js'
import type { Room, Participant } from "../../types" import { Show } from 'solid-js'
import { OnlineStatus } from "./OnlineStatus" import { formatRelativeTime } from '../../lib/date'
import { EncryptionBadge } from "./EncryptionBadge" import { getUserStatus } from '../../stores'
import { getUserStatus } from "../../stores" import type { Participant, Room } from '../../types'
import { formatRelativeTime } from "../../lib/date" import { EncryptionBadge } from './EncryptionBadge'
import { OnlineStatus } from './OnlineStatus'
interface ConversationItemProps { interface ConversationItemProps {
room: Room room: Room
@ -19,14 +20,14 @@ interface ConversationItemProps {
export function ConversationItem(props: ConversationItemProps): JSX.Element { export function ConversationItem(props: ConversationItemProps): JSX.Element {
const otherParticipant = (): Participant | null => { const otherParticipant = (): Participant | null => {
if (props.room.type !== "direct") return null if (props.room.type !== 'direct') return null
return props.room.participants[0] ?? null return props.room.participants[0] ?? null
} }
const displayName = (): string => { const displayName = (): string => {
if (props.room.name) return props.room.name if (props.room.name) return props.room.name
const other = otherParticipant() const other = otherParticipant()
return other?.display_name ?? other?.username ?? "CHAT" return other?.display_name ?? other?.username ?? 'CHAT'
} }
const initials = (): string => { const initials = (): string => {
@ -36,20 +37,20 @@ export function ConversationItem(props: ConversationItemProps): JSX.Element {
const lastMessagePreview = (): string => { const lastMessagePreview = (): string => {
const msg = props.room.last_message const msg = props.room.last_message
if (msg === null || msg === undefined) return "NO MESSAGES" if (msg === null || msg === undefined) return 'NO MESSAGES'
if (msg.is_encrypted) return "ENCRYPTED MESSAGE" if (msg.is_encrypted) return 'ENCRYPTED MESSAGE'
const content = msg.content.slice(0, 40) const content = msg.content.slice(0, 40)
return content.length < msg.content.length ? `${content}...` : content return content.length < msg.content.length ? `${content}...` : content
} }
const lastMessageTime = (): string => { const lastMessageTime = (): string => {
const msg = props.room.last_message const msg = props.room.last_message
if (msg === null || msg === undefined) return "" if (msg === null || msg === undefined) return ''
return formatRelativeTime(msg.created_at) return formatRelativeTime(msg.created_at)
} }
const containerClasses = (): string => { 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) { if (props.isActive) {
return `${base} bg-orange text-black border-orange` return `${base} bg-orange text-black border-orange`
} }
@ -59,13 +60,17 @@ export function ConversationItem(props: ConversationItemProps): JSX.Element {
return ( return (
<button <button
type="button" type="button"
class={`${containerClasses()} ${props.class ?? ""}`} class={`${containerClasses()} ${props.class ?? ''}`}
onClick={() => props.onClick()} onClick={() => props.onClick()}
> >
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<div class="relative flex-shrink-0"> <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"}`}> <div
<span class={`font-pixel text-[10px] ${props.isActive ? "text-black" : "text-orange"}`}> 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()} {initials()}
</span> </span>
</div> </div>
@ -83,16 +88,22 @@ export function ConversationItem(props: ConversationItemProps): JSX.Element {
<div class="flex-1 min-w-0 text-left"> <div class="flex-1 min-w-0 text-left">
<div class="flex items-center justify-between gap-2"> <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()} {displayName()}
</span> </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()} {lastMessageTime()}
</span> </span>
</div> </div>
<div class="flex items-center justify-between gap-2 mt-1"> <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()} {lastMessagePreview()}
</span> </span>
<div class="flex items-center gap-1 flex-shrink-0"> <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}> <Show when={props.room.unread_count > 0}>
<div class="min-w-[18px] h-[18px] bg-orange flex items-center justify-center"> <div class="min-w-[18px] h-[18px] bg-orange flex items-center justify-center">
<span class="font-pixel text-[8px] text-black"> <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> </span>
</div> </div>
</Show> </Show>

View File

@ -2,13 +2,14 @@
// © AngelaMos | 2025 // © AngelaMos | 2025
// ConversationList.tsx // ConversationList.tsx
// =================== // ===================
import { Show, For, createMemo } from "solid-js"
import type { JSX } from "solid-js" import { useStore } from '@nanostores/solid'
import { useStore } from "@nanostores/solid" import type { JSX } from 'solid-js'
import { $rooms, $activeRoomId, setActiveRoom } from "../../stores" import { createMemo, For, Show } from 'solid-js'
import { ConversationItem } from "./ConversationItem" import { $activeRoomId, $rooms, setActiveRoom } from '../../stores'
import { Spinner } from "../UI/Spinner" import type { Room } from '../../types'
import type { Room } from "../../types" import { Spinner } from '../UI/Spinner'
import { ConversationItem } from './ConversationItem'
interface ConversationListProps { interface ConversationListProps {
loading?: boolean loading?: boolean
@ -35,11 +36,9 @@ export function ConversationList(props: ConversationListProps): JSX.Element {
} }
return ( 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"> <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"> <h2 class="font-pixel text-[10px] text-orange">CONVERSATIONS</h2>
CONVERSATIONS
</h2>
<Show when={props.onNewChat}> <Show when={props.onNewChat}>
<button <button
type="button" type="button"
@ -112,7 +111,13 @@ function EmptyConversations(props: EmptyConversationsProps): JSX.Element {
function PlusIcon(): JSX.Element { function PlusIcon(): JSX.Element {
return ( 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="5" y="1" width="2" height="10" />
<rect x="1" y="5" width="10" height="2" /> <rect x="1" y="5" width="10" height="2" />
</svg> </svg>
@ -121,7 +126,14 @@ function PlusIcon(): JSX.Element {
function ChatIcon(): JSX.Element { function ChatIcon(): JSX.Element {
return ( 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="6" y="6" width="28" height="3" />
<rect x="3" y="9" width="3" height="20" /> <rect x="3" y="9" width="3" height="20" />
<rect x="34" y="9" width="3" height="20" /> <rect x="34" y="9" width="3" height="20" />

View File

@ -2,31 +2,31 @@
// © AngelaMos | 2025 // © AngelaMos | 2025
// EncryptionBadge.tsx // 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 { interface EncryptionBadgeProps {
isEncrypted: boolean isEncrypted: boolean
showLabel?: boolean showLabel?: boolean
size?: "sm" | "md" size?: 'sm' | 'md'
class?: string class?: string
} }
export function EncryptionBadge(props: EncryptionBadgeProps): JSX.Element { export function EncryptionBadge(props: EncryptionBadgeProps): JSX.Element {
const iconSize = (): string => props.size === "sm" ? "w-3 h-3" : "w-4 h-4" 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 textSize = (): string =>
props.size === 'sm' ? 'text-[6px]' : 'text-[8px]'
return ( return (
<Show when={props.isEncrypted}> <Show when={props.isEncrypted}>
<div <div
class={`flex items-center gap-1 ${props.class ?? ""}`} class={`flex items-center gap-1 ${props.class ?? ''}`}
title="END-TO-END ENCRYPTED" title="END-TO-END ENCRYPTED"
> >
<LockIcon class={`${iconSize()} text-success`} /> <LockIcon class={`${iconSize()} text-success`} />
<Show when={props.showLabel}> <Show when={props.showLabel}>
<span class={`font-pixel ${textSize()} text-success`}> <span class={`font-pixel ${textSize()} text-success`}>E2E</span>
E2E
</span>
</Show> </Show>
</div> </div>
</Show> </Show>
@ -50,31 +50,37 @@ function LockIcon(props: LockIconProps): JSX.Element {
<rect x="11" y="4" width="1" height="4" /> <rect x="11" y="4" width="1" height="4" />
<rect x="3" y="8" width="10" height="1" /> <rect x="3" y="8" width="10" height="1" />
<rect x="3" y="9" width="10" height="5" /> <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> </svg>
) )
} }
interface UnencryptedBadgeProps { interface UnencryptedBadgeProps {
showLabel?: boolean showLabel?: boolean
size?: "sm" | "md" size?: 'sm' | 'md'
class?: string class?: string
} }
export function UnencryptedBadge(props: UnencryptedBadgeProps): JSX.Element { export function UnencryptedBadge(props: UnencryptedBadgeProps): JSX.Element {
const iconSize = (): string => props.size === "sm" ? "w-3 h-3" : "w-4 h-4" 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 textSize = (): string =>
props.size === 'sm' ? 'text-[6px]' : 'text-[8px]'
return ( return (
<div <div
class={`flex items-center gap-1 ${props.class ?? ""}`} class={`flex items-center gap-1 ${props.class ?? ''}`}
title="NOT ENCRYPTED" title="NOT ENCRYPTED"
> >
<UnlockIcon class={`${iconSize()} text-gray`} /> <UnlockIcon class={`${iconSize()} text-gray`} />
<Show when={props.showLabel}> <Show when={props.showLabel}>
<span class={`font-pixel ${textSize()} text-gray`}> <span class={`font-pixel ${textSize()} text-gray`}>UNENCRYPTED</span>
UNENCRYPTED
</span>
</Show> </Show>
</div> </div>
) )
@ -93,7 +99,14 @@ function UnlockIcon(props: LockIconProps): JSX.Element {
<rect x="11" y="2" width="1" height="2" /> <rect x="11" y="2" width="1" height="2" />
<rect x="3" y="8" width="10" height="1" /> <rect x="3" y="8" width="10" height="1" />
<rect x="3" y="9" width="10" height="5" /> <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> </svg>
) )
} }

View File

@ -2,11 +2,12 @@
// © AngelaMos | 2025 // © AngelaMos | 2025
// MessageBubble.tsx // MessageBubble.tsx
// =================== // ===================
import { Show, Switch, Match } from "solid-js"
import type { JSX } from "solid-js" import type { JSX } from 'solid-js'
import type { Message, MessageStatus } from "../../types" import { Match, Show, Switch } from 'solid-js'
import { EncryptionBadge } from "./EncryptionBadge" import { formatTime } from '../../lib/date'
import { formatTime } from "../../lib/date" import type { Message, MessageStatus } from '../../types'
import { EncryptionBadge } from './EncryptionBadge'
interface MessageBubbleProps { interface MessageBubbleProps {
message: Message message: Message
@ -17,7 +18,7 @@ interface MessageBubbleProps {
export function MessageBubble(props: MessageBubbleProps): JSX.Element { export function MessageBubble(props: MessageBubbleProps): JSX.Element {
const bubbleClasses = (): string => { const bubbleClasses = (): string => {
const base = "max-w-[70%] p-4" const base = 'max-w-[70%] p-4'
if (props.isOwnMessage) { if (props.isOwnMessage) {
return `${base} bg-orange text-black` return `${base} bg-orange text-black`
} }
@ -25,7 +26,9 @@ export function MessageBubble(props: MessageBubbleProps): JSX.Element {
} }
return ( 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()}> <div class={bubbleClasses()}>
<Show when={props.showSender === true && !props.isOwnMessage}> <Show when={props.showSender === true && !props.isOwnMessage}>
<div class="font-pixel text-[10px] text-orange mb-2"> <div class="font-pixel text-[10px] text-orange mb-2">
@ -33,7 +36,10 @@ export function MessageBubble(props: MessageBubbleProps): JSX.Element {
</div> </div>
</Show> </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} {props.message.content}
</div> </div>
@ -41,7 +47,9 @@ export function MessageBubble(props: MessageBubbleProps): JSX.Element {
<Show when={props.message.is_encrypted}> <Show when={props.message.is_encrypted}>
<EncryptionBadge isEncrypted size="sm" /> <EncryptionBadge isEncrypted size="sm" />
</Show> </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)} {formatTime(props.message.created_at)}
</span> </span>
<Show when={props.isOwnMessage}> <Show when={props.isOwnMessage}>
@ -59,20 +67,20 @@ interface MessageStatusIconProps {
function MessageStatusIcon(props: MessageStatusIconProps): JSX.Element { function MessageStatusIcon(props: MessageStatusIconProps): JSX.Element {
return ( return (
<Switch fallback={<></>}> <Switch fallback={null}>
<Match when={props.status === "sending"}> <Match when={props.status === 'sending'}>
<ClockIcon class="w-3 h-3 text-black/40" /> <ClockIcon class="w-3 h-3 text-black/40" />
</Match> </Match>
<Match when={props.status === "sent"}> <Match when={props.status === 'sent'}>
<CheckIcon class="w-3 h-3 text-black/60" /> <CheckIcon class="w-3 h-3 text-black/60" />
</Match> </Match>
<Match when={props.status === "delivered"}> <Match when={props.status === 'delivered'}>
<DoubleCheckIcon class="w-3 h-3 text-black/60" /> <DoubleCheckIcon class="w-3 h-3 text-black/60" />
</Match> </Match>
<Match when={props.status === "read"}> <Match when={props.status === 'read'}>
<DoubleCheckIcon class="w-3 h-3 text-success" /> <DoubleCheckIcon class="w-3 h-3 text-success" />
</Match> </Match>
<Match when={props.status === "failed"}> <Match when={props.status === 'failed'}>
<ErrorIcon class="w-3 h-3 text-error" /> <ErrorIcon class="w-3 h-3 text-error" />
</Match> </Match>
</Switch> </Switch>
@ -85,7 +93,12 @@ interface IconProps {
function ClockIcon(props: IconProps): JSX.Element { function ClockIcon(props: IconProps): JSX.Element {
return ( 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="5" y="1" width="2" height="1" />
<rect x="3" y="2" width="2" height="1" /> <rect x="3" y="2" width="2" height="1" />
<rect x="7" 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 { function CheckIcon(props: IconProps): JSX.Element {
return ( 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="2" y="6" width="2" height="2" />
<rect x="4" y="8" width="2" height="2" /> <rect x="4" y="8" width="2" height="2" />
<rect x="6" y="6" 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 { function DoubleCheckIcon(props: IconProps): JSX.Element {
return ( 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="0" y="6" width="2" height="2" />
<rect x="2" y="8" width="2" height="2" /> <rect x="2" y="8" width="2" height="2" />
<rect x="4" y="6" 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 { function ErrorIcon(props: IconProps): JSX.Element {
return ( 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="5" y="1" width="2" height="1" />
<rect x="3" y="2" width="2" height="1" /> <rect x="3" y="2" width="2" height="1" />
<rect x="7" y="2" width="2" height="1" /> <rect x="7" y="2" width="2" height="1" />

View File

@ -2,15 +2,19 @@
// © AngelaMos | 2025 // © AngelaMos | 2025
// MessageList.tsx // MessageList.tsx
// =================== // ===================
import { Show, For, createEffect, createSignal, onMount } from "solid-js"
import type { JSX } from "solid-js" import { useStore } from '@nanostores/solid'
import { useStore } from "@nanostores/solid" import type { JSX } from 'solid-js'
import { $activeRoomMessages, $activeRoomPendingMessages } from "../../stores" import { createEffect, createSignal, For, onMount, Show } from 'solid-js'
import { $userId } from "../../stores" import {
import { MessageBubble } from "./MessageBubble" $activeRoomMessages,
import { TypingIndicator } from "./TypingIndicator" $activeRoomPendingMessages,
import { Spinner } from "../UI/Spinner" $userId,
import type { Message } from "../../types" } from '../../stores'
import type { Message } from '../../types'
import { Spinner } from '../UI/Spinner'
import { MessageBubble } from './MessageBubble'
import { TypingIndicator } from './TypingIndicator'
interface MessageListProps { interface MessageListProps {
roomId: string roomId: string
@ -39,7 +43,12 @@ export function MessageList(props: MessageListProps): JSX.Element {
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50 const isAtBottom = scrollHeight - scrollTop - clientHeight < 50
setAutoScroll(isAtBottom) 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() props.onLoadMore()
} }
} }
@ -61,10 +70,10 @@ export function MessageList(props: MessageListProps): JSX.Element {
const groups = new Map<string, Message[]>() const groups = new Map<string, Message[]>()
for (const msg of msgs) { for (const msg of msgs) {
const date = new Date(msg.created_at).toLocaleDateString("en-US", { const date = new Date(msg.created_at).toLocaleDateString('en-US', {
weekday: "short", weekday: 'short',
month: "short", month: 'short',
day: "numeric", day: 'numeric',
}) })
const existing = groups.get(date) ?? [] const existing = groups.get(date) ?? []
@ -77,7 +86,7 @@ export function MessageList(props: MessageListProps): JSX.Element {
return ( return (
<div <div
ref={containerRef} 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} onScroll={handleScroll}
> >
<Show when={props.loading}> <Show when={props.loading}>
@ -86,10 +95,7 @@ export function MessageList(props: MessageListProps): JSX.Element {
</div> </div>
</Show> </Show>
<Show <Show when={allMessages().length > 0} fallback={<EmptyMessages />}>
when={allMessages().length > 0}
fallback={<EmptyMessages />}
>
<For each={Array.from(groupMessagesByDate(allMessages()).entries())}> <For each={Array.from(groupMessagesByDate(allMessages()).entries())}>
{([date, dateMessages]) => ( {([date, dateMessages]) => (
<div class="mb-6"> <div class="mb-6">
@ -101,7 +107,9 @@ export function MessageList(props: MessageListProps): JSX.Element {
index() > 0 ? dateMessages[index() - 1] : undefined index() > 0 ? dateMessages[index() - 1] : undefined
const showSender = (): boolean => { const showSender = (): boolean => {
const prev = prevMessage() const prev = prevMessage()
return prev === undefined || prev.sender_id !== message.sender_id return (
prev === undefined || prev.sender_id !== message.sender_id
)
} }
return ( return (
@ -130,12 +138,8 @@ function EmptyMessages(): JSX.Element {
return ( return (
<div class="h-full flex flex-col items-center justify-center py-12"> <div class="h-full flex flex-col items-center justify-center py-12">
<MessageIcon /> <MessageIcon />
<p class="font-pixel text-[10px] text-gray mt-4"> <p class="font-pixel text-[10px] text-gray mt-4">NO MESSAGES YET</p>
NO MESSAGES YET <p class="font-pixel text-[8px] text-gray mt-1">START THE CONVERSATION</p>
</p>
<p class="font-pixel text-[8px] text-gray mt-1">
START THE CONVERSATION
</p>
</div> </div>
) )
} }
@ -148,9 +152,7 @@ function DateSeparator(props: DateSeparatorProps): JSX.Element {
return ( return (
<div class="flex items-center gap-4 my-4"> <div class="flex items-center gap-4 my-4">
<div class="flex-1 h-px bg-dark-gray" /> <div class="flex-1 h-px bg-dark-gray" />
<span class="font-pixel text-[8px] text-gray uppercase"> <span class="font-pixel text-[8px] text-gray uppercase">{props.date}</span>
{props.date}
</span>
<div class="flex-1 h-px bg-dark-gray" /> <div class="flex-1 h-px bg-dark-gray" />
</div> </div>
) )
@ -158,7 +160,14 @@ function DateSeparator(props: DateSeparatorProps): JSX.Element {
function MessageIcon(): JSX.Element { function MessageIcon(): JSX.Element {
return ( 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="8" y="8" width="32" height="4" />
<rect x="4" y="12" width="4" height="24" /> <rect x="4" y="12" width="4" height="24" />
<rect x="40" y="12" width="4" height="24" /> <rect x="40" y="12" width="4" height="24" />

View File

@ -2,13 +2,14 @@
// © AngelaMos | 2025 // © AngelaMos | 2025
// NewConversation.tsx // NewConversation.tsx
// =================== // ===================
import { createSignal, Show } from "solid-js"
import type { JSX } from "solid-js" import type { JSX } from 'solid-js'
import { Modal } from "../UI/Modal" import { createSignal, Show } from 'solid-js'
import { Button } from "../UI/Button" import { showToast } from '../../stores'
import { UserSearch } from "./UserSearch" import type { User } from '../../types'
import type { User } from "../../types" import { Button } from '../UI/Button'
import { showToast } from "../../stores" import { Modal } from '../UI/Modal'
import { UserSearch } from './UserSearch'
interface NewConversationProps { interface NewConversationProps {
isOpen: boolean isOpen: boolean
@ -32,10 +33,14 @@ export function NewConversation(props: NewConversationProps): JSX.Element {
try { try {
await props.onCreateRoom(user.id) 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() handleClose()
} catch (_error: unknown) { } catch (_error: unknown) {
showToast("error", "FAILED TO CREATE CHAT", "PLEASE TRY AGAIN") showToast('error', 'FAILED TO CREATE CHAT', 'PLEASE TRY AGAIN')
} finally { } finally {
setLoading(false) setLoading(false)
} }
@ -56,9 +61,9 @@ export function NewConversation(props: NewConversationProps): JSX.Element {
> >
<div class="space-y-4"> <div class="space-y-4">
<div> <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 SEARCH FOR A USER
</label> </span>
<UserSearch <UserSearch
onSelect={handleUserSelect} onSelect={handleUserSelect}
placeholder="ENTER USERNAME..." placeholder="ENTER USERNAME..."
@ -97,12 +102,7 @@ export function NewConversation(props: NewConversationProps): JSX.Element {
</Show> </Show>
<div class="flex items-center gap-3 pt-4 border-t-2 border-dark-gray"> <div class="flex items-center gap-3 pt-4 border-t-2 border-dark-gray">
<Button <Button variant="secondary" size="md" onClick={handleClose} fullWidth>
variant="secondary"
size="md"
onClick={handleClose}
fullWidth
>
CANCEL CANCEL
</Button> </Button>
<Button <Button
@ -123,7 +123,13 @@ export function NewConversation(props: NewConversationProps): JSX.Element {
function CloseIcon(): JSX.Element { function CloseIcon(): JSX.Element {
return ( 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="1" y="2" width="2" height="2" />
<rect x="2" y="3" width="2" height="2" /> <rect x="2" y="3" width="2" height="2" />
<rect x="3" y="4" width="2" height="2" /> <rect x="3" y="4" width="2" height="2" />

View File

@ -2,57 +2,57 @@
// © AngelaMos | 2025 // © AngelaMos | 2025
// OnlineStatus.tsx // OnlineStatus.tsx
// =================== // ===================
import { Show } from "solid-js"
import type { JSX } from "solid-js" import type { JSX } from 'solid-js'
import type { PresenceStatus } from "../../types" import { Show } from 'solid-js'
import type { PresenceStatus } from '../../types'
interface OnlineStatusProps { interface OnlineStatusProps {
status: PresenceStatus status: PresenceStatus
size?: "sm" | "md" | "lg" size?: 'sm' | 'md' | 'lg'
showLabel?: boolean showLabel?: boolean
class?: string class?: string
} }
const SIZE_MAP = { const SIZE_MAP = {
sm: "w-2 h-2", sm: 'w-2 h-2',
md: "w-3 h-3", md: 'w-3 h-3',
lg: "w-4 h-4", lg: 'w-4 h-4',
} }
export function OnlineStatus(props: OnlineStatusProps): JSX.Element { 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 => { const statusColor = (): string => {
switch (props.status) { switch (props.status) {
case "online": case 'online':
return "bg-success" return 'bg-success'
case "away": case 'away':
return "bg-away" return 'bg-away'
case "offline": case 'offline':
return "bg-offline" return 'bg-offline'
default: default:
return "bg-gray" return 'bg-gray'
} }
} }
const statusLabel = (): string => { const statusLabel = (): string => {
switch (props.status) { switch (props.status) {
case "online": case 'online':
return "ONLINE" return 'ONLINE'
case "away": case 'away':
return "AWAY" return 'AWAY'
case "offline": case 'offline':
return "OFFLINE" return 'OFFLINE'
default: default:
return "UNKNOWN" return 'UNKNOWN'
} }
} }
return ( return (
<div class={`flex items-center gap-2 ${props.class ?? ""}`}> <div class={`flex items-center gap-2 ${props.class ?? ''}`}>
<div <output
class={`${sizeClass()} ${statusColor()}`} class={`${sizeClass()} ${statusColor()} block`}
role="status"
aria-label={statusLabel()} aria-label={statusLabel()}
/> />
<Show when={props.showLabel === true}> <Show when={props.showLabel === true}>

View File

@ -2,10 +2,11 @@
// © AngelaMos | 2025 // © AngelaMos | 2025
// TypingIndicator.tsx // TypingIndicator.tsx
// =================== // ===================
import { Show, For } from "solid-js"
import type { JSX } from "solid-js" import { useStore } from '@nanostores/solid'
import { useStore } from "@nanostores/solid" import type { JSX } from 'solid-js'
import { $activeRoomTypingUsernames } from "../../stores" import { For, Show } from 'solid-js'
import { $activeRoomTypingUsernames } from '../../stores'
interface TypingIndicatorProps { interface TypingIndicatorProps {
class?: string class?: string
@ -16,7 +17,7 @@ export function TypingIndicator(props: TypingIndicatorProps): JSX.Element {
const typingText = (): string => { const typingText = (): string => {
const users = typingUsernames() 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 === 1) return `${users[0]} IS TYPING`
if (users.length === 2) return `${users[0]} AND ${users[1]} ARE TYPING` if (users.length === 2) return `${users[0]} AND ${users[1]} ARE TYPING`
return `${users[0]} AND ${users.length - 1} OTHERS ARE TYPING` return `${users[0]} AND ${users.length - 1} OTHERS ARE TYPING`
@ -24,11 +25,9 @@ export function TypingIndicator(props: TypingIndicatorProps): JSX.Element {
return ( return (
<Show when={typingUsernames().length > 0}> <Show when={typingUsernames().length > 0}>
<div class={`flex items-center gap-2 ${props.class ?? ""}`}> <div class={`flex items-center gap-2 ${props.class ?? ''}`}>
<TypingDots /> <TypingDots />
<span class="font-pixel text-[8px] text-gray"> <span class="font-pixel text-[8px] text-gray">{typingText()}</span>
{typingText()}
</span>
</div> </div>
</Show> </Show>
) )
@ -41,7 +40,7 @@ function TypingDots(): JSX.Element {
{(index) => ( {(index) => (
<div <div
class="w-1 h-1 bg-orange animate-bounce-pixel" class="w-1 h-1 bg-orange animate-bounce-pixel"
style={{ "animation-delay": `${index * 150}ms` }} style={{ 'animation-delay': `${index * 150}ms` }}
/> />
)} )}
</For> </For>
@ -54,10 +53,12 @@ interface TypingIndicatorInlineProps {
class?: string class?: string
} }
export function TypingIndicatorInline(props: TypingIndicatorInlineProps): JSX.Element { export function TypingIndicatorInline(
props: TypingIndicatorInlineProps
): JSX.Element {
const typingText = (): string => { const typingText = (): string => {
const users = props.usernames 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 === 1) return `${users[0]} IS TYPING`
if (users.length === 2) return `${users[0]} AND ${users[1]} ARE TYPING` if (users.length === 2) return `${users[0]} AND ${users[1]} ARE TYPING`
return `${users[0]} AND ${users.length - 1} OTHERS ARE TYPING` return `${users[0]} AND ${users.length - 1} OTHERS ARE TYPING`
@ -65,11 +66,9 @@ export function TypingIndicatorInline(props: TypingIndicatorInlineProps): JSX.El
return ( return (
<Show when={props.usernames.length > 0}> <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 /> <TypingDots />
<span class="font-pixel text-[8px] text-gray"> <span class="font-pixel text-[8px] text-gray">{typingText()}</span>
{typingText()}
</span>
</div> </div>
</Show> </Show>
) )

View File

@ -2,13 +2,14 @@
// © AngelaMos | 2025 // © AngelaMos | 2025
// UserSearch.tsx // UserSearch.tsx
// =================== // ===================
import { createSignal, Show, For, onCleanup } from "solid-js"
import type { JSX } from "solid-js" import type { JSX } from 'solid-js'
import { Input } from "../UI/Input" import { createSignal, For, onCleanup, Show } from 'solid-js'
import { Spinner } from "../UI/Spinner" import { USER_SEARCH_DEFAULT_LIMIT, USER_SEARCH_MIN_LENGTH } from '../../config'
import { api } from "../../lib/api-client" import { api } from '../../lib/api-client'
import { USER_SEARCH_MIN_LENGTH, USER_SEARCH_DEFAULT_LIMIT } from "../../config" import type { User } from '../../types'
import type { User } from "../../types" import { Input } from '../UI/Input'
import { Spinner } from '../UI/Spinner'
interface UserSearchProps { interface UserSearchProps {
onSelect: (user: User) => void onSelect: (user: User) => void
@ -24,7 +25,7 @@ interface SearchResult {
} }
export function UserSearch(props: UserSearchProps): JSX.Element { export function UserSearch(props: UserSearchProps): JSX.Element {
const [query, setQuery] = createSignal("") const [query, setQuery] = createSignal('')
const [result, setResult] = createSignal<SearchResult>({ const [result, setResult] = createSignal<SearchResult>({
users: [], users: [],
loading: false, loading: false,
@ -61,14 +62,15 @@ export function UserSearch(props: UserSearchProps): JSX.Element {
}) })
setResult({ users: response.users, loading: false, error: null }) setResult({ users: response.users, loading: false, error: null })
} catch (error) { } 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 }) setResult({ users: [], loading: false, error: errorMessage })
} }
} }
const handleSelect = (user: User): void => { const handleSelect = (user: User): void => {
props.onSelect(user) props.onSelect(user)
setQuery("") setQuery('')
setResult({ users: [], loading: false, error: null }) setResult({ users: [], loading: false, error: null })
setIsFocused(false) setIsFocused(false)
} }
@ -79,7 +81,10 @@ export function UserSearch(props: UserSearchProps): JSX.Element {
} }
const showResults = (): boolean => { const showResults = (): boolean => {
return isFocused() && (result().loading || filteredUsers().length > 0 || result().error !== null) return (
isFocused() &&
(result().loading || filteredUsers().length > 0 || result().error !== null)
)
} }
onCleanup(() => { onCleanup(() => {
@ -89,10 +94,10 @@ export function UserSearch(props: UserSearchProps): JSX.Element {
}) })
return ( return (
<div class={`relative ${props.class ?? ""}`}> <div class={`relative ${props.class ?? ''}`}>
<Input <Input
name="user-search" name="user-search"
placeholder={props.placeholder ?? "SEARCH USERS..."} placeholder={props.placeholder ?? 'SEARCH USERS...'}
value={query()} value={query()}
onInput={handleInput} onInput={handleInput}
onFocus={() => setIsFocused(true)} onFocus={() => setIsFocused(true)}
@ -143,11 +148,16 @@ export function UserSearch(props: UserSearchProps): JSX.Element {
</For> </For>
</Show> </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"> <div class="p-3">
<span class="font-pixel text-[10px] text-gray"> <span class="font-pixel text-[10px] text-gray">NO USERS FOUND</span>
NO USERS FOUND
</span>
</div> </div>
</Show> </Show>
</div> </div>
@ -158,7 +168,14 @@ export function UserSearch(props: UserSearchProps): JSX.Element {
function SearchIcon(): JSX.Element { function SearchIcon(): JSX.Element {
return ( 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="4" y="1" width="4" height="1" />
<rect x="2" y="2" width="2" height="1" /> <rect x="2" y="2" width="2" height="1" />
<rect x="8" y="2" width="2" height="1" /> <rect x="8" y="2" width="2" height="1" />

View File

@ -2,14 +2,15 @@
// © AngelaMos | 2025 // © AngelaMos | 2025
// index.ts // index.ts
// =================== // ===================
export * from "./ConversationList"
export * from "./ConversationItem" export * from './ChatHeader'
export * from "./MessageList" export * from './ChatInput'
export * from "./MessageBubble" export * from './ConversationItem'
export * from "./ChatInput" export * from './ConversationList'
export * from "./ChatHeader" export * from './EncryptionBadge'
export * from "./TypingIndicator" export * from './MessageBubble'
export * from "./OnlineStatus" export * from './MessageList'
export * from "./EncryptionBadge" export * from './NewConversation'
export * from "./UserSearch" export * from './OnlineStatus'
export * from "./NewConversation" export * from './TypingIndicator'
export * from './UserSearch'

View File

@ -2,12 +2,12 @@
* Main application shell layout * Main application shell layout
*/ */
import type { ParentProps, JSX } from "solid-js" import { useStore } from '@nanostores/solid'
import { Show } from "solid-js" import type { JSX, ParentProps } from 'solid-js'
import { useStore } from "@nanostores/solid" import { Show } from 'solid-js'
import { $sidebarOpen, $isMobile } from "../../stores" import { $isMobile, $sidebarOpen } from '../../stores'
import { Sidebar } from "./Sidebar" import { Header } from './Header'
import { Header } from "./Header" import { Sidebar } from './Sidebar'
interface AppShellProps extends ParentProps { interface AppShellProps extends ParentProps {
showSidebar?: boolean showSidebar?: boolean
@ -34,15 +34,19 @@ export function AppShell(props: AppShellProps): JSX.Element {
</div> </div>
</Show> </Show>
<main class="flex-1 overflow-hidden bg-black"> <main class="flex-1 overflow-hidden bg-black">{props.children}</main>
{props.children}
</main>
</div> </div>
<Show when={isMobile() && sidebarOpen()}> <Show when={isMobile() && sidebarOpen()}>
<div <button
class="fixed inset-0 z-30 bg-black/80" 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)} onClick={() => $sidebarOpen.set(false)}
onKeyDown={(e) => {
if (e.key === 'Escape') $sidebarOpen.set(false)
}}
/> />
</Show> </Show>
</div> </div>

View File

@ -2,20 +2,20 @@
* 8-bit styled header/navbar component * 8-bit styled header/navbar component
*/ */
import type { JSX } from "solid-js" import { useStore } from '@nanostores/solid'
import { Show } from "solid-js" import { A, useLocation } from '@solidjs/router'
import { A, useLocation } from "@solidjs/router" import type { JSX } from 'solid-js'
import { useStore } from "@nanostores/solid" import { Show } from 'solid-js'
import { import {
$currentUser,
$isAuthenticated, $isAuthenticated,
$sidebarOpen, $sidebarOpen,
$currentUser,
toggleSidebar,
openModal, openModal,
} from "../../stores" toggleSidebar,
import { IconButton } from "../UI/IconButton" } from '../../stores'
import { Badge } from "../UI/Badge" import { Avatar } from '../UI/Avatar'
import { Avatar } from "../UI/Avatar" import { Badge } from '../UI/Badge'
import { IconButton } from '../UI/IconButton'
export function Header(): JSX.Element { export function Header(): JSX.Element {
const isAuthenticated = useStore($isAuthenticated) const isAuthenticated = useStore($isAuthenticated)
@ -30,7 +30,7 @@ export function Header(): JSX.Element {
<Show when={isAuthenticated()}> <Show when={isAuthenticated()}>
<IconButton <IconButton
icon={sidebarOpen() ? <CloseMenuIcon /> : <MenuIcon />} icon={sidebarOpen() ? <CloseMenuIcon /> : <MenuIcon />}
ariaLabel={sidebarOpen() ? "Close menu" : "Open menu"} ariaLabel={sidebarOpen() ? 'Close menu' : 'Open menu'}
onClick={toggleSidebar} onClick={toggleSidebar}
size="sm" size="sm"
/> />
@ -75,20 +75,26 @@ export function Header(): JSX.Element {
icon={<SearchIcon />} icon={<SearchIcon />}
ariaLabel="Search" ariaLabel="Search"
size="sm" size="sm"
onClick={() => openModal("new-conversation")} onClick={() => openModal('new-conversation')}
/> />
<Show when={currentUser()} keyed> <Show when={currentUser()} keyed>
{(user) => ( {(user) => (
<A <A
href="/settings" href="/settings"
class={`flex items-center gap-2 px-2 py-1 border-2 ${ class={`flex items-center gap-2 px-2 py-1 border-2 ${
location.pathname === "/settings" location.pathname === '/settings'
? "border-orange bg-orange text-black" ? 'border-orange bg-orange text-black'
: "border-transparent text-white hover:text-orange" : 'border-transparent text-white hover:text-orange'
}`} }`}
> >
<Avatar alt={user.display_name} size="xs" fallback={user.display_name.slice(0, 2)} /> <Avatar
<span class="font-pixel text-[10px] hidden sm:block">{user.display_name}</span> 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> </A>
)} )}
</Show> </Show>
@ -101,7 +107,13 @@ export function Header(): JSX.Element {
function MenuIcon(): JSX.Element { function MenuIcon(): JSX.Element {
return ( 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="3" width="12" height="2" />
<rect x="2" y="7" width="12" height="2" /> <rect x="2" y="7" width="12" height="2" />
<rect x="2" y="11" 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 { function CloseMenuIcon(): JSX.Element {
return ( 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="3" y="3" width="2" height="2" />
<rect x="5" y="5" width="2" height="2" /> <rect x="5" y="5" width="2" height="2" />
<rect x="7" y="7" 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 { function LockIcon(): JSX.Element {
return ( return (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none"> <svg
<rect x="6" y="3" width="8" height="2" fill="currentColor" class="text-orange" /> width="20"
<rect x="4" y="5" width="2" height="4" fill="currentColor" class="text-orange" /> height="20"
<rect x="14" y="5" width="2" height="4" fill="currentColor" class="text-orange" /> viewBox="0 0 20 20"
<rect x="3" y="9" width="14" height="2" fill="currentColor" class="text-orange" /> fill="none"
<rect x="3" y="11" width="14" height="6" fill="currentColor" class="text-orange" /> aria-hidden="true"
<rect x="9" y="12" width="2" height="4" fill="currentColor" class="text-black" /> >
<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> </svg>
) )
} }
function SearchIcon(): JSX.Element { function SearchIcon(): JSX.Element {
return ( 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="4" y="2" width="6" height="2" />
<rect x="2" y="4" width="2" height="6" /> <rect x="2" y="4" width="2" height="6" />
<rect x="10" y="4" width="2" height="6" /> <rect x="10" y="4" width="2" height="6" />

View File

@ -2,12 +2,12 @@
* Protected route wrapper that redirects unauthenticated users * Protected route wrapper that redirects unauthenticated users
*/ */
import type { ParentProps, JSX } from "solid-js" import { useStore } from '@nanostores/solid'
import { Show, createEffect } from "solid-js" import { useLocation, useNavigate } from '@solidjs/router'
import { useNavigate, useLocation } from "@solidjs/router" import type { JSX, ParentProps } from 'solid-js'
import { useStore } from "@nanostores/solid" import { createEffect, Show } from 'solid-js'
import { $isAuthenticated } from "../../stores" import { $isAuthenticated } from '../../stores'
import { Spinner } from "../UI/Spinner" import { Spinner } from '../UI/Spinner'
interface ProtectedRouteProps extends ParentProps { interface ProtectedRouteProps extends ParentProps {
redirectTo?: string redirectTo?: string
@ -18,14 +18,15 @@ export function ProtectedRoute(props: ProtectedRouteProps): JSX.Element {
const location = useLocation() const location = useLocation()
const isAuthenticated = useStore($isAuthenticated) const isAuthenticated = useStore($isAuthenticated)
const redirectPath = (): string => props.redirectTo ?? "/login" const redirectPath = (): string => props.redirectTo ?? '/login'
createEffect(() => { createEffect(() => {
if (!isAuthenticated()) { if (!isAuthenticated()) {
const currentPath = location.pathname const currentPath = location.pathname
const redirectUrl = currentPath !== "/" const redirectUrl =
? `${redirectPath()}?redirect=${encodeURIComponent(currentPath)}` currentPath !== '/'
: redirectPath() ? `${redirectPath()}?redirect=${encodeURIComponent(currentPath)}`
: redirectPath()
navigate(redirectUrl, { replace: true }) navigate(redirectUrl, { replace: true })
} }
@ -58,7 +59,7 @@ export function GuestRoute(props: ParentProps): JSX.Element {
createEffect(() => { createEffect(() => {
if (isAuthenticated()) { if (isAuthenticated()) {
const params = new URLSearchParams(location.search) const params = new URLSearchParams(location.search)
const redirectPath = params.get("redirect") ?? "/chat" const redirectPath = params.get('redirect') ?? '/chat'
navigate(redirectPath, { replace: true }) 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="h-full flex items-center justify-center bg-black">
<div class="flex flex-col items-center gap-4"> <div class="flex flex-col items-center gap-4">
<Spinner size="lg" /> <Spinner size="lg" />
<span class="font-pixel text-[10px] text-orange"> <span class="font-pixel text-[10px] text-orange">REDIRECTING...</span>
REDIRECTING...
</span>
</div> </div>
</div> </div>
} }

View File

@ -2,21 +2,21 @@
* Sidebar - Room list only * Sidebar - Room list only
*/ */
import type { JSX } from "solid-js" import { useStore } from '@nanostores/solid'
import { Show, Index, createMemo } from "solid-js" import type { JSX } from 'solid-js'
import type { Participant } from "../../types" import { createMemo, Index, Show } from 'solid-js'
import { useStore } from "@nanostores/solid"
import { import {
$activeRoomId,
$currentUser, $currentUser,
$rooms, $rooms,
$activeRoomId,
$totalUnreadCount, $totalUnreadCount,
setActiveRoom,
openModal, openModal,
} from "../../stores" setActiveRoom,
import { Avatar } from "../UI/Avatar" } from '../../stores'
import { Badge } from "../UI/Badge" import type { Participant } from '../../types'
import { IconButton } from "../UI/IconButton" import { Avatar } from '../UI/Avatar'
import { Badge } from '../UI/Badge'
import { IconButton } from '../UI/IconButton'
export function Sidebar(): JSX.Element { export function Sidebar(): JSX.Element {
const currentUser = useStore($currentUser) const currentUser = useStore($currentUser)
@ -39,7 +39,9 @@ export function Sidebar(): JSX.Element {
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h2 class="font-pixel text-[10px] text-orange uppercase">Messages</h2> <h2 class="font-pixel text-[10px] text-orange uppercase">Messages</h2>
<Show when={totalUnread() > 0}> <Show when={totalUnread() > 0}>
<Badge variant="primary" size="xs">{totalUnread()}</Badge> <Badge variant="primary" size="xs">
{totalUnread()}
</Badge>
</Show> </Show>
</div> </div>
</div> </div>
@ -51,16 +53,22 @@ export function Sidebar(): JSX.Element {
variant="subtle" variant="subtle"
size="sm" size="sm"
class="w-full justify-start gap-2 px-3" class="w-full justify-start gap-2 px-3"
onClick={() => openModal("new-conversation")} onClick={() => openModal('new-conversation')}
/> />
</div> </div>
<div class="flex-1 overflow-y-auto p-2"> <div class="flex-1 overflow-y-auto p-2">
<Index each={sortedRooms()}> <Index each={sortedRooms()}>
{(room, idx) => { {(room, _idx) => {
const isActive = createMemo(() => activeRoomId() === room().id) const isActive = createMemo(() => activeRoomId() === room().id)
const other = createMemo(() => room().participants?.find((p: Participant) => p.user_id !== currentUser()?.id)) const other = createMemo(() =>
const displayName = createMemo(() => room().name ?? other()?.display_name ?? "Chat") room().participants?.find(
(p: Participant) => p.user_id !== currentUser()?.id
)
)
const displayName = createMemo(
() => room().name ?? other()?.display_name ?? 'Chat'
)
return ( return (
<button <button
@ -69,17 +77,19 @@ export function Sidebar(): JSX.Element {
data-active={isActive()} data-active={isActive()}
onClick={() => setActiveRoom(room().id)} onClick={() => setActiveRoom(room().id)}
style={{ style={{
width: "100%", width: '100%',
display: "flex", display: 'flex',
"align-items": "center", 'align-items': 'center',
gap: "12px", gap: '12px',
padding: "12px", padding: '12px',
"margin-bottom": "4px", 'margin-bottom': '4px',
border: isActive() ? "2px solid #FF5300" : "2px solid transparent", border: isActive()
background: isActive() ? "#FF5300" : "black", ? '2px solid #FF5300'
color: isActive() ? "black" : "white", : '2px solid transparent',
cursor: "pointer", background: isActive() ? '#FF5300' : 'black',
"text-align": "left", color: isActive() ? 'black' : 'white',
cursor: 'pointer',
'text-align': 'left',
}} }}
> >
<Avatar <Avatar
@ -87,14 +97,14 @@ export function Sidebar(): JSX.Element {
size="sm" size="sm"
fallback={displayName().slice(0, 2)} 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"> <span class="font-pixel text-[10px] truncate block">
{displayName()} {displayName()}
</span> </span>
<Show when={room().last_message}> <Show when={room().last_message}>
<p <p
class="font-pixel text-[8px] truncate mt-0.5" 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} {room().last_message?.content}
</p> </p>
@ -116,7 +126,13 @@ export function Sidebar(): JSX.Element {
function PlusIcon(): JSX.Element { function PlusIcon(): JSX.Element {
return ( 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="7" y="2" width="2" height="12" />
<rect x="2" y="7" width="12" height="2" /> <rect x="2" y="7" width="12" height="2" />
</svg> </svg>

View File

@ -2,7 +2,7 @@
// © AngelaMos | 2025 // © AngelaMos | 2025
// index.ts // index.ts
// =================== // ===================
export { AppShell } from "./AppShell" export { AppShell } from './AppShell'
export { Sidebar } from "./Sidebar" export { Header } from './Header'
export { Header } from "./Header" export { GuestRoute, ProtectedRoute } from './ProtectedRoute'
export { ProtectedRoute, GuestRoute } from "./ProtectedRoute" export { Sidebar } from './Sidebar'

View File

@ -2,37 +2,37 @@
* 8-bit styled avatar component * 8-bit styled avatar component
*/ */
import { Show, createSignal, onMount } from "solid-js" import type { JSX } from 'solid-js'
import type { JSX } from "solid-js" import { createSignal, onMount, Show } from 'solid-js'
import type { AvatarProps, Size, PresenceStatus } from "../../types" import type { AvatarProps, PresenceStatus, Size } from '../../types'
const SIZE_CLASSES: Record<Size, string> = { const SIZE_CLASSES: Record<Size, string> = {
xs: "w-6 h-6 text-[8px]", xs: 'w-6 h-6 text-[8px]',
sm: "w-8 h-8 text-[10px]", sm: 'w-8 h-8 text-[10px]',
md: "w-10 h-10 text-xs", md: 'w-10 h-10 text-xs',
lg: "w-12 h-12 text-sm", lg: 'w-12 h-12 text-sm',
xl: "w-16 h-16 text-base", xl: 'w-16 h-16 text-base',
} }
const STATUS_COLORS: Record<PresenceStatus, string> = { const STATUS_COLORS: Record<PresenceStatus, string> = {
online: "bg-success", online: 'bg-success',
away: "bg-orange", away: 'bg-orange',
offline: "bg-gray", offline: 'bg-gray',
} }
const STATUS_SIZE: Record<Size, string> = { const STATUS_SIZE: Record<Size, string> = {
xs: "w-2 h-2", xs: 'w-2 h-2',
sm: "w-2.5 h-2.5", sm: 'w-2.5 h-2.5',
md: "w-3 h-3", md: 'w-3 h-3',
lg: "w-3.5 h-3.5", lg: 'w-3.5 h-3.5',
xl: "w-4 h-4", xl: 'w-4 h-4',
} }
export function Avatar(props: AvatarProps): JSX.Element { export function Avatar(props: AvatarProps): JSX.Element {
const [imageError, setImageError] = createSignal(false) const [imageError, setImageError] = createSignal(false)
let imgRef: HTMLImageElement | undefined let imgRef: HTMLImageElement | undefined
const size = (): Size => props.size ?? "md" const size = (): Size => props.size ?? 'md'
const showStatus = (): boolean => props.showStatus ?? false const showStatus = (): boolean => props.showStatus ?? false
const getFallbackInitials = (): string => { const getFallbackInitials = (): string => {
@ -44,7 +44,7 @@ export function Avatar(props: AvatarProps): JSX.Element {
onMount(() => { onMount(() => {
if (imgRef !== undefined) { 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 relative inline-flex items-center justify-center
bg-black border-2 border-orange bg-black border-2 border-orange
${SIZE_CLASSES[size()]} ${SIZE_CLASSES[size()]}
${props.class ?? ""} ${props.class ?? ''}
`} `}
> >
<Show <Show
@ -74,7 +74,7 @@ export function Avatar(props: AvatarProps): JSX.Element {
src={props.src} src={props.src}
alt={props.alt} alt={props.alt}
class="w-full h-full object-cover" class="w-full h-full object-cover"
style={{ "image-rendering": "pixelated" }} style={{ 'image-rendering': 'pixelated' }}
/> />
</Show> </Show>

View File

@ -2,37 +2,37 @@
* 8-bit styled badge component * 8-bit styled badge component
*/ */
import { Show } from "solid-js" import type { JSX } from 'solid-js'
import type { JSX } from "solid-js" import { Show } from 'solid-js'
import type { BadgeProps, Size, BadgeVariant } from "../../types" import type { BadgeProps, BadgeVariant, Size } from '../../types'
const SIZE_CLASSES: Record<Size, string> = { const SIZE_CLASSES: Record<Size, string> = {
xs: "px-1 py-0.5 text-[6px]", xs: 'px-1 py-0.5 text-[6px]',
sm: "px-1.5 py-0.5 text-[8px]", sm: 'px-1.5 py-0.5 text-[8px]',
md: "px-2 py-1 text-[10px]", md: 'px-2 py-1 text-[10px]',
lg: "px-3 py-1 text-xs", lg: 'px-3 py-1 text-xs',
xl: "px-4 py-1.5 text-sm", xl: 'px-4 py-1.5 text-sm',
} }
const VARIANT_CLASSES: Record<BadgeVariant, string> = { const VARIANT_CLASSES: Record<BadgeVariant, string> = {
default: "bg-dark-gray text-white border-gray", default: 'bg-dark-gray text-white border-gray',
primary: "bg-black text-orange border-orange", primary: 'bg-black text-orange border-orange',
success: "bg-black text-success border-success", success: 'bg-black text-success border-success',
warning: "bg-black text-orange border-orange", warning: 'bg-black text-orange border-orange',
error: "bg-black text-error border-error", error: 'bg-black text-error border-error',
} }
const DOT_COLORS: Record<BadgeVariant, string> = { const DOT_COLORS: Record<BadgeVariant, string> = {
default: "bg-gray", default: 'bg-gray',
primary: "bg-orange", primary: 'bg-orange',
success: "bg-success", success: 'bg-success',
warning: "bg-orange", warning: 'bg-orange',
error: "bg-error", error: 'bg-error',
} }
export function Badge(props: BadgeProps): JSX.Element { export function Badge(props: BadgeProps): JSX.Element {
const variant = (): BadgeVariant => props.variant ?? "default" const variant = (): BadgeVariant => props.variant ?? 'default'
const size = (): Size => props.size ?? "md" const size = (): Size => props.size ?? 'md'
const showDot = (): boolean => props.dot ?? false const showDot = (): boolean => props.dot ?? false
return ( return (
@ -42,7 +42,7 @@ export function Badge(props: BadgeProps): JSX.Element {
font-pixel border-2 uppercase font-pixel border-2 uppercase
${SIZE_CLASSES[size()]} ${SIZE_CLASSES[size()]}
${VARIANT_CLASSES[variant()]} ${VARIANT_CLASSES[variant()]}
${props.class ?? ""} ${props.class ?? ''}
`} `}
> >
<Show when={showDot()}> <Show when={showDot()}>

View File

@ -2,17 +2,17 @@
* 8-bit styled button component * 8-bit styled button component
*/ */
import { Show, splitProps } from "solid-js" import type { JSX } from 'solid-js'
import type { JSX } from "solid-js" import { Show, splitProps } from 'solid-js'
import type { ButtonProps, Size, ButtonVariant } from "../../types" import type { ButtonProps, ButtonVariant, Size } from '../../types'
import { Spinner } from "./Spinner" import { Spinner } from './Spinner'
const SIZE_CLASSES: Record<Size, string> = { const SIZE_CLASSES: Record<Size, string> = {
xs: "px-2 py-1 text-[8px]", xs: 'px-2 py-1 text-[8px]',
sm: "px-3 py-1.5 text-[10px]", sm: 'px-3 py-1.5 text-[10px]',
md: "px-4 py-2 text-xs", md: 'px-4 py-2 text-xs',
lg: "px-6 py-3 text-sm", lg: 'px-6 py-3 text-sm',
xl: "px-8 py-4 text-base", xl: 'px-8 py-4 text-base',
} }
const VARIANT_CLASSES: Record<ButtonVariant, string> = { const VARIANT_CLASSES: Record<ButtonVariant, string> = {
@ -49,21 +49,21 @@ const DISABLED_CLASSES = `
export function Button(props: ButtonProps): JSX.Element { export function Button(props: ButtonProps): JSX.Element {
const [local, rest] = splitProps(props, [ const [local, rest] = splitProps(props, [
"variant", 'variant',
"size", 'size',
"fullWidth", 'fullWidth',
"disabled", 'disabled',
"loading", 'loading',
"leftIcon", 'leftIcon',
"rightIcon", 'rightIcon',
"type", 'type',
"onClick", 'onClick',
"class", 'class',
"children", 'children',
]) ])
const variant = (): ButtonVariant => local.variant ?? "primary" const variant = (): ButtonVariant => local.variant ?? 'primary'
const size = (): Size => local.size ?? "md" const size = (): Size => local.size ?? 'md'
const isDisabled = (): boolean => local.disabled ?? false const isDisabled = (): boolean => local.disabled ?? false
const isLoading = (): boolean => local.loading ?? false const isLoading = (): boolean => local.loading ?? false
@ -75,7 +75,7 @@ export function Button(props: ButtonProps): JSX.Element {
return ( return (
<button <button
type={local.type ?? "button"} type={local.type ?? 'button'}
disabled={isDisabled() || isLoading()} disabled={isDisabled() || isLoading()}
onClick={handleClick} onClick={handleClick}
class={` 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 focus:outline-none focus:ring-2 focus:ring-orange focus:ring-offset-2 focus:ring-offset-black
${SIZE_CLASSES[size()]} ${SIZE_CLASSES[size()]}
${VARIANT_CLASSES[variant()]} ${VARIANT_CLASSES[variant()]}
${(isDisabled() || isLoading()) ? DISABLED_CLASSES : ""} ${isDisabled() || isLoading() ? DISABLED_CLASSES : ''}
${local.fullWidth === true ? "w-full" : ""} ${local.fullWidth === true ? 'w-full' : ''}
${local.class ?? ""} ${local.class ?? ''}
`} `}
{...rest} {...rest}
> >
<Show when={isLoading()}> <Show when={isLoading()}>
<Spinner size="xs" /> <Spinner size="xs" />
</Show> </Show>
<Show when={!isLoading() && local.leftIcon}> <Show when={!isLoading() && local.leftIcon}>{local.leftIcon}</Show>
{local.leftIcon}
</Show>
<span>{local.children}</span> <span>{local.children}</span>
<Show when={!isLoading() && local.rightIcon}> <Show when={!isLoading() && local.rightIcon}>{local.rightIcon}</Show>
{local.rightIcon}
</Show>
</button> </button>
) )
} }

View File

@ -2,30 +2,32 @@
* 8-bit styled dropdown menu component * 8-bit styled dropdown menu component
*/ */
import { Show, For, createSignal, onCleanup, createEffect } from "solid-js" import type { JSX } from 'solid-js'
import type { JSX } from "solid-js" import { createEffect, createSignal, For, onCleanup, Show } from 'solid-js'
import type { DropdownProps, DropdownItem } from "../../types" import type { DropdownItem, DropdownProps } from '../../types'
type DropdownAlign = "left" | "right" type DropdownAlign = 'left' | 'right'
export function Dropdown(props: DropdownProps): JSX.Element { export function Dropdown(props: DropdownProps): JSX.Element {
const [isOpen, setIsOpen] = createSignal(false) const [isOpen, setIsOpen] = createSignal(false)
let containerRef: HTMLDivElement | undefined let containerRef: HTMLDivElement | undefined
const align = (): DropdownAlign => props.align ?? "left" const align = (): DropdownAlign => props.align ?? 'left'
const getItemClasses = (item: DropdownItem): string => { const getItemClasses = (item: DropdownItem): string => {
if (item.disabled === true) { if (item.disabled === true) {
return "text-gray cursor-not-allowed" return 'text-gray cursor-not-allowed'
} }
if (item.danger === true) { 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 => { 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 => { const handleToggle = (): void => {
@ -45,41 +47,38 @@ export function Dropdown(props: DropdownProps): JSX.Element {
} }
const handleKeyDown = (e: KeyboardEvent): void => { const handleKeyDown = (e: KeyboardEvent): void => {
if (e.key === "Escape") { if (e.key === 'Escape') {
setIsOpen(false) setIsOpen(false)
} }
} }
createEffect(() => { createEffect(() => {
if (isOpen()) { if (isOpen()) {
document.addEventListener("click", handleClickOutside) document.addEventListener('click', handleClickOutside)
document.addEventListener("keydown", handleKeyDown) document.addEventListener('keydown', handleKeyDown)
} }
onCleanup(() => { onCleanup(() => {
document.removeEventListener("click", handleClickOutside) document.removeEventListener('click', handleClickOutside)
document.removeEventListener("keydown", handleKeyDown) document.removeEventListener('keydown', handleKeyDown)
}) })
}) })
return ( return (
<div <div ref={containerRef} class={`relative inline-block ${props.class ?? ''}`}>
ref={containerRef} <button
class={`relative inline-block ${props.class ?? ""}`} type="button"
>
<div
role="button"
tabIndex={0} tabIndex={0}
onClick={handleToggle} onClick={handleToggle}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") { if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault() e.preventDefault()
handleToggle() handleToggle()
} }
}} }}
> >
{props.trigger} {props.trigger}
</div> </button>
<Show when={isOpen()}> <Show when={isOpen()}>
<div <div
@ -88,7 +87,7 @@ export function Dropdown(props: DropdownProps): JSX.Element {
bg-black border-2 border-orange bg-black border-2 border-orange
shadow-[4px_4px_0_var(--color-orange)] shadow-[4px_4px_0_var(--color-orange)]
animate-scale-in origin-top animate-scale-in origin-top
${align() === "right" ? "right-0" : "left-0"} ${align() === 'right' ? 'right-0' : 'left-0'}
`} `}
role="menu" role="menu"
> >

View File

@ -2,27 +2,27 @@
* 8-bit styled icon button component * 8-bit styled icon button component
*/ */
import { Show, splitProps } from "solid-js" import type { JSX } from 'solid-js'
import type { JSX } from "solid-js" import { Show, splitProps } from 'solid-js'
import type { IconButtonProps, Size } from "../../types" import type { IconButtonProps, Size } from '../../types'
import { Spinner } from "./Spinner" import { Spinner } from './Spinner'
type IconButtonVariant = "ghost" | "subtle" type IconButtonVariant = 'ghost' | 'subtle'
const SIZE_CLASSES: Record<Size, string> = { const SIZE_CLASSES: Record<Size, string> = {
xs: "w-6 h-6", xs: 'w-6 h-6',
sm: "w-8 h-8", sm: 'w-8 h-8',
md: "w-10 h-10", md: 'w-10 h-10',
lg: "w-12 h-12", lg: 'w-12 h-12',
xl: "w-14 h-14", xl: 'w-14 h-14',
} }
const ICON_SIZE_CLASSES: Record<Size, string> = { const ICON_SIZE_CLASSES: Record<Size, string> = {
xs: "w-3 h-3", xs: 'w-3 h-3',
sm: "w-4 h-4", sm: 'w-4 h-4',
md: "w-5 h-5", md: 'w-5 h-5',
lg: "w-6 h-6", lg: 'w-6 h-6',
xl: "w-7 h-7", xl: 'w-7 h-7',
} }
const VARIANT_CLASSES: Record<IconButtonVariant, string> = { 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 { export function IconButton(props: IconButtonProps): JSX.Element {
const [local, rest] = splitProps(props, [ const [local, rest] = splitProps(props, [
"icon", 'icon',
"onClick", 'onClick',
"size", 'size',
"variant", 'variant',
"ariaLabel", 'ariaLabel',
"disabled", 'disabled',
"loading", 'loading',
"class", 'class',
]) ])
const size = (): Size => local.size ?? "md" const size = (): Size => local.size ?? 'md'
const variant = (): IconButtonVariant => local.variant ?? "ghost" const variant = (): IconButtonVariant => local.variant ?? 'ghost'
const isDisabled = (): boolean => local.disabled ?? false const isDisabled = (): boolean => local.disabled ?? false
const isLoading = (): boolean => local.loading ?? 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 focus:outline-none focus:ring-2 focus:ring-orange focus:ring-offset-1 focus:ring-offset-black
${SIZE_CLASSES[size()]} ${SIZE_CLASSES[size()]}
${VARIANT_CLASSES[variant()]} ${VARIANT_CLASSES[variant()]}
${(isDisabled() || isLoading()) ? DISABLED_CLASSES : ""} ${isDisabled() || isLoading() ? DISABLED_CLASSES : ''}
${local.class ?? ""} ${local.class ?? ''}
`} `}
{...rest} {...rest}
> >
<Show <Show
when={!isLoading()} when={!isLoading()}
fallback={<Spinner size={size() === "xs" ? "xs" : "sm"} />} fallback={<Spinner size={size() === 'xs' ? 'xs' : 'sm'} />}
> >
<span class={ICON_SIZE_CLASSES[size()]}> <span class={ICON_SIZE_CLASSES[size()]}>{local.icon}</span>
{local.icon}
</span>
</Show> </Show>
</button> </button>
) )

View File

@ -2,32 +2,32 @@
* 8-bit styled input component * 8-bit styled input component
*/ */
import { Show, splitProps, createSignal } from "solid-js" import type { JSX } from 'solid-js'
import type { JSX } from "solid-js" import { createSignal, Show, splitProps } from 'solid-js'
import type { InputProps } from "../../types" import type { InputProps } from '../../types'
export function Input(props: InputProps): JSX.Element { export function Input(props: InputProps): JSX.Element {
const [local, rest] = splitProps(props, [ const [local, rest] = splitProps(props, [
"type", 'type',
"name", 'name',
"placeholder", 'placeholder',
"value", 'value',
"onInput", 'onInput',
"onChange", 'onChange',
"onFocus", 'onFocus',
"onBlur", 'onBlur',
"disabled", 'disabled',
"error", 'error',
"label", 'label',
"hint", 'hint',
"leftIcon", 'leftIcon',
"rightIcon", 'rightIcon',
"fullWidth", 'fullWidth',
"maxLength", 'maxLength',
"minLength", 'minLength',
"required", 'required',
"autofocus", 'autofocus',
"class", 'class',
]) ])
const [focused, setFocused] = createSignal(false) const [focused, setFocused] = createSignal(false)
@ -48,7 +48,9 @@ export function Input(props: InputProps): JSX.Element {
const isDisabled = (): boolean => local.disabled ?? false const isDisabled = (): boolean => local.disabled ?? false
return ( 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}> <Show when={local.label}>
<label <label
class="font-pixel text-[10px] text-orange uppercase tracking-wider" 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 relative flex items-center
bg-black border-2 bg-black border-2
transition-all duration-100 transition-all duration-100
${focused() ? "border-orange shadow-[0_0_0_2px_var(--color-orange)]" : "border-dark-gray"} ${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)]" : ""} ${hasError() ? 'border-error shadow-[0_0_0_2px_var(--color-error)]' : ''}
${isDisabled() ? "opacity-50 cursor-not-allowed" : ""} ${isDisabled() ? 'opacity-50 cursor-not-allowed' : ''}
`} `}
> >
<Show when={local.leftIcon}> <Show when={local.leftIcon}>
@ -76,11 +78,11 @@ export function Input(props: InputProps): JSX.Element {
</Show> </Show>
<input <input
type={local.type ?? "text"} type={local.type ?? 'text'}
name={local.name} name={local.name}
id={local.name} id={local.name}
placeholder={local.placeholder} placeholder={local.placeholder}
value={local.value ?? ""} value={local.value ?? ''}
disabled={isDisabled()} disabled={isDisabled()}
maxLength={local.maxLength} maxLength={local.maxLength}
minLength={local.minLength} minLength={local.minLength}
@ -102,9 +104,9 @@ export function Input(props: InputProps): JSX.Element {
placeholder:font-placeholder placeholder:text-[16px] placeholder:text-gray placeholder:font-placeholder placeholder:text-[16px] placeholder:text-gray
focus:outline-none focus:outline-none
disabled:cursor-not-allowed disabled:cursor-not-allowed
${local.leftIcon !== undefined ? "pl-1" : ""} ${local.leftIcon !== undefined ? 'pl-1' : ''}
${local.rightIcon !== undefined ? "pr-1" : ""} ${local.rightIcon !== undefined ? 'pr-1' : ''}
${local.class ?? ""} ${local.class ?? ''}
`} `}
{...rest} {...rest}
/> />

View File

@ -2,50 +2,44 @@
* 8-bit styled modal component * 8-bit styled modal component
*/ */
import { Show, createEffect, onCleanup } from "solid-js" import type { JSX } from 'solid-js'
import type { JSX } from "solid-js" import { createEffect, onCleanup, Show } from 'solid-js'
import type { ModalProps, Size } from "../../types" import type { ModalProps, Size } from '../../types'
import { IconButton } from "./IconButton" import { IconButton } from './IconButton'
const SIZE_CLASSES: Record<Size, string> = { const SIZE_CLASSES: Record<Size, string> = {
xs: "max-w-xs", xs: 'max-w-xs',
sm: "max-w-sm", sm: 'max-w-sm',
md: "max-w-md", md: 'max-w-md',
lg: "max-w-lg", lg: 'max-w-lg',
xl: "max-w-xl", xl: 'max-w-xl',
} }
export function Modal(props: ModalProps): JSX.Element { 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 closeOnOverlayClick = (): boolean => props.closeOnOverlayClick ?? true
const showCloseButton = (): boolean => props.showCloseButton ?? 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 => { const handleKeyDown = (e: KeyboardEvent): void => {
if (e.key === "Escape") { if (e.key === 'Escape') {
props.onClose() props.onClose()
} }
} }
createEffect(() => { createEffect(() => {
if (props.isOpen) { if (props.isOpen) {
document.addEventListener("keydown", handleKeyDown) document.addEventListener('keydown', handleKeyDown)
document.body.style.overflow = "hidden" document.body.style.overflow = 'hidden'
} }
onCleanup(() => { onCleanup(() => {
document.removeEventListener("keydown", handleKeyDown) document.removeEventListener('keydown', handleKeyDown)
document.body.style.overflow = "" document.body.style.overflow = ''
}) })
}) })
const handleOverlayKeyDown = (e: KeyboardEvent): void => { const handleOverlayKeyDown = (e: KeyboardEvent): void => {
if (e.key === "Enter" || e.key === " ") { if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault() e.preventDefault()
if (closeOnOverlayClick()) { if (closeOnOverlayClick()) {
props.onClose() props.onClose()
@ -55,15 +49,17 @@ export function Modal(props: ModalProps): JSX.Element {
return ( return (
<Show when={props.isOpen}> <Show when={props.isOpen}>
<div <div class="fixed inset-0 z-50 flex items-center justify-center p-4">
class="fixed inset-0 z-50 flex items-center justify-center p-4" <button
role="button" type="button"
tabIndex={0} class="absolute inset-0 w-full h-full bg-black/80 animate-fade-in border-0 cursor-default appearance-none"
onClick={handleOverlayClick} tabIndex={-1}
onKeyDown={handleOverlayKeyDown} aria-label="Close modal"
aria-label="Close modal overlay" onClick={() => {
> if (closeOnOverlayClick()) props.onClose()
<div class="absolute inset-0 bg-black/80 animate-fade-in" /> }}
onKeyDown={handleOverlayKeyDown}
/>
<div <div
class={` class={`
@ -75,9 +71,14 @@ export function Modal(props: ModalProps): JSX.Element {
`} `}
role="dialog" role="dialog"
aria-modal="true" 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 items-start justify-between p-4 border-b-2 border-orange">
<div class="flex-1"> <div class="flex-1">
<Show when={props.title}> <Show when={props.title}>
@ -107,9 +108,7 @@ export function Modal(props: ModalProps): JSX.Element {
</div> </div>
</Show> </Show>
<div class="p-4"> <div class="p-4">{props.children}</div>
{props.children}
</div>
</div> </div>
</div> </div>
</Show> </Show>
@ -124,6 +123,7 @@ function CloseIcon(): JSX.Element {
viewBox="0 0 16 16" viewBox="0 0 16 16"
fill="none" fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
> >
<path <path
d="M2 2L14 14M14 2L2 14" d="M2 2L14 14M14 2L2 14"

View File

@ -2,25 +2,24 @@
* 8-bit styled skeleton loading component * 8-bit styled skeleton loading component
*/ */
import { For } from "solid-js" import type { JSX } from 'solid-js'
import type { JSX } from "solid-js" import { For } from 'solid-js'
import type { SkeletonProps } from "../../types" import type { SkeletonProps } from '../../types'
type SkeletonVariant = "text" | "circular" | "rectangular" type SkeletonVariant = 'text' | 'circular' | 'rectangular'
export function Skeleton(props: SkeletonProps): JSX.Element { 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 lines = (): number => props.lines ?? 1
const getVariantClasses = (): string => { const getVariantClasses = (): string => {
switch (variant()) { switch (variant()) {
case "circular": case 'circular':
return "rounded-none aspect-square" return 'rounded-none aspect-square'
case "rectangular": case 'rectangular':
return "" return ''
case "text":
default: default:
return "h-4" return 'h-4'
} }
} }
@ -39,7 +38,7 @@ export function Skeleton(props: SkeletonProps): JSX.Element {
} }
return ( 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)}> <For each={Array(lines()).fill(0)}>
{(_, index) => ( {(_, index) => (
<div <div
@ -47,7 +46,7 @@ export function Skeleton(props: SkeletonProps): JSX.Element {
bg-dark-gray bg-dark-gray
animate-pixel-pulse animate-pixel-pulse
${getVariantClasses()} ${getVariantClasses()}
${index() === lines() - 1 && variant() === "text" ? "w-3/4" : "w-full"} ${index() === lines() - 1 && variant() === 'text' ? 'w-3/4' : 'w-full'}
`} `}
style={getStyle()} style={getStyle()}
/> />

View File

@ -2,28 +2,27 @@
* 8-bit styled spinner component * 8-bit styled spinner component
*/ */
import type { JSX } from "solid-js" import type { JSX } from 'solid-js'
import type { SpinnerProps, Size } from "../../types" import type { Size, SpinnerProps } from '../../types'
const SIZE_CLASSES: Record<Size, string> = { const SIZE_CLASSES: Record<Size, string> = {
xs: "w-3 h-3", xs: 'w-3 h-3',
sm: "w-4 h-4", sm: 'w-4 h-4',
md: "w-6 h-6", md: 'w-6 h-6',
lg: "w-8 h-8", lg: 'w-8 h-8',
xl: "w-12 h-12", xl: 'w-12 h-12',
} }
export function Spinner(props: SpinnerProps): JSX.Element { export function Spinner(props: SpinnerProps): JSX.Element {
const size = (): Size => props.size ?? "md" const size = (): Size => props.size ?? 'md'
return ( return (
<div <output
class={` class={`
inline-block animate-pixel-spin inline-block animate-pixel-spin
${SIZE_CLASSES[size()]} ${SIZE_CLASSES[size()]}
${props.class ?? ""} ${props.class ?? ''}
`} `}
role="status"
aria-label="Loading" aria-label="Loading"
> >
<svg <svg
@ -31,17 +30,74 @@ export function Spinner(props: SpinnerProps): JSX.Element {
fill="none" fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class="w-full h-full" class="w-full h-full"
aria-hidden="true"
> >
<rect x="6" y="0" width="4" height="4" fill="currentColor" class="text-orange" /> <rect
<rect x="10" y="2" width="4" height="4" fill="currentColor" class="text-orange/80" /> x="6"
<rect x="12" y="6" width="4" height="4" fill="currentColor" class="text-orange/60" /> y="0"
<rect x="10" y="10" width="4" height="4" fill="currentColor" class="text-orange/40" /> width="4"
<rect x="6" y="12" width="4" height="4" fill="currentColor" class="text-orange/30" /> height="4"
<rect x="2" y="10" width="4" height="4" fill="currentColor" class="text-orange/20" /> fill="currentColor"
<rect x="0" y="6" width="4" height="4" fill="currentColor" class="text-orange/10" /> class="text-orange"
<rect x="2" y="2" width="4" height="4" fill="currentColor" class="text-orange/90" /> />
<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> </svg>
</div> </output>
) )
} }

View File

@ -2,23 +2,23 @@
* 8-bit styled textarea component * 8-bit styled textarea component
*/ */
import { Show, splitProps, createSignal, createEffect, onMount } from "solid-js" import type { JSX } from 'solid-js'
import type { JSX } from "solid-js" import { createEffect, createSignal, onMount, Show, splitProps } from 'solid-js'
import type { TextAreaProps } from "../../types" import type { TextAreaProps } from '../../types'
export function TextArea(props: TextAreaProps): JSX.Element { export function TextArea(props: TextAreaProps): JSX.Element {
const [local, rest] = splitProps(props, [ const [local, rest] = splitProps(props, [
"name", 'name',
"placeholder", 'placeholder',
"value", 'value',
"onInput", 'onInput',
"disabled", 'disabled',
"error", 'error',
"label", 'label',
"rows", 'rows',
"maxLength", 'maxLength',
"autoResize", 'autoResize',
"class", 'class',
]) ])
const [focused, setFocused] = createSignal(false) const [focused, setFocused] = createSignal(false)
@ -36,7 +36,7 @@ export function TextArea(props: TextAreaProps): JSX.Element {
const adjustHeight = (): void => { const adjustHeight = (): void => {
if (textareaRef !== undefined) { if (textareaRef !== undefined) {
textareaRef.style.height = "auto" textareaRef.style.height = 'auto'
textareaRef.style.height = `${textareaRef.scrollHeight}px` textareaRef.style.height = `${textareaRef.scrollHeight}px`
} }
} }
@ -48,14 +48,18 @@ export function TextArea(props: TextAreaProps): JSX.Element {
}) })
createEffect(() => { createEffect(() => {
if (local.autoResize === true && local.value !== undefined && textareaRef !== undefined) { if (
local.autoResize === true &&
local.value !== undefined &&
textareaRef !== undefined
) {
adjustHeight() adjustHeight()
} }
}) })
const hasError = (): boolean => Boolean(local.error) const hasError = (): boolean => Boolean(local.error)
const isDisabled = (): boolean => local.disabled ?? false const isDisabled = (): boolean => local.disabled ?? false
const currentLength = (): number => (local.value ?? "").length const currentLength = (): number => (local.value ?? '').length
return ( return (
<div class="flex flex-col gap-1 w-full"> <div class="flex flex-col gap-1 w-full">
@ -73,9 +77,9 @@ export function TextArea(props: TextAreaProps): JSX.Element {
relative relative
bg-black border-2 bg-black border-2
transition-all duration-100 transition-all duration-100
${focused() ? "border-orange shadow-[0_0_0_2px_var(--color-orange)]" : "border-dark-gray"} ${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)]" : ""} ${hasError() ? 'border-error shadow-[0_0_0_2px_var(--color-error)]' : ''}
${isDisabled() ? "opacity-50 cursor-not-allowed" : ""} ${isDisabled() ? 'opacity-50 cursor-not-allowed' : ''}
`} `}
> >
<textarea <textarea
@ -83,7 +87,7 @@ export function TextArea(props: TextAreaProps): JSX.Element {
name={local.name} name={local.name}
id={local.name} id={local.name}
placeholder={local.placeholder} placeholder={local.placeholder}
value={local.value ?? ""} value={local.value ?? ''}
disabled={isDisabled()} disabled={isDisabled()}
rows={local.rows ?? 3} rows={local.rows ?? 3}
maxLength={local.maxLength} maxLength={local.maxLength}
@ -97,8 +101,8 @@ export function TextArea(props: TextAreaProps): JSX.Element {
focus:outline-none focus:outline-none
disabled:cursor-not-allowed disabled:cursor-not-allowed
resize-none resize-none
${local.autoResize === true ? "overflow-hidden" : ""} ${local.autoResize === true ? 'overflow-hidden' : ''}
${local.class ?? ""} ${local.class ?? ''}
`} `}
{...rest} {...rest}
/> />
@ -116,8 +120,8 @@ export function TextArea(props: TextAreaProps): JSX.Element {
<span <span
class={`font-pixel text-[8px] ${ class={`font-pixel text-[8px] ${
currentLength() > (local.maxLength ?? 0) * 0.9 currentLength() > (local.maxLength ?? 0) * 0.9
? "text-error" ? 'text-error'
: "text-gray" : 'text-gray'
}`} }`}
> >
{currentLength()}/{local.maxLength} {currentLength()}/{local.maxLength}

View File

@ -2,20 +2,20 @@
* 8-bit styled toast notification component * 8-bit styled toast notification component
*/ */
import { For, Show } from "solid-js" import { useStore } from '@nanostores/solid'
import type { JSX } from "solid-js" import type { JSX } from 'solid-js'
import type { ToastProps } from "../../types" import { For, Show } from 'solid-js'
import { $toasts, dismissToast } from "../../stores" import { $toasts, dismissToast } from '../../stores'
import { useStore } from "@nanostores/solid" import type { ToastProps } from '../../types'
import { IconButton } from "./IconButton" import { IconButton } from './IconButton'
type ToastVariant = "info" | "success" | "warning" | "error" type ToastVariant = 'info' | 'success' | 'warning' | 'error'
const VARIANT_CLASSES: Record<ToastVariant, string> = { const VARIANT_CLASSES: Record<ToastVariant, string> = {
info: "border-info text-info", info: 'border-info text-info',
success: "border-success text-success", success: 'border-success text-success',
warning: "border-orange text-orange", warning: 'border-orange text-orange',
error: "border-error text-error", error: 'border-error text-error',
} }
const VARIANT_ICONS: Record<ToastVariant, () => JSX.Element> = { const VARIANT_ICONS: Record<ToastVariant, () => JSX.Element> = {
@ -41,22 +41,17 @@ export function Toast(props: ToastProps): JSX.Element {
`} `}
role="alert" role="alert"
> >
<span class="flex-shrink-0 mt-0.5"> <span class="flex-shrink-0 mt-0.5">{VARIANT_ICONS[props.variant]()}</span>
{VARIANT_ICONS[props.variant]()}
</span>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<p class="font-pixel text-[10px] text-white uppercase"> <p class="font-pixel text-[10px] text-white uppercase">{props.title}</p>
{props.title}
</p>
<Show when={props.description}> <Show when={props.description}>
<p class="font-pixel text-[8px] text-gray mt-1"> <p class="font-pixel text-[8px] text-gray mt-1">{props.description}</p>
{props.description}
</p>
</Show> </Show>
<Show when={props.action} keyed> <Show when={props.action} keyed>
{(action) => ( {(action) => (
<button <button
type="button"
onClick={() => action.onClick()} onClick={() => action.onClick()}
class="font-pixel text-[8px] text-orange hover:underline mt-2" class="font-pixel text-[8px] text-orange hover:underline mt-2"
> >
@ -82,16 +77,20 @@ export function ToastContainer(): JSX.Element {
return ( return (
<div class="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-sm"> <div class="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-sm">
<For each={toasts()}> <For each={toasts()}>{(toast) => <Toast {...toast} />}</For>
{(toast) => <Toast {...toast} />}
</For>
</div> </div>
) )
} }
function InfoIcon(): JSX.Element { function InfoIcon(): JSX.Element {
return ( 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="3" width="2" height="2" />
<rect x="7" y="7" width="2" height="6" /> <rect x="7" y="7" width="2" height="6" />
</svg> </svg>
@ -100,7 +99,13 @@ function InfoIcon(): JSX.Element {
function CheckIcon(): JSX.Element { function CheckIcon(): JSX.Element {
return ( 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="3" y="8" width="2" height="2" />
<rect x="5" y="10" width="2" height="2" /> <rect x="5" y="10" width="2" height="2" />
<rect x="7" y="8" 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 { function WarningIcon(): JSX.Element {
return ( 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="2" width="2" height="8" />
<rect x="7" y="12" width="2" height="2" /> <rect x="7" y="12" width="2" height="2" />
</svg> </svg>
@ -121,7 +132,13 @@ function WarningIcon(): JSX.Element {
function ErrorIcon(): JSX.Element { function ErrorIcon(): JSX.Element {
return ( 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="3" y="3" width="2" height="2" />
<rect x="5" y="5" width="2" height="2" /> <rect x="5" y="5" width="2" height="2" />
<rect x="7" y="7" 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 { function CloseIcon(): JSX.Element {
return ( 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="2" y="2" width="2" height="2" />
<rect x="4" y="4" width="2" height="2" /> <rect x="4" y="4" width="2" height="2" />
<rect x="6" y="4" width="2" height="2" /> <rect x="6" y="4" width="2" height="2" />

View File

@ -2,24 +2,26 @@
* 8-bit styled tooltip component * 8-bit styled tooltip component
*/ */
import { Show, createSignal, onCleanup } from "solid-js" import type { JSX } from 'solid-js'
import type { JSX } from "solid-js" import { createSignal, onCleanup, Show } from 'solid-js'
import type { TooltipProps } from "../../types" import type { TooltipProps } from '../../types'
type TooltipPosition = "top" | "bottom" | "left" | "right" type TooltipPosition = 'top' | 'bottom' | 'left' | 'right'
const POSITION_CLASSES: Record<TooltipPosition, string> = { const POSITION_CLASSES: Record<TooltipPosition, string> = {
top: "bottom-full left-1/2 -translate-x-1/2 mb-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", bottom: 'top-full left-1/2 -translate-x-1/2 mt-2',
left: "right-full top-1/2 -translate-y-1/2 mr-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", right: 'left-full top-1/2 -translate-y-1/2 ml-2',
} }
const ARROW_CLASSES: Record<TooltipPosition, string> = { 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", 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", bottom:
left: "left-full top-1/2 -translate-y-1/2 border-l-orange border-y-transparent border-r-transparent", 'bottom-full left-1/2 -translate-x-1/2 border-b-orange border-x-transparent border-t-transparent',
right: "right-full top-1/2 -translate-y-1/2 border-r-orange border-y-transparent border-l-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 const DEFAULT_DELAY_MS = 300
@ -28,7 +30,7 @@ export function Tooltip(props: TooltipProps): JSX.Element {
const [visible, setVisible] = createSignal(false) const [visible, setVisible] = createSignal(false)
let timeoutId: ReturnType<typeof setTimeout> | undefined 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 delay = (): number => props.delay ?? DEFAULT_DELAY_MS
const showTooltip = (): void => { const showTooltip = (): void => {
@ -51,9 +53,8 @@ export function Tooltip(props: TooltipProps): JSX.Element {
}) })
return ( return (
<div <span
class="relative inline-block" class="relative inline-block"
role="presentation"
onMouseEnter={showTooltip} onMouseEnter={showTooltip}
onMouseLeave={hideTooltip} onMouseLeave={hideTooltip}
onFocus={showTooltip} onFocus={showTooltip}
@ -91,6 +92,6 @@ export function Tooltip(props: TooltipProps): JSX.Element {
</div> </div>
</div> </div>
</Show> </Show>
</div> </span>
) )
} }

View File

@ -2,15 +2,21 @@
// © AngelaMos | 2025 // © AngelaMos | 2025
// index.ts // index.ts
// =================== // ===================
export { Button } from "./Button"
export { Input } from "./Input" export { Avatar } from './Avatar'
export { TextArea } from "./TextArea" export { Badge } from './Badge'
export { Avatar } from "./Avatar" export { Button } from './Button'
export { Badge } from "./Badge" export { Dropdown } from './Dropdown'
export { Modal } from "./Modal" export { IconButton } from './IconButton'
export { Spinner, LoadingOverlay } from "./Spinner" export { Input } from './Input'
export { Toast, ToastContainer } from "./Toast" export { Modal } from './Modal'
export { Skeleton, MessageSkeleton, ConversationSkeleton, AvatarSkeleton } from "./Skeleton" export {
export { Tooltip } from "./Tooltip" AvatarSkeleton,
export { Dropdown } from "./Dropdown" ConversationSkeleton,
export { IconButton } from "./IconButton" MessageSkeleton,
Skeleton,
} from './Skeleton'
export { LoadingOverlay, Spinner } from './Spinner'
export { TextArea } from './TextArea'
export { Toast, ToastContainer } from './Toast'
export { Tooltip } from './Tooltip'

View File

@ -2,21 +2,32 @@
// © AngelaMos | 2025 // © AngelaMos | 2025
// config.ts // config.ts
// =================== // ===================
const envApiUrl: string | undefined = import.meta.env.VITE_API_URL as string | undefined; const envApiUrl: string | undefined = import.meta.env.VITE_API_URL as
const envWsUrl: string | undefined = import.meta.env.VITE_WS_URL as string | undefined; | string
const envRpId: string | undefined = import.meta.env.VITE_RP_ID as string | undefined; | 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 API_URL =
export const WS_URL = envWsUrl !== undefined && envWsUrl !== "" ? envWsUrl : "ws://localhost:8000"; envApiUrl !== undefined && envApiUrl !== ''
export const RP_ID = envRpId !== undefined && envRpId !== "" ? envRpId : "localhost"; ? 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_HEARTBEAT_INTERVAL = 30000
export const WS_RECONNECT_DELAY = 5000; export const WS_RECONNECT_DELAY = 5000
export const MESSAGE_MAX_LENGTH = 50000; export const MESSAGE_MAX_LENGTH = 50000
export const USERNAME_MIN_LENGTH = 3; export const USERNAME_MIN_LENGTH = 3
export const USERNAME_MAX_LENGTH = 50; export const USERNAME_MAX_LENGTH = 50
export const DISPLAY_NAME_MIN_LENGTH = 1; export const DISPLAY_NAME_MIN_LENGTH = 1
export const DISPLAY_NAME_MAX_LENGTH = 100; export const DISPLAY_NAME_MAX_LENGTH = 100
export const USER_SEARCH_MIN_LENGTH = 2; export const USER_SEARCH_MIN_LENGTH = 2
export const USER_SEARCH_DEFAULT_LIMIT = 10; export const USER_SEARCH_DEFAULT_LIMIT = 10

View File

@ -2,53 +2,54 @@
// © AngelaMos | 2025 // © AngelaMos | 2025
// crypto-service.ts // crypto-service.ts
// =================== // ===================
import { api } from '../lib/api-client'
import type { import type {
IdentityKeyPair,
SignedPreKey,
OneTimePreKey,
DoubleRatchetState, DoubleRatchetState,
EncryptedMessage, EncryptedMessage,
X3DHHeader,
FullMessageHeader, FullMessageHeader,
IdentityKeyPair,
MessageHeader, MessageHeader,
} from "../types" OneTimePreKey,
import { DEFAULT_ONE_TIME_PREKEY_COUNT } from "../types" SignedPreKey,
X3DHHeader,
} from '../types'
import { DEFAULT_ONE_TIME_PREKEY_COUNT } from '../types'
import { import {
generateIdentityKeyPair,
generateSignedPreKey,
generateOneTimePreKeys,
initiateX3DH,
receiveX3DH,
} from "./x3dh"
import {
initializeRatchetSender,
initializeRatchetReceiver,
encryptMessage,
decryptMessage, decryptMessage,
serializeRatchetState,
deserializeRatchetState, deserializeRatchetState,
} from "./double-ratchet" encryptMessage,
initializeRatchetReceiver,
initializeRatchetSender,
serializeRatchetState,
} from './double-ratchet'
import { import {
saveIdentityKey,
getIdentityKey,
saveSignedPreKey,
getLatestSignedPreKey,
saveOneTimePreKeys,
getUnusedOneTimePreKeys,
getOneTimePreKeyByPublicKey,
markOneTimePreKeyUsed,
saveRatchetState,
getRatchetState,
deleteRatchetState,
clearAllKeys, clearAllKeys,
} from "./key-store" deleteRatchetState,
import { api } from "../lib/api-client" getIdentityKey,
getLatestSignedPreKey,
getOneTimePreKeyByPublicKey,
getRatchetState,
getUnusedOneTimePreKeys,
markOneTimePreKeyUsed,
saveIdentityKey,
saveOneTimePreKeys,
saveRatchetState,
saveSignedPreKey,
} from './key-store'
import { import {
base64ToBytes, base64ToBytes,
bytesToBase64, bytesToBase64,
importX25519PrivateKey, importX25519PrivateKey,
importX25519PublicKey, importX25519PublicKey,
} from "./primitives" } from './primitives'
import {
generateIdentityKeyPair,
generateOneTimePreKeys,
generateSignedPreKey,
initiateX3DH,
receiveX3DH,
} from './x3dh'
class CryptoService { class CryptoService {
private userId: string | null = null private userId: string | null = null
@ -72,7 +73,10 @@ class CryptoService {
this.signedPreKey = await getLatestSignedPreKey(userId) this.signedPreKey = await getLatestSignedPreKey(userId)
if (this.signedPreKey === null || this.isSignedPreKeyExpired(this.signedPreKey)) { if (
this.signedPreKey === null ||
this.isSignedPreKeyExpired(this.signedPreKey)
) {
await this.rotateSignedPreKey() await this.rotateSignedPreKey()
} }
@ -89,40 +93,49 @@ class CryptoService {
} }
private async generateAndStoreKeys(): Promise<void> { 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() this.identityKeyPair = await generateIdentityKeyPair()
await saveIdentityKey(this.userId, this.identityKeyPair) 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) 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 saveOneTimePreKeys(this.userId, oneTimePreKeys)
await this.uploadPublicKeys(oneTimePreKeys) await this.uploadPublicKeys(oneTimePreKeys)
} }
private async rotateSignedPreKey(): Promise<void> { 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 saveSignedPreKey(this.userId, this.signedPreKey)
await api.encryption.rotateSignedPrekey(this.userId) await api.encryption.rotateSignedPrekey(this.userId)
} }
private async replenishOneTimePreKeys(): Promise<void> { 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) await saveOneTimePreKeys(this.userId, newPreKeys)
} }
private async uploadPublicKeys(oneTimePreKeys: OneTimePreKey[]): Promise<void> { 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) { if (!this.identityKeyPair || !this.signedPreKey) {
throw new Error("Keys not generated") throw new Error('Keys not generated')
} }
await api.encryption.uploadKeys(this.userId, { await api.encryption.uploadKeys(this.userId, {
@ -135,7 +148,8 @@ class CryptoService {
} }
async establishSession(peerId: string): Promise<void> { 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) const existingState = await this.getRatchetState(peerId)
if (existingState !== null) return if (existingState !== null) return
@ -157,7 +171,9 @@ class CryptoService {
const x3dhHeader: X3DHHeader = { const x3dhHeader: X3DHHeader = {
identity_key: this.identityKeyPair.x25519_public, identity_key: this.identityKeyPair.x25519_public,
ephemeral_key: x3dhResult.ephemeral_public_key, 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) this.pendingX3DHHeaders.set(peerId, x3dhHeader)
@ -172,7 +188,7 @@ class CryptoService {
oneTimePreKeyPublic: string | null oneTimePreKeyPublic: string | null
): Promise<void> { ): Promise<void> {
if (this.identityKeyPair === null || this.signedPreKey === null) { if (this.identityKeyPair === null || this.signedPreKey === null) {
throw new Error("Keys not initialized") throw new Error('Keys not initialized')
} }
let oneTimePreKey: OneTimePreKey | null = null let oneTimePreKey: OneTimePreKey | null = null
@ -214,7 +230,10 @@ class CryptoService {
await saveRatchetState(serialized) await saveRatchetState(serialized)
} }
async encrypt(peerId: string, plaintext: string): Promise<{ async encrypt(
peerId: string,
plaintext: string
): Promise<{
ciphertext: string ciphertext: string
nonce: string nonce: string
header: string header: string
@ -261,7 +280,7 @@ class CryptoService {
let ratchetHeader: MessageHeader let ratchetHeader: MessageHeader
let x3dhHeader: X3DHHeader | undefined let x3dhHeader: X3DHHeader | undefined
if ("ratchet" in parsedHeader) { if ('ratchet' in parsedHeader) {
ratchetHeader = parsedHeader.ratchet ratchetHeader = parsedHeader.ratchet
x3dhHeader = parsedHeader.x3dh x3dhHeader = parsedHeader.x3dh
} else { } else {
@ -276,7 +295,7 @@ class CryptoService {
if (state === null) { if (state === null) {
if (!x3dhHeader) { if (!x3dhHeader) {
throw new Error("Cannot establish session: missing X3DH header") throw new Error('Cannot establish session: missing X3DH header')
} }
await this.handleIncomingSession( await this.handleIncomingSession(
@ -289,7 +308,7 @@ class CryptoService {
} }
if (state === null) { if (state === null) {
throw new Error("Failed to establish session") throw new Error('Failed to establish session')
} }
const plaintextBytes = await decryptMessage(state, encryptedMessage) const plaintextBytes = await decryptMessage(state, encryptedMessage)
@ -300,7 +319,9 @@ class CryptoService {
return new TextDecoder().decode(plaintextBytes) 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) let state = this.ratchetStates.get(peerId)
if (state === undefined) { if (state === undefined) {
@ -337,18 +358,19 @@ class CryptoService {
this.ratchetStates.clear() this.ratchetStates.clear()
this.pendingX3DHHeaders.clear() this.pendingX3DHHeaders.clear()
const database = indexedDB.open("encrypted-chat-keys", 1) const database = indexedDB.open('encrypted-chat-keys', 1)
database.onsuccess = () => { database.onsuccess = () => {
const db = database.result const db = database.result
const tx = db.transaction("ratchet_states", "readwrite") const tx = db.transaction('ratchet_states', 'readwrite')
tx.objectStore("ratchet_states").clear() tx.objectStore('ratchet_states').clear()
} }
} }
} }
export const cryptoService = new CryptoService() export const cryptoService = new CryptoService()
if (typeof window !== "undefined") { if (typeof window !== 'undefined') {
(window as unknown as { resetCryptoSessions: () => Promise<void> }).resetCryptoSessions = ;(
() => cryptoService.resetAllSessions() window as unknown as { resetCryptoSessions: () => Promise<void> }
).resetCryptoSessions = () => cryptoService.resetAllSessions()
} }

View File

@ -4,32 +4,35 @@
// =================== // ===================
import type { import type {
DoubleRatchetState, DoubleRatchetState,
SerializedRatchetState,
EncryptedMessage, EncryptedMessage,
MessageHeader, MessageHeader,
} from "../types" SerializedRatchetState,
import { MAX_SKIP_MESSAGE_KEYS } from "../types" } from '../types'
import { MAX_SKIP_MESSAGE_KEYS } from '../types'
import { import {
generateX25519KeyPair,
x25519DeriveSharedSecret,
importX25519PublicKey,
importX25519PrivateKey,
exportPublicKey,
exportPrivateKey,
hkdfDerive,
aesGcmEncrypt,
aesGcmDecrypt, aesGcmDecrypt,
hmacSha256, aesGcmEncrypt,
concatBytes,
bytesToBase64,
base64ToBytes, base64ToBytes,
} from "./primitives" bytesToBase64,
concatBytes,
exportPrivateKey,
exportPublicKey,
generateX25519KeyPair,
hkdfDerive,
hmacSha256,
importX25519PrivateKey,
importX25519PublicKey,
x25519DeriveSharedSecret,
} from './primitives'
const RATCHET_INFO = new TextEncoder().encode("DoubleRatchet") const RATCHET_INFO = new TextEncoder().encode('DoubleRatchet')
const MESSAGE_KEY_INFO = new TextEncoder().encode("MessageKey") const MESSAGE_KEY_INFO = new TextEncoder().encode('MessageKey')
const CHAIN_KEY_INFO = new TextEncoder().encode("ChainKey") 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}` return `${bytesToBase64(dhPublicKey)}:${messageNumber}`
} }
@ -45,7 +48,12 @@ export async function initializeRatchetSender(
const dhOutput = await x25519DeriveSharedSecret(dhKeyPair.privateKey, peerKey) const dhOutput = await x25519DeriveSharedSecret(dhKeyPair.privateKey, peerKey)
const rootChainInput = concatBytes(sharedKey, dhOutput) 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 rootKey = derivedKeys.slice(0, 32)
const sendingChainKey = derivedKeys.slice(32, 64) const sendingChainKey = derivedKeys.slice(32, 64)
@ -105,7 +113,7 @@ async function performDHRatchet(
peerPublicKey: Uint8Array peerPublicKey: Uint8Array
): Promise<void> { ): Promise<void> {
if (state.dh_private_key === null) { 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 state.previous_sending_chain_length = state.sending_message_number
@ -114,10 +122,18 @@ async function performDHRatchet(
state.dh_peer_public_key = peerPublicKey state.dh_peer_public_key = peerPublicKey
const peerKey = await importX25519PublicKey(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 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.root_key = derivedKeys.slice(0, 32)
state.receiving_chain_key = derivedKeys.slice(32, 64) state.receiving_chain_key = derivedKeys.slice(32, 64)
@ -126,9 +142,17 @@ async function performDHRatchet(
state.dh_private_key = newDHKeyPair state.dh_private_key = newDHKeyPair
state.dh_public_key = await exportPublicKey(newDHKeyPair.publicKey) 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 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.root_key = newDerivedKeys.slice(0, 32)
state.sending_chain_key = newDerivedKeys.slice(32, 64) state.sending_chain_key = newDerivedKeys.slice(32, 64)
@ -138,16 +162,22 @@ async function skipMessageKeys(
state: DoubleRatchetState, state: DoubleRatchetState,
until: number until: number
): Promise<void> { ): 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) { 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) { 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.skipped_message_keys.set(keyId, messageKey)
state.receiving_chain_key = nextChainKey state.receiving_chain_key = nextChainKey
@ -161,10 +191,12 @@ export async function encryptMessage(
associatedData?: Uint8Array associatedData?: Uint8Array
): Promise<EncryptedMessage> { ): Promise<EncryptedMessage> {
if (state.dh_public_key === null) { 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 = { const header: MessageHeader = {
dh_public_key: bytesToBase64(state.dh_public_key), 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 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) const { ciphertext, nonce } = await aesGcmEncrypt(messageKey, plaintext, aad)
@ -194,14 +229,20 @@ export async function decryptMessage(
): Promise<Uint8Array> { ): Promise<Uint8Array> {
const peerPublicKey = base64ToBytes(message.header.dh_public_key) 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) const skippedKey = state.skipped_message_keys.get(skippedKeyId)
if (skippedKey !== undefined) { if (skippedKey !== undefined) {
state.skipped_message_keys.delete(skippedKeyId) state.skipped_message_keys.delete(skippedKeyId)
const headerBytes = new TextEncoder().encode(JSON.stringify(message.header)) 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) return await aesGcmDecrypt(skippedKey, message.ciphertext, message.nonce, aad)
} }
@ -221,15 +262,25 @@ export async function decryptMessage(
await skipMessageKeys(state, message.header.message_number) await skipMessageKeys(state, message.header.message_number)
if (state.receiving_chain_key === null) { 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 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_chain_key = nextChainKey
state.receiving_message_number++ state.receiving_message_number++
@ -242,7 +293,9 @@ export async function serializeRatchetState(
): Promise<SerializedRatchetState> { ): Promise<SerializedRatchetState> {
let dhPrivateKey: string | null = null let dhPrivateKey: string | null = null
if (state.dh_private_key !== 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) dhPrivateKey = bytesToBase64(privateKeyBytes)
} }
@ -255,14 +308,17 @@ export async function serializeRatchetState(
peer_id: state.peer_id, peer_id: state.peer_id,
root_key: bytesToBase64(state.root_key), root_key: bytesToBase64(state.root_key),
sending_chain_key: bytesToBase64(state.sending_chain_key), sending_chain_key: bytesToBase64(state.sending_chain_key),
receiving_chain_key: state.receiving_chain_key !== null receiving_chain_key:
? bytesToBase64(state.receiving_chain_key) state.receiving_chain_key !== null
: null, ? bytesToBase64(state.receiving_chain_key)
: null,
dh_private_key: dhPrivateKey, dh_private_key: dhPrivateKey,
dh_public_key: state.dh_public_key !== null ? bytesToBase64(state.dh_public_key) : null, dh_public_key:
dh_peer_public_key: state.dh_peer_public_key !== null state.dh_public_key !== null ? bytesToBase64(state.dh_public_key) : null,
? bytesToBase64(state.dh_peer_public_key) dh_peer_public_key:
: null, state.dh_peer_public_key !== null
? bytesToBase64(state.dh_peer_public_key)
: null,
sending_message_number: state.sending_message_number, sending_message_number: state.sending_message_number,
receiving_message_number: state.receiving_message_number, receiving_message_number: state.receiving_message_number,
previous_sending_chain_length: state.previous_sending_chain_length, previous_sending_chain_length: state.previous_sending_chain_length,
@ -332,7 +388,9 @@ interface SerializedEncryptedMessage {
header: MessageHeader header: MessageHeader
} }
export function deserializeEncryptedMessage(serialized: string): EncryptedMessage { export function deserializeEncryptedMessage(
serialized: string
): EncryptedMessage {
const parsed = JSON.parse(serialized) as SerializedEncryptedMessage const parsed = JSON.parse(serialized) as SerializedEncryptedMessage
return { return {
ciphertext: base64ToBytes(parsed.ciphertext), ciphertext: base64ToBytes(parsed.ciphertext),

View File

@ -2,9 +2,10 @@
// © AngelaMos | 2025 // © AngelaMos | 2025
// index.ts // index.ts
// =================== // ===================
export * from "./primitives"
export * from "./key-store" export * from './crypto-service'
export * from "./message-store" export * from './double-ratchet'
export * from "./x3dh" export * from './key-store'
export * from "./double-ratchet" export * from './message-store'
export * from "./crypto-service" export * from './primitives'
export * from './x3dh'

View File

@ -4,19 +4,19 @@
// =================== // ===================
import type { import type {
IdentityKeyPair, IdentityKeyPair,
SignedPreKey,
OneTimePreKey, OneTimePreKey,
SerializedRatchetState, SerializedRatchetState,
} from "../types" SignedPreKey,
} from '../types'
const DB_NAME = "encrypted-chat-keys" const DB_NAME = 'encrypted-chat-keys'
const DB_VERSION = 1 const DB_VERSION = 1
const STORES = { const STORES = {
IDENTITY: "identity_keys", IDENTITY: 'identity_keys',
SIGNED_PREKEYS: "signed_prekeys", SIGNED_PREKEYS: 'signed_prekeys',
ONE_TIME_PREKEYS: "one_time_prekeys", ONE_TIME_PREKEYS: 'one_time_prekeys',
RATCHET_STATES: "ratchet_states", RATCHET_STATES: 'ratchet_states',
} as const } as const
let db: IDBDatabase | null = null let db: IDBDatabase | null = null
@ -28,7 +28,7 @@ async function openDatabase(): Promise<IDBDatabase> {
const request = indexedDB.open(DB_NAME, DB_VERSION) const request = indexedDB.open(DB_NAME, DB_VERSION)
request.onerror = () => { request.onerror = () => {
reject(new Error("Failed to open key database")) reject(new Error('Failed to open key database'))
} }
request.onsuccess = () => { request.onsuccess = () => {
@ -40,22 +40,26 @@ async function openDatabase(): Promise<IDBDatabase> {
const database = (event.target as IDBOpenDBRequest).result const database = (event.target as IDBOpenDBRequest).result
if (!database.objectStoreNames.contains(STORES.IDENTITY)) { 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)) { if (!database.objectStoreNames.contains(STORES.SIGNED_PREKEYS)) {
const signedStore = database.createObjectStore(STORES.SIGNED_PREKEYS, { keyPath: "id" }) const signedStore = database.createObjectStore(STORES.SIGNED_PREKEYS, {
signedStore.createIndex("userId", "userId", { unique: false }) keyPath: 'id',
})
signedStore.createIndex('userId', 'userId', { unique: false })
} }
if (!database.objectStoreNames.contains(STORES.ONE_TIME_PREKEYS)) { if (!database.objectStoreNames.contains(STORES.ONE_TIME_PREKEYS)) {
const otpStore = database.createObjectStore(STORES.ONE_TIME_PREKEYS, { keyPath: "id" }) const otpStore = database.createObjectStore(STORES.ONE_TIME_PREKEYS, {
otpStore.createIndex("userId", "userId", { unique: false }) keyPath: 'id',
otpStore.createIndex("isUsed", "is_used", { unique: false }) })
otpStore.createIndex('userId', 'userId', { unique: false })
otpStore.createIndex('isUsed', 'is_used', { unique: false })
} }
if (!database.objectStoreNames.contains(STORES.RATCHET_STATES)) { 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) const request = operation(store)
request.onsuccess = () => resolve(request.result) 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, ...keyPair,
} }
await performTransaction( await performTransaction(STORES.IDENTITY, 'readwrite', (store) =>
STORES.IDENTITY, store.put(stored)
"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>( const result = await performTransaction<StoredIdentityKey | undefined>(
STORES.IDENTITY, STORES.IDENTITY,
"readonly", 'readonly',
(store) => store.get(userId) as IDBRequest<StoredIdentityKey | undefined> (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> { export async function deleteIdentityKey(userId: string): Promise<void> {
await performTransaction( await performTransaction(STORES.IDENTITY, 'readwrite', (store) =>
STORES.IDENTITY, store.delete(userId)
"readwrite",
(store) => store.delete(userId)
) )
} }
@ -136,17 +139,15 @@ export async function saveSignedPreKey(
...preKey, ...preKey,
} }
await performTransaction( await performTransaction(STORES.SIGNED_PREKEYS, 'readwrite', (store) =>
STORES.SIGNED_PREKEYS, store.put(stored)
"readwrite",
(store) => store.put(stored)
) )
} }
export async function getSignedPreKey(id: string): Promise<SignedPreKey | null> { export async function getSignedPreKey(id: string): Promise<SignedPreKey | null> {
const result = await performTransaction<StoredSignedPreKey | undefined>( const result = await performTransaction<StoredSignedPreKey | undefined>(
STORES.SIGNED_PREKEYS, STORES.SIGNED_PREKEYS,
"readonly", 'readonly',
(store) => store.get(id) as IDBRequest<StoredSignedPreKey | undefined> (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() const database = await openDatabase()
return new Promise((resolve, reject) => { 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 store = transaction.objectStore(STORES.SIGNED_PREKEYS)
const index = store.index("userId") const index = store.index('userId')
const request = index.getAll(userId) const request = index.getAll(userId)
request.onsuccess = () => { request.onsuccess = () => {
@ -179,7 +182,8 @@ export async function getLatestSignedPreKey(userId: string): Promise<SignedPreKe
} }
const sorted = results.sort( 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] 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> { export async function deleteSignedPreKey(id: string): Promise<void> {
await performTransaction( await performTransaction(STORES.SIGNED_PREKEYS, 'readwrite', (store) =>
STORES.SIGNED_PREKEYS, store.delete(id)
"readwrite",
(store) => store.delete(id)
) )
} }
@ -218,10 +221,8 @@ export async function saveOneTimePreKey(
...preKey, ...preKey,
} }
await performTransaction( await performTransaction(STORES.ONE_TIME_PREKEYS, 'readwrite', (store) =>
STORES.ONE_TIME_PREKEYS, store.put(stored)
"readwrite",
(store) => store.put(stored)
) )
} }
@ -232,11 +233,14 @@ export async function saveOneTimePreKeys(
const database = await openDatabase() const database = await openDatabase()
return new Promise((resolve, reject) => { 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) const store = transaction.objectStore(STORES.ONE_TIME_PREKEYS)
transaction.oncomplete = () => resolve() 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) { for (const preKey of preKeys) {
const stored: StoredOneTimePreKey = { 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>( const result = await performTransaction<StoredOneTimePreKey | undefined>(
STORES.ONE_TIME_PREKEYS, STORES.ONE_TIME_PREKEYS,
"readonly", 'readonly',
(store) => store.get(id) as IDBRequest<StoredOneTimePreKey | undefined> (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() const database = await openDatabase()
return new Promise((resolve, reject) => { 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 store = transaction.objectStore(STORES.ONE_TIME_PREKEYS)
const request = store.getAll() 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() const database = await openDatabase()
return new Promise((resolve, reject) => { 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 store = transaction.objectStore(STORES.ONE_TIME_PREKEYS)
const index = store.index("userId") const index = store.index('userId')
const request = index.getAll(userId) const request = index.getAll(userId)
request.onsuccess = () => { request.onsuccess = () => {
@ -320,14 +333,19 @@ export async function getUnusedOneTimePreKeys(userId: string): Promise<OneTimePr
resolve(unused) 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> { export async function markOneTimePreKeyUsed(id: string): Promise<void> {
const preKey = await performTransaction<StoredOneTimePreKey | undefined>( const preKey = await performTransaction<StoredOneTimePreKey | undefined>(
STORES.ONE_TIME_PREKEYS, STORES.ONE_TIME_PREKEYS,
"readonly", 'readonly',
(store) => store.get(id) as IDBRequest<StoredOneTimePreKey | undefined> (store) => store.get(id) as IDBRequest<StoredOneTimePreKey | undefined>
) )
@ -335,33 +353,31 @@ export async function markOneTimePreKeyUsed(id: string): Promise<void> {
preKey.is_used = true preKey.is_used = true
await performTransaction( await performTransaction(STORES.ONE_TIME_PREKEYS, 'readwrite', (store) =>
STORES.ONE_TIME_PREKEYS, store.put(preKey)
"readwrite",
(store) => store.put(preKey)
) )
} }
export async function deleteOneTimePreKey(id: string): Promise<void> { export async function deleteOneTimePreKey(id: string): Promise<void> {
await performTransaction( await performTransaction(STORES.ONE_TIME_PREKEYS, 'readwrite', (store) =>
STORES.ONE_TIME_PREKEYS, store.delete(id)
"readwrite",
(store) => store.delete(id)
) )
} }
export async function saveRatchetState(state: SerializedRatchetState): Promise<void> { export async function saveRatchetState(
await performTransaction( state: SerializedRatchetState
STORES.RATCHET_STATES, ): Promise<void> {
"readwrite", await performTransaction(STORES.RATCHET_STATES, 'readwrite', (store) =>
(store) => store.put(state) 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>( const result = await performTransaction<SerializedRatchetState | undefined>(
STORES.RATCHET_STATES, STORES.RATCHET_STATES,
"readonly", 'readonly',
(store) => store.get(peerId) as IDBRequest<SerializedRatchetState | undefined> (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> { export async function deleteRatchetState(peerId: string): Promise<void> {
await performTransaction( await performTransaction(STORES.RATCHET_STATES, 'readwrite', (store) =>
STORES.RATCHET_STATES, store.delete(peerId)
"readwrite",
(store) => store.delete(peerId)
) )
} }
@ -380,12 +394,15 @@ export async function getAllRatchetStates(): Promise<SerializedRatchetState[]> {
const database = await openDatabase() const database = await openDatabase()
return new Promise((resolve, reject) => { 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 store = transaction.objectStore(STORES.RATCHET_STATES)
const request = store.getAll() const request = store.getAll()
request.onsuccess = () => resolve(request.result as SerializedRatchetState[]) 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, STORES.RATCHET_STATES,
] ]
const transaction = database.transaction(storeNames, "readwrite") const transaction = database.transaction(storeNames, 'readwrite')
transaction.oncomplete = () => resolve() 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) { for (const storeName of storeNames) {
transaction.objectStore(storeName).clear() transaction.objectStore(storeName).clear()

View File

@ -2,13 +2,13 @@
// © AngelaMos | 2025 // © AngelaMos | 2025
// message-store.ts // 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 DB_VERSION = 1
const STORES = { const STORES = {
MESSAGES: "decrypted_messages", MESSAGES: 'decrypted_messages',
} as const } as const
let db: IDBDatabase | null = null let db: IDBDatabase | null = null
@ -20,7 +20,7 @@ async function openDatabase(): Promise<IDBDatabase> {
const request = indexedDB.open(DB_NAME, DB_VERSION) const request = indexedDB.open(DB_NAME, DB_VERSION)
request.onerror = () => { request.onerror = () => {
reject(new Error("Failed to open message database")) reject(new Error('Failed to open message database'))
} }
request.onsuccess = () => { request.onsuccess = () => {
@ -32,10 +32,14 @@ async function openDatabase(): Promise<IDBDatabase> {
const database = (event.target as IDBOpenDBRequest).result const database = (event.target as IDBOpenDBRequest).result
if (!database.objectStoreNames.contains(STORES.MESSAGES)) { if (!database.objectStoreNames.contains(STORES.MESSAGES)) {
const messageStore = database.createObjectStore(STORES.MESSAGES, { keyPath: "id" }) const messageStore = database.createObjectStore(STORES.MESSAGES, {
messageStore.createIndex("room_id", "room_id", { unique: false }) keyPath: 'id',
messageStore.createIndex("created_at", "created_at", { unique: false }) })
messageStore.createIndex("room_created", ["room_id", "created_at"], { unique: false }) 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) const request = operation(store)
request.onsuccess = () => resolve(request.result) 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> { export async function saveDecryptedMessage(message: Message): Promise<void> {
await performTransaction( await performTransaction(STORES.MESSAGES, 'readwrite', (store) =>
STORES.MESSAGES, store.put(message)
"readwrite",
(store) => store.put(message)
) )
} }
@ -70,11 +73,12 @@ export async function saveDecryptedMessages(messages: Message[]): Promise<void>
const database = await openDatabase() const database = await openDatabase()
return new Promise((resolve, reject) => { 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 store = transaction.objectStore(STORES.MESSAGES)
transaction.oncomplete = () => resolve() 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) { for (const message of messages) {
store.put(message) 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>( const result = await performTransaction<Message | undefined>(
STORES.MESSAGES, STORES.MESSAGES,
"readonly", 'readonly',
(store) => store.get(messageId) as IDBRequest<Message | undefined> (store) => store.get(messageId) as IDBRequest<Message | undefined>
) )
return result ?? null 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() const database = await openDatabase()
return new Promise((resolve, reject) => { 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 store = transaction.objectStore(STORES.MESSAGES)
const index = store.index("room_id") const index = store.index('room_id')
const request = index.getAll(roomId) const request = index.getAll(roomId)
request.onsuccess = () => { request.onsuccess = () => {
let results = request.result as Message[] let results = request.result as Message[]
results = results.sort( 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) { if (limit !== undefined && limit > 0) {
@ -114,19 +124,22 @@ export async function getDecryptedMessages(roomId: string, limit?: number): Prom
resolve(results) 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() const database = await openDatabase()
return new Promise((resolve, reject) => { 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 store = transaction.objectStore(STORES.MESSAGES)
const index = store.index("room_created") const index = store.index('room_created')
const range = IDBKeyRange.bound([roomId, ""], [roomId, "\uffff"]) const range = IDBKeyRange.bound([roomId, ''], [roomId, '\uffff'])
const request = index.openCursor(range, "prev") const request = index.openCursor(range, 'prev')
request.onsuccess = () => { request.onsuccess = () => {
const cursor = request.result 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> { export async function deleteMessage(messageId: string): Promise<void> {
await performTransaction( await performTransaction(STORES.MESSAGES, 'readwrite', (store) =>
STORES.MESSAGES, store.delete(messageId)
"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) const message = await getDecryptedMessage(oldId)
if (message) { if (message) {
await deleteMessage(oldId) await deleteMessage(oldId)
@ -163,9 +180,9 @@ export async function clearRoomMessages(roomId: string): Promise<void> {
const database = await openDatabase() const database = await openDatabase()
return new Promise((resolve, reject) => { 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 store = transaction.objectStore(STORES.MESSAGES)
const index = store.index("room_id") const index = store.index('room_id')
const request = index.openCursor(IDBKeyRange.only(roomId)) const request = index.openCursor(IDBKeyRange.only(roomId))
request.onsuccess = () => { request.onsuccess = () => {
@ -177,29 +194,29 @@ export async function clearRoomMessages(roomId: string): Promise<void> {
} }
transaction.oncomplete = () => resolve() 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> { export async function clearAllMessages(): Promise<void> {
await performTransaction( await performTransaction(STORES.MESSAGES, 'readwrite', (store) => store.clear())
STORES.MESSAGES,
"readwrite",
(store) => store.clear()
)
} }
export async function getMessageCount(roomId: string): Promise<number> { export async function getMessageCount(roomId: string): Promise<number> {
const database = await openDatabase() const database = await openDatabase()
return new Promise((resolve, reject) => { 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 store = transaction.objectStore(STORES.MESSAGES)
const index = store.index("room_id") const index = store.index('room_id')
const request = index.count(IDBKeyRange.only(roomId)) const request = index.count(IDBKeyRange.only(roomId))
request.onsuccess = () => resolve(request.result) 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'))
}) })
} }

View File

@ -3,23 +3,23 @@
// primitives.ts // primitives.ts
// =================== // ===================
import { import {
X25519_KEY_SIZE,
AES_GCM_KEY_SIZE, AES_GCM_KEY_SIZE,
AES_GCM_NONCE_SIZE, AES_GCM_NONCE_SIZE,
HKDF_OUTPUT_SIZE, HKDF_OUTPUT_SIZE,
} from "../types" X25519_KEY_SIZE,
} from '../types'
const crypto = globalThis.crypto const crypto = globalThis.crypto
const subtle = crypto.subtle const subtle = crypto.subtle
export async function generateX25519KeyPair(): Promise<CryptoKeyPair> { export async function generateX25519KeyPair(): Promise<CryptoKeyPair> {
const keyPair = await subtle.generateKey( const keyPair = (await subtle.generateKey(
{ {
name: "X25519", name: 'X25519',
}, },
true, true,
["deriveBits"] ['deriveBits']
) as CryptoKeyPair )) as CryptoKeyPair
return keyPair return keyPair
} }
@ -29,7 +29,7 @@ export async function x25519DeriveSharedSecret(
): Promise<Uint8Array> { ): Promise<Uint8Array> {
const sharedBits = await subtle.deriveBits( const sharedBits = await subtle.deriveBits(
{ {
name: "X25519", name: 'X25519',
public: publicKey, public: publicKey,
}, },
privateKey, privateKey,
@ -38,61 +38,65 @@ export async function x25519DeriveSharedSecret(
return new Uint8Array(sharedBits) return new Uint8Array(sharedBits)
} }
export async function importX25519PublicKey(keyBytes: Uint8Array): Promise<CryptoKey> { export async function importX25519PublicKey(
keyBytes: Uint8Array
): Promise<CryptoKey> {
if (keyBytes.length === 32) { if (keyBytes.length === 32) {
return await subtle.importKey( return await subtle.importKey(
"raw", 'raw',
keyBytes.buffer as ArrayBuffer, keyBytes.buffer as ArrayBuffer,
{ name: "X25519" }, { name: 'X25519' },
true, true,
[] []
) )
} }
return await subtle.importKey( return await subtle.importKey(
"spki", 'spki',
keyBytes.buffer as ArrayBuffer, keyBytes.buffer as ArrayBuffer,
{ name: "X25519" }, { name: 'X25519' },
true, true,
[] []
) )
} }
export async function importX25519PrivateKey(keyBytes: Uint8Array): Promise<CryptoKey> { export async function importX25519PrivateKey(
keyBytes: Uint8Array
): Promise<CryptoKey> {
if (keyBytes.length === 32) { if (keyBytes.length === 32) {
return await subtle.importKey( return await subtle.importKey(
"raw", 'raw',
keyBytes.buffer as ArrayBuffer, keyBytes.buffer as ArrayBuffer,
{ name: "X25519" }, { name: 'X25519' },
true, true,
["deriveBits"] ['deriveBits']
) )
} }
return await subtle.importKey( return await subtle.importKey(
"pkcs8", 'pkcs8',
keyBytes.buffer as ArrayBuffer, keyBytes.buffer as ArrayBuffer,
{ name: "X25519" }, { name: 'X25519' },
true, true,
["deriveBits"] ['deriveBits']
) )
} }
export async function exportPublicKey(key: CryptoKey): Promise<Uint8Array> { 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) return new Uint8Array(exported)
} }
export async function exportPrivateKey(key: CryptoKey): Promise<Uint8Array> { 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) return new Uint8Array(exported)
} }
export async function generateEd25519KeyPair(): Promise<CryptoKeyPair> { export async function generateEd25519KeyPair(): Promise<CryptoKeyPair> {
return await subtle.generateKey( return await subtle.generateKey(
{ {
name: "Ed25519", name: 'Ed25519',
}, },
true, true,
["sign", "verify"] ['sign', 'verify']
) )
} }
@ -102,7 +106,7 @@ export async function ed25519Sign(
): Promise<Uint8Array> { ): Promise<Uint8Array> {
const signature = await subtle.sign( const signature = await subtle.sign(
{ {
name: "Ed25519", name: 'Ed25519',
}, },
privateKey, privateKey,
data.buffer as ArrayBuffer data.buffer as ArrayBuffer
@ -117,7 +121,7 @@ export async function ed25519Verify(
): Promise<boolean> { ): Promise<boolean> {
return await subtle.verify( return await subtle.verify(
{ {
name: "Ed25519", name: 'Ed25519',
}, },
publicKey, publicKey,
signature.buffer as ArrayBuffer, 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) { if (keyBytes.length === 32) {
return await subtle.importKey( return await subtle.importKey(
"raw", 'raw',
keyBytes.buffer as ArrayBuffer, keyBytes.buffer as ArrayBuffer,
{ name: "Ed25519" }, { name: 'Ed25519' },
true, true,
["verify"] ['verify']
) )
} }
return await subtle.importKey( return await subtle.importKey(
"spki", 'spki',
keyBytes.buffer as ArrayBuffer, keyBytes.buffer as ArrayBuffer,
{ name: "Ed25519" }, { name: 'Ed25519' },
true, true,
["verify"] ['verify']
) )
} }
export async function importEd25519PrivateKey(keyBytes: Uint8Array): Promise<CryptoKey> { export async function importEd25519PrivateKey(
keyBytes: Uint8Array
): Promise<CryptoKey> {
if (keyBytes.length === 32) { if (keyBytes.length === 32) {
return await subtle.importKey( return await subtle.importKey(
"raw", 'raw',
keyBytes.buffer as ArrayBuffer, keyBytes.buffer as ArrayBuffer,
{ name: "Ed25519" }, { name: 'Ed25519' },
true, true,
["sign"] ['sign']
) )
} }
return await subtle.importKey( return await subtle.importKey(
"pkcs8", 'pkcs8',
keyBytes.buffer as ArrayBuffer, keyBytes.buffer as ArrayBuffer,
{ name: "Ed25519" }, { name: 'Ed25519' },
true, true,
["sign"] ['sign']
) )
} }
@ -170,17 +178,17 @@ export async function hkdfDerive(
outputLength: number = HKDF_OUTPUT_SIZE outputLength: number = HKDF_OUTPUT_SIZE
): Promise<Uint8Array> { ): Promise<Uint8Array> {
const baseKey = await subtle.importKey( const baseKey = await subtle.importKey(
"raw", 'raw',
inputKeyMaterial.buffer as ArrayBuffer, inputKeyMaterial.buffer as ArrayBuffer,
{ name: "HKDF" }, { name: 'HKDF' },
false, false,
["deriveBits"] ['deriveBits']
) )
const derivedBits = await subtle.deriveBits( const derivedBits = await subtle.deriveBits(
{ {
name: "HKDF", name: 'HKDF',
hash: "SHA-256", hash: 'SHA-256',
salt: salt.buffer as ArrayBuffer, salt: salt.buffer as ArrayBuffer,
info: info.buffer as ArrayBuffer, info: info.buffer as ArrayBuffer,
}, },
@ -197,27 +205,27 @@ export async function hkdfDeriveKey(
info: Uint8Array info: Uint8Array
): Promise<CryptoKey> { ): Promise<CryptoKey> {
const keyMaterial = await subtle.importKey( const keyMaterial = await subtle.importKey(
"raw", 'raw',
inputKeyMaterial.buffer as ArrayBuffer, inputKeyMaterial.buffer as ArrayBuffer,
{ name: "HKDF" }, { name: 'HKDF' },
false, false,
["deriveKey"] ['deriveKey']
) )
return await subtle.deriveKey( return await subtle.deriveKey(
{ {
name: "HKDF", name: 'HKDF',
hash: "SHA-256", hash: 'SHA-256',
salt: salt.buffer as ArrayBuffer, salt: salt.buffer as ArrayBuffer,
info: info.buffer as ArrayBuffer, info: info.buffer as ArrayBuffer,
}, },
keyMaterial, keyMaterial,
{ {
name: "AES-GCM", name: 'AES-GCM',
length: AES_GCM_KEY_SIZE * 8, length: AES_GCM_KEY_SIZE * 8,
}, },
true, true,
["encrypt", "decrypt"] ['encrypt', 'decrypt']
) )
} }
@ -230,11 +238,11 @@ export async function aesGcmEncrypt(
if (key instanceof Uint8Array) { if (key instanceof Uint8Array) {
cryptoKey = await subtle.importKey( cryptoKey = await subtle.importKey(
"raw", 'raw',
key.buffer as ArrayBuffer, key.buffer as ArrayBuffer,
{ name: "AES-GCM", length: AES_GCM_KEY_SIZE * 8 }, { name: 'AES-GCM', length: AES_GCM_KEY_SIZE * 8 },
false, false,
["encrypt"] ['encrypt']
) )
} else { } else {
cryptoKey = key cryptoKey = key
@ -244,7 +252,7 @@ export async function aesGcmEncrypt(
const ciphertext = await subtle.encrypt( const ciphertext = await subtle.encrypt(
{ {
name: "AES-GCM", name: 'AES-GCM',
iv: nonce.buffer as ArrayBuffer, iv: nonce.buffer as ArrayBuffer,
additionalData: associatedData?.buffer as ArrayBuffer | undefined, additionalData: associatedData?.buffer as ArrayBuffer | undefined,
}, },
@ -268,11 +276,11 @@ export async function aesGcmDecrypt(
if (key instanceof Uint8Array) { if (key instanceof Uint8Array) {
cryptoKey = await subtle.importKey( cryptoKey = await subtle.importKey(
"raw", 'raw',
key.buffer as ArrayBuffer, key.buffer as ArrayBuffer,
{ name: "AES-GCM", length: AES_GCM_KEY_SIZE * 8 }, { name: 'AES-GCM', length: AES_GCM_KEY_SIZE * 8 },
false, false,
["decrypt"] ['decrypt']
) )
} else { } else {
cryptoKey = key cryptoKey = key
@ -280,7 +288,7 @@ export async function aesGcmDecrypt(
const plaintext = await subtle.decrypt( const plaintext = await subtle.decrypt(
{ {
name: "AES-GCM", name: 'AES-GCM',
iv: nonce.buffer as ArrayBuffer, iv: nonce.buffer as ArrayBuffer,
additionalData: associatedData?.buffer as ArrayBuffer | undefined, additionalData: associatedData?.buffer as ArrayBuffer | undefined,
}, },
@ -298,12 +306,12 @@ export function generateRandomBytes(length: number): Uint8Array {
} }
export async function sha256(data: Uint8Array): Promise<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) return new Uint8Array(hash)
} }
export async function sha512(data: Uint8Array): Promise<Uint8Array> { 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) return new Uint8Array(hash)
} }
@ -312,17 +320,21 @@ export async function hmacSha256(
data: Uint8Array data: Uint8Array
): Promise<Uint8Array> { ): Promise<Uint8Array> {
const cryptoKey = await subtle.importKey( const cryptoKey = await subtle.importKey(
"raw", 'raw',
key.buffer as ArrayBuffer, key.buffer as ArrayBuffer,
{ {
name: "HMAC", name: 'HMAC',
hash: "SHA-256", hash: 'SHA-256',
}, },
false, 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) return new Uint8Array(signature)
} }
@ -332,17 +344,22 @@ export async function hmacSha256Verify(
data: Uint8Array data: Uint8Array
): Promise<boolean> { ): Promise<boolean> {
const cryptoKey = await subtle.importKey( const cryptoKey = await subtle.importKey(
"raw", 'raw',
key.buffer as ArrayBuffer, key.buffer as ArrayBuffer,
{ {
name: "HMAC", name: 'HMAC',
hash: "SHA-256", hash: 'SHA-256',
}, },
false, 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 { export function concatBytes(...arrays: Uint8Array[]): Uint8Array {
@ -373,8 +390,8 @@ export function base64ToBytes(base64: string): Uint8Array {
export function bytesToHex(bytes: Uint8Array): string { export function bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes) return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0")) .map((b) => b.toString(16).padStart(2, '0'))
.join("") .join('')
} }
export function hexToBytes(hex: string): Uint8Array { export function hexToBytes(hex: string): Uint8Array {

View File

@ -3,34 +3,34 @@
// x3dh.ts // x3dh.ts
// =================== // ===================
import type { import type {
PreKeyBundle,
X3DHResult,
X3DHHeader,
IdentityKeyPair, IdentityKeyPair,
SignedPreKey,
OneTimePreKey, OneTimePreKey,
} from "../types" PreKeyBundle,
SignedPreKey,
X3DHHeader,
X3DHResult,
} from '../types'
import { DEFAULT_ONE_TIME_PREKEY_COUNT, HKDF_OUTPUT_SIZE } from '../types'
import { import {
generateX25519KeyPair, base64ToBytes,
x25519DeriveSharedSecret, bytesToBase64,
importX25519PublicKey, concatBytes,
importX25519PrivateKey,
exportPublicKey,
exportPrivateKey,
generateEd25519KeyPair,
ed25519Sign, ed25519Sign,
ed25519Verify, ed25519Verify,
importEd25519PublicKey, exportPrivateKey,
importEd25519PrivateKey, exportPublicKey,
hkdfDerive, generateEd25519KeyPair,
concatBytes,
bytesToBase64,
base64ToBytes,
generateRandomBytes, generateRandomBytes,
} from "./primitives" generateX25519KeyPair,
import { HKDF_OUTPUT_SIZE, DEFAULT_ONE_TIME_PREKEY_COUNT } from "../types" 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) const EMPTY_SALT = new Uint8Array(HKDF_OUTPUT_SIZE)
export async function generateIdentityKeyPair(): Promise<IdentityKeyPair> { export async function generateIdentityKeyPair(): Promise<IdentityKeyPair> {
@ -57,7 +57,9 @@ export async function generateSignedPreKey(
const publicKey = await exportPublicKey(keyPair.publicKey) const publicKey = await exportPublicKey(keyPair.publicKey)
const privateKey = await exportPrivateKey(keyPair.privateKey) 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 signature = await ed25519Sign(signingKey, publicKey)
const id = bytesToBase64(generateRandomBytes(16)) const id = bytesToBase64(generateRandomBytes(16))
@ -102,7 +104,9 @@ export async function verifySignedPreKey(
signature: string signature: string
): Promise<boolean> { ): Promise<boolean> {
try { try {
const verifyKey = await importEd25519PublicKey(base64ToBytes(identityPublicKey)) const verifyKey = await importEd25519PublicKey(
base64ToBytes(identityPublicKey)
)
const publicKeyBytes = base64ToBytes(signedPreKeyPublic) const publicKeyBytes = base64ToBytes(signedPreKeyPublic)
const signatureBytes = base64ToBytes(signature) const signatureBytes = base64ToBytes(signature)
@ -123,7 +127,7 @@ export async function initiateX3DH(
) )
if (!signatureValid) { if (!signatureValid) {
throw new Error("Invalid signed prekey signature") throw new Error('Invalid signed prekey signature')
} }
const ephemeralKeyPair = await generateX25519KeyPair() const ephemeralKeyPair = await generateX25519KeyPair()
@ -139,9 +143,18 @@ export async function initiateX3DH(
base64ToBytes(recipientBundle.signed_prekey) base64ToBytes(recipientBundle.signed_prekey)
) )
const dh1 = await x25519DeriveSharedSecret(senderIdentityPrivate, recipientSignedPreKeyPublic) const dh1 = await x25519DeriveSharedSecret(
const dh2 = await x25519DeriveSharedSecret(ephemeralKeyPair.privateKey, recipientIdentityPublic) senderIdentityPrivate,
const dh3 = await x25519DeriveSharedSecret(ephemeralKeyPair.privateKey, recipientSignedPreKeyPublic) recipientSignedPreKeyPublic
)
const dh2 = await x25519DeriveSharedSecret(
ephemeralKeyPair.privateKey,
recipientIdentityPublic
)
const dh3 = await x25519DeriveSharedSecret(
ephemeralKeyPair.privateKey,
recipientSignedPreKeyPublic
)
let dhResults: Uint8Array[] let dhResults: Uint8Array[]
let usedOneTimePreKey = false let usedOneTimePreKey = false
@ -150,7 +163,10 @@ export async function initiateX3DH(
const recipientOneTimePreKeyPublic = await importX25519PublicKey( const recipientOneTimePreKeyPublic = await importX25519PublicKey(
base64ToBytes(recipientBundle.one_time_prekey) base64ToBytes(recipientBundle.one_time_prekey)
) )
const dh4 = await x25519DeriveSharedSecret(ephemeralKeyPair.privateKey, recipientOneTimePreKeyPublic) const dh4 = await x25519DeriveSharedSecret(
ephemeralKeyPair.privateKey,
recipientOneTimePreKeyPublic
)
dhResults = [dh1, dh2, dh3, dh4] dhResults = [dh1, dh2, dh3, dh4]
usedOneTimePreKey = true usedOneTimePreKey = true
} else { } else {
@ -192,9 +208,18 @@ export async function receiveX3DH(
base64ToBytes(senderEphemeralKey) base64ToBytes(senderEphemeralKey)
) )
const dh1 = await x25519DeriveSharedSecret(recipientSignedPreKeyPrivate, senderIdentityPublic) const dh1 = await x25519DeriveSharedSecret(
const dh2 = await x25519DeriveSharedSecret(recipientIdentityPrivate, senderEphemeralPublic) recipientSignedPreKeyPrivate,
const dh3 = await x25519DeriveSharedSecret(recipientSignedPreKeyPrivate, senderEphemeralPublic) senderIdentityPublic
)
const dh2 = await x25519DeriveSharedSecret(
recipientIdentityPrivate,
senderEphemeralPublic
)
const dh3 = await x25519DeriveSharedSecret(
recipientSignedPreKeyPrivate,
senderEphemeralPublic
)
let dhResults: Uint8Array[] let dhResults: Uint8Array[]
@ -202,7 +227,10 @@ export async function receiveX3DH(
const recipientOneTimePreKeyPrivate = await importX25519PrivateKey( const recipientOneTimePreKeyPrivate = await importX25519PrivateKey(
base64ToBytes(oneTimePreKey.private_key) base64ToBytes(oneTimePreKey.private_key)
) )
const dh4 = await x25519DeriveSharedSecret(recipientOneTimePreKeyPrivate, senderEphemeralPublic) const dh4 = await x25519DeriveSharedSecret(
recipientOneTimePreKeyPrivate,
senderEphemeralPublic
)
dhResults = [dh1, dh2, dh3, dh4] dhResults = [dh1, dh2, dh3, dh4]
} else { } else {
dhResults = [dh1, dh2, dh3] dhResults = [dh1, dh2, dh3]

View File

@ -1,9 +1,9 @@
import { render } from "solid-js/web"; import { Router } from '@solidjs/router'
import { Router } from "@solidjs/router"; import { QueryClient, QueryClientProvider } from '@tanstack/solid-query'
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"; import { render } from 'solid-js/web'
import App from "./App"; import App from './App'
import { ToastContainer } from "./components/UI/Toast"; import { ToastContainer } from './components/UI/Toast'
import "./index.css"; import './index.css'
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
@ -13,12 +13,12 @@ const queryClient = new QueryClient({
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
}, },
}, },
}); })
const root = document.getElementById("root"); const root = document.getElementById('root')
if (root === null) { if (root === null) {
throw new Error("Root element not found"); throw new Error('Root element not found')
} }
render( render(
@ -31,4 +31,4 @@ render(
</QueryClientProvider> </QueryClientProvider>
), ),
root root
); )

View File

@ -2,36 +2,33 @@
// © AngelaMos | 2025 // © AngelaMos | 2025
// Chat.tsx // Chat.tsx
// =================== // ===================
import { Show, onMount, onCleanup, createEffect } from "solid-js"
import type { JSX } from "solid-js" import { useStore } from '@nanostores/solid'
import type { Participant } from "../types" import type { JSX } from 'solid-js'
import { useStore } from "@nanostores/solid" import { createEffect, onCleanup, onMount, Show } from 'solid-js'
import { AppShell, ProtectedRoute } from "../components/Layout"
import { import {
MessageList,
ChatHeader, ChatHeader,
ChatInput, ChatInput,
MessageList,
NewConversation, NewConversation,
} from "../components/Chat" } from '../components/Chat'
import { AppShell, ProtectedRoute } from '../components/Layout'
import { cryptoService, saveDecryptedMessage } from '../crypto'
import { roomService } from '../services'
import { import {
$activeModal,
$activeRoom, $activeRoom,
$activeRoomId, $activeRoomId,
$activeModal,
$userId,
$currentUser, $currentUser,
showToast, $userId,
setActiveRoom,
openModal,
closeModal,
addMessage, addMessage,
} from "../stores" closeModal,
import { openModal,
connectWebSocket, setActiveRoom,
disconnectWebSocket, showToast,
wsManager, } from '../stores'
} from "../websocket" import type { Participant } from '../types'
import { roomService } from "../services" import { connectWebSocket, disconnectWebSocket, wsManager } from '../websocket'
import { cryptoService, saveDecryptedMessage } from "../crypto"
export default function Chat(): JSX.Element { export default function Chat(): JSX.Element {
const activeRoom = useStore($activeRoom) const activeRoom = useStore($activeRoom)
@ -51,8 +48,7 @@ export default function Chat(): JSX.Element {
if (currentUserId) { if (currentUserId) {
try { try {
await cryptoService.initialize(currentUserId) await cryptoService.initialize(currentUserId)
} catch { } catch {}
}
connectWebSocket() connectWebSocket()
await roomService.loadRooms(currentUserId) await roomService.loadRooms(currentUserId)
} }
@ -69,19 +65,21 @@ export default function Chat(): JSX.Element {
const user = $currentUser.get() const user = $currentUser.get()
if (roomId === null || room === null) { if (roomId === null || room === null) {
showToast("error", "SEND FAILED", "NO ACTIVE ROOM") showToast('error', 'SEND FAILED', 'NO ACTIVE ROOM')
return return
} }
if (currentUserId === null) { if (currentUserId === null) {
showToast("error", "SEND FAILED", "NOT AUTHENTICATED") showToast('error', 'SEND FAILED', 'NOT AUTHENTICATED')
return 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) { if (recipientId === undefined) {
showToast("error", "SEND FAILED", "NO RECIPIENT FOUND") showToast('error', 'SEND FAILED', 'NO RECIPIENT FOUND')
return return
} }
@ -92,9 +90,9 @@ export default function Chat(): JSX.Element {
id: tempId, id: tempId,
room_id: roomId, room_id: roomId,
sender_id: currentUserId, sender_id: currentUserId,
sender_username: user?.username ?? "me", sender_username: user?.username ?? 'me',
content, content,
status: "sending" as const, status: 'sending' as const,
is_encrypted: true, is_encrypted: true,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
@ -114,11 +112,10 @@ export default function Chat(): JSX.Element {
if (sent) { if (sent) {
void saveDecryptedMessage(messageToSend) void saveDecryptedMessage(messageToSend)
} else { } else {
showToast("error", "SEND FAILED", "NOT CONNECTED") showToast('error', 'SEND FAILED', 'NOT CONNECTED')
} }
} catch (error) { } catch (_error) {
console.error("[Chat] Encryption failed:", error) showToast('error', 'SEND FAILED', 'ENCRYPTION ERROR')
showToast("error", "SEND FAILED", "ENCRYPTION ERROR")
} }
} }
@ -126,7 +123,7 @@ export default function Chat(): JSX.Element {
const currentUserId = userId() const currentUserId = userId()
if (currentUserId === null) { if (currentUserId === null) {
showToast("error", "FAILED", "NOT AUTHENTICATED") showToast('error', 'FAILED', 'NOT AUTHENTICATED')
return return
} }
@ -136,12 +133,12 @@ export default function Chat(): JSX.Element {
setActiveRoom(room.id) setActiveRoom(room.id)
closeModal() closeModal()
} else { } else {
showToast("error", "FAILED", "COULD NOT CREATE CONVERSATION") showToast('error', 'FAILED', 'COULD NOT CREATE CONVERSATION')
} }
} }
const handleNewChat = (): void => { const handleNewChat = (): void => {
openModal("new-conversation") openModal('new-conversation')
} }
return ( return (
@ -155,15 +152,15 @@ export default function Chat(): JSX.Element {
> >
{(roomId) => ( {(roomId) => (
<> <>
<ChatHeader <ChatHeader room={activeRoom()} />
room={activeRoom()} <MessageList roomId={roomId} />
/>
<MessageList
roomId={roomId}
/>
<ChatInput <ChatInput
roomId={roomId} 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} onSend={handleSendMessage}
/> />
</> </>
@ -172,7 +169,7 @@ export default function Chat(): JSX.Element {
</div> </div>
<NewConversation <NewConversation
isOpen={activeModal() === "new-conversation"} isOpen={activeModal() === 'new-conversation'}
onClose={closeModal} onClose={closeModal}
onCreateRoom={handleCreateRoom} onCreateRoom={handleCreateRoom}
/> />
@ -210,7 +207,14 @@ function EmptyState(props: EmptyStateProps): JSX.Element {
function ChatIcon(): JSX.Element { function ChatIcon(): JSX.Element {
return ( 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="8" y="8" width="32" height="4" />
<rect x="4" y="12" width="4" height="24" /> <rect x="4" y="12" width="4" height="24" />
<rect x="40" y="12" width="4" height="24" /> <rect x="40" y="12" width="4" height="24" />

View File

@ -2,12 +2,12 @@
* Home page - landing or redirect to chat * Home page - landing or redirect to chat
*/ */
import type { JSX } from "solid-js" import { useStore } from '@nanostores/solid'
import { Show } from "solid-js" import { A } from '@solidjs/router'
import { A } from "@solidjs/router" import type { JSX } from 'solid-js'
import { useStore } from "@nanostores/solid" import { Show } from 'solid-js'
import { $isAuthenticated } from "../stores" import { Button } from '../components/UI'
import { Button } from "../components/UI" import { $isAuthenticated } from '../stores'
export default function Home(): JSX.Element { export default function Home(): JSX.Element {
const isAuthenticated = useStore($isAuthenticated) const isAuthenticated = useStore($isAuthenticated)
@ -19,13 +19,11 @@ export default function Home(): JSX.Element {
<LockIcon /> <LockIcon />
</div> </div>
<h1 class="font-pixel text-2xl text-orange mb-4"> <h1 class="font-pixel text-2xl text-orange mb-4">ENCRYPTED CHAT</h1>
ENCRYPTED CHAT
</h1>
<p class="font-pixel text-[10px] text-gray mb-8 leading-relaxed"> <p class="font-pixel text-[10px] text-gray mb-8 leading-relaxed">
END-TO-END ENCRYPTED MESSAGING WITH DOUBLE RATCHET PROTOCOL. END-TO-END ENCRYPTED MESSAGING WITH DOUBLE RATCHET PROTOCOL. YOUR
YOUR MESSAGES ARE SECURE AND PRIVATE. MESSAGES ARE SECURE AND PRIVATE.
</p> </p>
<div class="flex flex-col gap-4 items-center"> <div class="flex flex-col gap-4 items-center">
@ -80,20 +78,75 @@ function FeatureItem(props: FeatureItemProps): JSX.Element {
function LockIcon(): JSX.Element { function LockIcon(): JSX.Element {
return ( return (
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" class="mx-auto"> <svg
<rect x="20" y="12" width="24" height="4" fill="currentColor" class="text-orange" /> width="64"
<rect x="16" y="16" width="4" height="16" fill="currentColor" class="text-orange" /> height="64"
<rect x="44" y="16" width="4" height="16" fill="currentColor" class="text-orange" /> viewBox="0 0 64 64"
<rect x="12" y="32" width="40" height="4" fill="currentColor" class="text-orange" /> fill="none"
<rect x="12" y="36" width="40" height="20" fill="currentColor" class="text-orange" /> class="mx-auto"
<rect x="30" y="42" width="4" height="8" fill="currentColor" class="text-black" /> 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> </svg>
) )
} }
function EncryptIcon(): JSX.Element { function EncryptIcon(): JSX.Element {
return ( 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="10" y="2" width="4" height="4" />
<rect x="6" y="6" width="4" height="4" /> <rect x="6" y="6" width="4" height="4" />
<rect x="14" 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 { function KeyIcon(): JSX.Element {
return ( 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="4" y="8" width="4" height="4" />
<rect x="8" y="4" width="4" height="4" /> <rect x="8" y="4" width="4" height="4" />
<rect x="8" y="12" 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 { function ShieldIcon(): JSX.Element {
return ( 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="10" y="2" width="4" height="2" />
<rect x="6" y="4" width="4" height="2" /> <rect x="6" y="4" width="4" height="2" />
<rect x="14" y="4" width="4" height="2" /> <rect x="14" y="4" width="4" height="2" />

View File

@ -2,9 +2,9 @@
* Login page with WebAuthn passkey authentication * Login page with WebAuthn passkey authentication
*/ */
import type { JSX } from "solid-js" import type { JSX } from 'solid-js'
import { AuthForm } from "../components/Auth" import { AuthForm } from '../components/Auth'
import { GuestRoute } from "../components/Layout" import { GuestRoute } from '../components/Layout'
export default function Login(): JSX.Element { export default function Login(): JSX.Element {
return ( return (

View File

@ -2,21 +2,17 @@
* 404 Not Found page * 404 Not Found page
*/ */
import type { JSX } from "solid-js" import { A } from '@solidjs/router'
import { A } from "@solidjs/router" import type { JSX } from 'solid-js'
import { Button } from "../components/UI" import { Button } from '../components/UI'
export default function NotFound(): JSX.Element { export default function NotFound(): JSX.Element {
return ( return (
<div class="min-h-screen flex flex-col items-center justify-center bg-black p-4"> <div class="min-h-screen flex flex-col items-center justify-center bg-black p-4">
<div class="text-center"> <div class="text-center">
<h1 class="font-pixel text-6xl text-orange mb-4"> <h1 class="font-pixel text-6xl text-orange mb-4">404</h1>
404
</h1>
<p class="font-pixel text-sm text-white mb-2"> <p class="font-pixel text-sm text-white mb-2">PAGE NOT FOUND</p>
PAGE NOT FOUND
</p>
<p class="font-pixel text-[10px] text-gray mb-8"> <p class="font-pixel text-[10px] text-gray mb-8">
THE PAGE YOU ARE LOOKING FOR DOES NOT EXIST THE PAGE YOU ARE LOOKING FOR DOES NOT EXIST

View File

@ -3,9 +3,9 @@
// Registration page with WebAuthn passkey creation // Registration page with WebAuthn passkey creation
// =================== // ===================
import type { JSX } from "solid-js" import type { JSX } from 'solid-js'
import { AuthForm } from "../components/Auth" import { AuthForm } from '../components/Auth'
import { GuestRoute } from "../components/Layout" import { GuestRoute } from '../components/Layout'
export default function Register(): JSX.Element { export default function Register(): JSX.Element {
return ( return (

View File

@ -3,20 +3,17 @@
* Handles passkey registration and authentication flows * Handles passkey registration and authentication flows
*/ */
import { api } from "../lib/api-client" import { cryptoService } from '../crypto'
import { import { api } from '../lib/api-client'
base64UrlEncode, import { base64UrlDecode, base64UrlEncode } from '../lib/base64'
base64UrlDecode, import { setCurrentUser, logout as storeLogout } from '../stores'
} from "../lib/base64"
import type { import type {
User,
RegistrationBeginRequest,
AuthenticationBeginRequest, AuthenticationBeginRequest,
PublicKeyCredentialJSON, PublicKeyCredentialJSON,
} from "../types" RegistrationBeginRequest,
import { isPublicKeyCredential } from "../types/guards" User,
import { setCurrentUser, logout as storeLogout } from "../stores" } from '../types'
import { cryptoService } from "../crypto" import { isPublicKeyCredential } from '../types/guards'
interface PublicKeyCredentialStatic { interface PublicKeyCredentialStatic {
isUserVerifyingPlatformAuthenticatorAvailable(): Promise<boolean> isUserVerifyingPlatformAuthenticatorAvailable(): Promise<boolean>
@ -31,22 +28,32 @@ function publicKeyCredentialToJSON(
const json: PublicKeyCredentialJSON = { const json: PublicKeyCredentialJSON = {
id: credential.id, id: credential.id,
rawId: base64UrlEncode(credential.rawId), rawId: base64UrlEncode(credential.rawId),
type: "public-key", type: 'public-key',
response: { response: {
clientDataJSON: base64UrlEncode(response.clientDataJSON), clientDataJSON: base64UrlEncode(response.clientDataJSON),
}, },
authenticatorAttachment: credential.authenticatorAttachment as "platform" | "cross-platform" | undefined, authenticatorAttachment: credential.authenticatorAttachment as
clientExtensionResults: credential.getClientExtensionResults() as Record<string, unknown>, | 'platform'
| 'cross-platform'
| undefined,
clientExtensionResults: credential.getClientExtensionResults() as Record<
string,
unknown
>,
} }
if ("attestationObject" in response) { if ('attestationObject' in response) {
const attestationResponse = response as AuthenticatorAttestationResponse 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 const assertionResponse = response as AuthenticatorAssertionResponse
json.response.authenticatorData = base64UrlEncode(assertionResponse.authenticatorData) json.response.authenticatorData = base64UrlEncode(
assertionResponse.authenticatorData
)
json.response.signature = base64UrlEncode(assertionResponse.signature) json.response.signature = base64UrlEncode(assertionResponse.signature)
if (assertionResponse.userHandle !== null) { if (assertionResponse.userHandle !== null) {
json.response.userHandle = base64UrlEncode(assertionResponse.userHandle) json.response.userHandle = base64UrlEncode(assertionResponse.userHandle)
@ -57,7 +64,10 @@ function publicKeyCredentialToJSON(
} }
function toBufferSource(data: Uint8Array): ArrayBuffer { 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( function preparePublicKeyOptions(
@ -65,34 +75,48 @@ function preparePublicKeyOptions(
): PublicKeyCredentialCreationOptions | PublicKeyCredentialRequestOptions { ): PublicKeyCredentialCreationOptions | PublicKeyCredentialRequestOptions {
const prepared = { ...options } const prepared = { ...options }
if ("challenge" in prepared && typeof prepared.challenge === "string") { if ('challenge' in prepared && typeof prepared.challenge === 'string') {
prepared.challenge = toBufferSource(base64UrlDecode(prepared.challenge as unknown as string)) prepared.challenge = toBufferSource(
base64UrlDecode(prepared.challenge as unknown as string)
)
} }
if ("user" in prepared) { if ('user' in prepared) {
const creationOptions = prepared as PublicKeyCredentialCreationOptions const creationOptions = prepared as PublicKeyCredentialCreationOptions
if (typeof creationOptions.user.id === "string") { if (typeof creationOptions.user.id === 'string') {
creationOptions.user.id = toBufferSource(base64UrlDecode(creationOptions.user.id as unknown as 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 const creationOptions = prepared as PublicKeyCredentialCreationOptions
if (creationOptions.excludeCredentials !== undefined) { if (creationOptions.excludeCredentials !== undefined) {
creationOptions.excludeCredentials = creationOptions.excludeCredentials.map((cred) => ({ creationOptions.excludeCredentials = creationOptions.excludeCredentials.map(
...cred, (cred) => ({
id: typeof cred.id === "string" ? toBufferSource(base64UrlDecode(cred.id as unknown as string)) : cred.id, ...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 const requestOptions = prepared as PublicKeyCredentialRequestOptions
if (requestOptions.allowCredentials !== undefined) { if (requestOptions.allowCredentials !== undefined) {
requestOptions.allowCredentials = requestOptions.allowCredentials.map((cred) => ({ requestOptions.allowCredentials = requestOptions.allowCredentials.map(
...cred, (cred) => ({
id: typeof cred.id === "string" ? toBufferSource(base64UrlDecode(cred.id as unknown as string)) : cred.id, ...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)) { if (!isPublicKeyCredential(credential)) {
throw new Error("Failed to create credential") throw new Error('Failed to create credential')
} }
const credentialJSON = publicKeyCredentialToJSON(credential) const credentialJSON = publicKeyCredentialToJSON(credential)
@ -153,7 +177,7 @@ export async function login(username?: string): Promise<User> {
}) })
if (!isPublicKeyCredential(credential)) { if (!isPublicKeyCredential(credential)) {
throw new Error("Failed to get credential") throw new Error('Failed to get credential')
} }
const credentialJSON = publicKeyCredentialToJSON(credential) const credentialJSON = publicKeyCredentialToJSON(credential)
@ -174,9 +198,9 @@ export function logout(): void {
export function isWebAuthnSupported(): boolean { export function isWebAuthnSupported(): boolean {
return ( return (
typeof window !== "undefined" && typeof window !== 'undefined' &&
typeof window.PublicKeyCredential !== "undefined" && typeof window.PublicKeyCredential !== 'undefined' &&
typeof navigator.credentials !== "undefined" typeof navigator.credentials !== 'undefined'
) )
} }
@ -199,7 +223,7 @@ export async function isConditionalUIAvailable(): Promise<boolean> {
try { try {
const pkc = PublicKeyCredential as unknown as PublicKeyCredentialStatic const pkc = PublicKeyCredential as unknown as PublicKeyCredentialStatic
if (typeof pkc.isConditionalMediationAvailable === "function") { if (typeof pkc.isConditionalMediationAvailable === 'function') {
return await pkc.isConditionalMediationAvailable() return await pkc.isConditionalMediationAvailable()
} }
return false return false

View File

@ -2,5 +2,5 @@
// © AngelaMos | 2025 // © AngelaMos | 2025
// index.ts // index.ts
// =================== // ===================
export * from "./auth.service" export * from './auth.service'
export * from "./room.service" export * from './room.service'

View File

@ -3,21 +3,21 @@
* room.service.ts * room.service.ts
*/ */
import { api } from "../lib/api-client"
import type { Message, Room } from "../types"
import { import {
setRooms,
addRoom,
setRoomMessages,
setHasMore,
$userId,
} from "../stores"
import {
getDecryptedMessages,
getDecryptedMessage,
saveDecryptedMessage,
cryptoService, 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[]> { export async function loadRooms(userId: string): Promise<Room[]> {
try { try {
@ -32,7 +32,7 @@ export async function loadRooms(userId: string): Promise<Room[]> {
export async function createRoom( export async function createRoom(
creatorId: string, creatorId: string,
participantId: string, participantId: string,
roomType: "direct" | "group" | "ephemeral" = "direct" roomType: 'direct' | 'group' | 'ephemeral' = 'direct'
): Promise<Room | null> { ): Promise<Room | null> {
try { try {
const room = await api.rooms.create({ const room = await api.rooms.create({
@ -42,8 +42,7 @@ export async function createRoom(
}) })
addRoom(room) addRoom(room)
return room return room
} catch (err) { } catch (_err) {
console.error("[RoomService] Failed to create room:", err)
return null return null
} }
} }
@ -73,7 +72,7 @@ export async function loadMessages(
continue continue
} }
let content = "[Encrypted - from another session]" let content = '[Encrypted - from another session]'
const isOwnMessage = msg.sender_id === currentUserId const isOwnMessage = msg.sender_id === currentUserId
if (isOwnMessage) { if (isOwnMessage) {
@ -81,7 +80,7 @@ export async function loadMessages(
if (localCopy) { if (localCopy) {
content = localCopy.content content = localCopy.content
} else { } else {
content = "[Your message - not stored locally]" content = '[Your message - not stored locally]'
} }
} else { } else {
try { try {
@ -92,7 +91,7 @@ export async function loadMessages(
msg.header msg.header
) )
} catch { } 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_id: msg.sender_id,
sender_username: msg.sender_username, sender_username: msg.sender_username,
content, content,
status: "delivered" as const, status: 'delivered' as const,
is_encrypted: true, is_encrypted: true,
encrypted_content: msg.ciphertext, encrypted_content: msg.ciphertext,
nonce: msg.nonce, nonce: msg.nonce,
@ -111,7 +110,10 @@ export async function loadMessages(
updated_at: msg.created_at, updated_at: msg.created_at,
} }
if (!content.startsWith("[Encrypted") && !content.startsWith("[Your message")) { if (
!content.startsWith('[Encrypted') &&
!content.startsWith('[Your message')
) {
void saveDecryptedMessage(decryptedMessage) void saveDecryptedMessage(decryptedMessage)
} }
@ -121,13 +123,15 @@ export async function loadMessages(
const allMessages = [...localMessages, ...newMessages] const allMessages = [...localMessages, ...newMessages]
const uniqueMessages = Array.from( const uniqueMessages = Array.from(
new Map(allMessages.map((m) => [m.id, m])).values() 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) setRoomMessages(roomId, uniqueMessages)
setHasMore(roomId, response.has_more) setHasMore(roomId, response.has_more)
return uniqueMessages return uniqueMessages
} catch (err) { } catch (_err) {
console.error("[RoomService] Failed to load messages:", err)
const localMessages = await getDecryptedMessages(roomId, limit) const localMessages = await getDecryptedMessages(roomId, limit)
if (localMessages.length > 0) { if (localMessages.length > 0) {
setRoomMessages(roomId, localMessages) setRoomMessages(roomId, localMessages)

View File

@ -3,18 +3,18 @@
* Manages current user and authentication status * Manages current user and authentication status
*/ */
import { atom, computed } from "nanostores" import { persistentAtom } from '@nanostores/persistent'
import { persistentAtom } from "@nanostores/persistent" import { atom, computed } from 'nanostores'
import type { User, Session } from "../types" import type { Session, User } from '../types'
export const $currentUser = atom<User | null>(null) 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, encode: (value: string) => value,
decode: (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) 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 { export function setCurrentUser(user: User | null): void {
$currentUser.set(user) $currentUser.set(user)
$userIdRaw.set(user?.id ?? "") $userIdRaw.set(user?.id ?? '')
if (user !== null) { if (user !== null) {
$session.set({ $session.set({
@ -39,7 +39,7 @@ export function setCurrentUser(user: User | null): void {
export function logout(): void { export function logout(): void {
$currentUser.set(null) $currentUser.set(null)
$userIdRaw.set("") $userIdRaw.set('')
$session.set(null) $session.set(null)
} }

View File

@ -2,11 +2,11 @@
// © AngelaMos | 2025 // © AngelaMos | 2025
// index.ts // index.ts
// =================== // ===================
export * from "./auth.store" export * from './auth.store'
export * from "./session.store" export * from './messages.store'
export * from "./settings.store" export * from './presence.store'
export * from "./ui.store" export * from './rooms.store'
export * from "./rooms.store" export * from './session.store'
export * from "./messages.store" export * from './settings.store'
export * from "./presence.store" export * from './typing.store'
export * from "./typing.store" export * from './ui.store'

View File

@ -3,9 +3,9 @@
* Manages message cache per room * Manages message cache per room
*/ */
import { map, computed } from "nanostores" import { computed, map } from 'nanostores'
import type { Message, MessageStatus } from "../types" import type { Message, MessageStatus } from '../types'
import { $activeRoomId } from "./rooms.store" import { $activeRoomId } from './rooms.store'
export const $messagesByRoom = map<Record<string, Message[]>>({}) export const $messagesByRoom = map<Record<string, Message[]>>({})
@ -17,12 +17,12 @@ export const $oldestMessageIdByRoom = map<Record<string, string | undefined>>({}
export const $activeRoomMessages = computed( export const $activeRoomMessages = computed(
[$messagesByRoom, $activeRoomId], [$messagesByRoom, $activeRoomId],
(messages, roomId) => (roomId ? messages[roomId] ?? [] : []) (messages, roomId) => (roomId ? (messages[roomId] ?? []) : [])
) )
export const $activeRoomPendingMessages = computed( export const $activeRoomPendingMessages = computed(
[$pendingMessages, $activeRoomId], [$pendingMessages, $activeRoomId],
(pending, roomId) => (roomId ? pending[roomId] ?? [] : []) (pending, roomId) => (roomId ? (pending[roomId] ?? []) : [])
) )
export function addMessage(roomId: string, message: Message): void { export function addMessage(roomId: string, message: Message): void {
@ -142,7 +142,10 @@ export function clearAllMessages(): void {
$oldestMessageIdByRoom.set({}) $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] const messages = $messagesByRoom.get()[roomId]
return messages?.find((m) => m.id === messageId) ?? null return messages?.find((m) => m.id === messageId) ?? null
} }

View File

@ -3,8 +3,8 @@
* Manages user online/offline status * Manages user online/offline status
*/ */
import { map } from "nanostores" import { map } from 'nanostores'
import type { PresenceStatus, UserPresence } from "../types" import type { PresenceStatus, UserPresence } from '../types'
export const $presenceByUser = map<Record<string, UserPresence>>({}) export const $presenceByUser = map<Record<string, UserPresence>>({})
@ -38,18 +38,18 @@ export function getUserPresence(userId: string): UserPresence | null {
} }
export function getUserStatus(userId: string): PresenceStatus { export function getUserStatus(userId: string): PresenceStatus {
return $presenceByUser.get()[userId]?.status ?? "offline" return $presenceByUser.get()[userId]?.status ?? 'offline'
} }
export function isUserOnline(userId: string): boolean { export function isUserOnline(userId: string): boolean {
const status = getUserStatus(userId) const status = getUserStatus(userId)
return status === "online" return status === 'online'
} }
export function getOnlineUserIds(): string[] { export function getOnlineUserIds(): string[] {
const presences = $presenceByUser.get() const presences = $presenceByUser.get()
return Object.entries(presences) return Object.entries(presences)
.filter(([_, p]) => p.status === "online") .filter(([_, p]) => p.status === 'online')
.map(([id]) => id) .map(([id]) => id)
} }

View File

@ -3,16 +3,15 @@
* Manages chat rooms and active conversation state * Manages chat rooms and active conversation state
*/ */
import { atom, computed, map } from "nanostores" import { atom, computed, map } from 'nanostores'
import type { Room } from "../types" import type { Room } from '../types'
export const $rooms = map<Record<string, Room>>({}) export const $rooms = map<Record<string, Room>>({})
export const $activeRoomId = atom<string | null>(null) export const $activeRoomId = atom<string | null>(null)
export const $activeRoom = computed( export const $activeRoom = computed([$rooms, $activeRoomId], (rooms, activeId) =>
[$rooms, $activeRoomId], activeId ? (rooms[activeId] ?? null) : null
(rooms, activeId) => (activeId ? rooms[activeId] ?? null : null)
) )
export const $roomList = computed($rooms, (rooms) => export const $roomList = computed($rooms, (rooms) =>

View File

@ -3,7 +3,7 @@
* Stores authentication tokens in localStorage * Stores authentication tokens in localStorage
*/ */
import { persistentMap, persistentAtom } from "@nanostores/persistent" import { persistentAtom, persistentMap } from '@nanostores/persistent'
interface SessionTokens { interface SessionTokens {
accessToken: string accessToken: string
@ -12,13 +12,13 @@ interface SessionTokens {
} }
const DEFAULT_TOKENS: SessionTokens = { const DEFAULT_TOKENS: SessionTokens = {
accessToken: "", accessToken: '',
refreshToken: "", refreshToken: '',
expiresAt: 0, expiresAt: 0,
} }
export const $sessionTokens = persistentMap<SessionTokens>( export const $sessionTokens = persistentMap<SessionTokens>(
"chat:session:", 'chat:session:',
DEFAULT_TOKENS, DEFAULT_TOKENS,
{ {
encode: JSON.stringify, encode: JSON.stringify,
@ -26,14 +26,10 @@ export const $sessionTokens = persistentMap<SessionTokens>(
} }
) )
export const $lastActivity = persistentAtom<string>( export const $lastActivity = persistentAtom<string>('chat:last_activity', '', {
"chat:last_activity", encode: String,
"", decode: String,
{ })
encode: String,
decode: String,
}
)
export function setSessionTokens( export function setSessionTokens(
accessToken: string, accessToken: string,
@ -55,7 +51,7 @@ export function clearSessionTokens(): void {
export function isSessionValid(): boolean { export function isSessionValid(): boolean {
const tokens = $sessionTokens.get() const tokens = $sessionTokens.get()
if (tokens.accessToken === "") { if (tokens.accessToken === '') {
return false return false
} }
return tokens.expiresAt > Date.now() return tokens.expiresAt > Date.now()
@ -70,7 +66,7 @@ export function getAccessToken(): string | null {
export function getRefreshToken(): string | null { export function getRefreshToken(): string | null {
const tokens = $sessionTokens.get() const tokens = $sessionTokens.get()
return tokens.refreshToken !== "" ? tokens.refreshToken : null return tokens.refreshToken !== '' ? tokens.refreshToken : null
} }
export function updateLastActivity(): void { export function updateLastActivity(): void {
@ -79,5 +75,5 @@ export function updateLastActivity(): void {
export function getLastActivity(): Date | null { export function getLastActivity(): Date | null {
const activity = $lastActivity.get() const activity = $lastActivity.get()
return activity !== "" ? new Date(activity) : null return activity !== '' ? new Date(activity) : null
} }

View File

@ -3,12 +3,12 @@
* Stores user preferences in localStorage * Stores user preferences in localStorage
*/ */
import { persistentMap } from "@nanostores/persistent" import { persistentMap } from '@nanostores/persistent'
import { computed } from "nanostores" import { computed } from 'nanostores'
type Theme = "dark" | "light" type Theme = 'dark' | 'light'
type FontSize = "small" | "medium" | "large" type FontSize = 'small' | 'medium' | 'large'
type NotificationSound = "retro" | "subtle" | "none" type NotificationSound = 'retro' | 'subtle' | 'none'
interface UserSettings { interface UserSettings {
theme: Theme theme: Theme
@ -24,10 +24,10 @@ interface UserSettings {
} }
const DEFAULT_SETTINGS: UserSettings = { const DEFAULT_SETTINGS: UserSettings = {
theme: "dark", theme: 'dark',
fontSize: "medium", fontSize: 'medium',
soundEnabled: true, soundEnabled: true,
notificationSound: "retro", notificationSound: 'retro',
desktopNotifications: true, desktopNotifications: true,
showTypingIndicators: true, showTypingIndicators: true,
showReadReceipts: true, showReadReceipts: true,
@ -37,7 +37,7 @@ const DEFAULT_SETTINGS: UserSettings = {
} }
export const $settings = persistentMap<UserSettings>( export const $settings = persistentMap<UserSettings>(
"chat:settings:", 'chat:settings:',
DEFAULT_SETTINGS, DEFAULT_SETTINGS,
{ {
encode: JSON.stringify, encode: JSON.stringify,
@ -50,43 +50,43 @@ export const $theme = computed($settings, (settings) => settings.theme)
export const $fontSize = computed($settings, (settings) => settings.fontSize) export const $fontSize = computed($settings, (settings) => settings.fontSize)
export function setTheme(theme: Theme): void { export function setTheme(theme: Theme): void {
$settings.setKey("theme", theme) $settings.setKey('theme', theme)
} }
export function setFontSize(fontSize: FontSize): void { export function setFontSize(fontSize: FontSize): void {
$settings.setKey("fontSize", fontSize) $settings.setKey('fontSize', fontSize)
} }
export function setSoundEnabled(enabled: boolean): void { export function setSoundEnabled(enabled: boolean): void {
$settings.setKey("soundEnabled", enabled) $settings.setKey('soundEnabled', enabled)
} }
export function setNotificationSound(sound: NotificationSound): void { export function setNotificationSound(sound: NotificationSound): void {
$settings.setKey("notificationSound", sound) $settings.setKey('notificationSound', sound)
} }
export function setDesktopNotifications(enabled: boolean): void { export function setDesktopNotifications(enabled: boolean): void {
$settings.setKey("desktopNotifications", enabled) $settings.setKey('desktopNotifications', enabled)
} }
export function setShowTypingIndicators(show: boolean): void { export function setShowTypingIndicators(show: boolean): void {
$settings.setKey("showTypingIndicators", show) $settings.setKey('showTypingIndicators', show)
} }
export function setShowReadReceipts(show: boolean): void { export function setShowReadReceipts(show: boolean): void {
$settings.setKey("showReadReceipts", show) $settings.setKey('showReadReceipts', show)
} }
export function setShowOnlineStatus(show: boolean): void { export function setShowOnlineStatus(show: boolean): void {
$settings.setKey("showOnlineStatus", show) $settings.setKey('showOnlineStatus', show)
} }
export function setEnterToSend(enabled: boolean): void { export function setEnterToSend(enabled: boolean): void {
$settings.setKey("enterToSend", enabled) $settings.setKey('enterToSend', enabled)
} }
export function setCompactMode(enabled: boolean): void { export function setCompactMode(enabled: boolean): void {
$settings.setKey("compactMode", enabled) $settings.setKey('compactMode', enabled)
} }
export function resetSettings(): void { export function resetSettings(): void {

View File

@ -3,9 +3,9 @@
* Manages typing status per room * Manages typing status per room
*/ */
import { map, computed } from "nanostores" import { computed, map } from 'nanostores'
import type { TypingState } from "../types" import type { TypingState } from '../types'
import { $activeRoomId } from "./rooms.store" import { $activeRoomId } from './rooms.store'
const TYPING_TIMEOUT_MS = 5000 const TYPING_TIMEOUT_MS = 5000
@ -15,7 +15,7 @@ const typingTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
export const $activeRoomTyping = computed( export const $activeRoomTyping = computed(
[$typingByRoom, $activeRoomId], [$typingByRoom, $activeRoomId],
(typing, roomId) => (roomId ? typing[roomId] ?? [] : []) (typing, roomId) => (roomId ? (typing[roomId] ?? []) : [])
) )
export const $activeRoomTypingUsernames = computed( export const $activeRoomTypingUsernames = computed(

View File

@ -3,20 +3,20 @@
* Manages sidebar, modals, and other UI state * Manages sidebar, modals, and other UI state
*/ */
import { atom, computed } from "nanostores" import { atom, computed } from 'nanostores'
type ModalType = type ModalType =
| "new-conversation" | 'new-conversation'
| "user-search" | 'user-search'
| "settings" | 'settings'
| "profile" | 'profile'
| "encryption-info" | 'encryption-info'
| "confirm-logout" | 'confirm-logout'
| null | null
interface ToastNotification { interface ToastNotification {
id: string id: string
variant: "info" | "success" | "warning" | "error" variant: 'info' | 'success' | 'warning' | 'error'
title: string title: string
description?: string description?: string
duration?: number duration?: number
@ -34,16 +34,13 @@ export const $toasts = atom<ToastNotification[]>([])
export const $isLoading = atom<boolean>(false) export const $isLoading = atom<boolean>(false)
export const $loadingMessage = atom<string>("") export const $loadingMessage = atom<string>('')
export const $isMobile = atom<boolean>(false) export const $isMobile = atom<boolean>(false)
export const $searchQuery = atom<string>("") export const $searchQuery = atom<string>('')
export const $hasActiveModal = computed( export const $hasActiveModal = computed($activeModal, (modal) => modal !== null)
$activeModal,
(modal) => modal !== null
)
let toastIdCounter = 0 let toastIdCounter = 0
@ -74,14 +71,15 @@ export function closeModal(): void {
} }
export function showToast( export function showToast(
variant: ToastNotification["variant"], variant: ToastNotification['variant'],
title: string, title: string,
description?: string, description?: string,
duration?: number duration?: number
): string { ): string {
const existingToasts = $toasts.get() const existingToasts = $toasts.get()
const duplicate = existingToasts.find( 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) { if (duplicate !== undefined) {
@ -119,7 +117,7 @@ export function clearAllToasts(): void {
export function setLoading(loading: boolean, message?: string): void { export function setLoading(loading: boolean, message?: string): void {
$isLoading.set(loading) $isLoading.set(loading)
$loadingMessage.set(message ?? "") $loadingMessage.set(message ?? '')
} }
export function setIsMobile(isMobile: boolean): void { export function setIsMobile(isMobile: boolean): void {
@ -131,7 +129,7 @@ export function setSearchQuery(query: string): void {
} }
export function clearSearchQuery(): void { export function clearSearchQuery(): void {
$searchQuery.set("") $searchQuery.set('')
} }
export type { ModalType, ToastNotification } export type { ModalType, ToastNotification }

View File

@ -9,7 +9,7 @@ export class ApiError extends Error {
message?: string message?: string
) { ) {
super(message ?? `API Error: ${status}`) super(message ?? `API Error: ${status}`)
this.name = "ApiError" this.name = 'ApiError'
} }
} }

View File

@ -41,13 +41,15 @@ export interface AuthenticationCompleteRequest {
} }
export interface WebAuthnOptions { export interface WebAuthnOptions {
publicKey: PublicKeyCredentialCreationOptions | PublicKeyCredentialRequestOptions publicKey:
| PublicKeyCredentialCreationOptions
| PublicKeyCredentialRequestOptions
} }
export interface PublicKeyCredentialJSON { export interface PublicKeyCredentialJSON {
id: string id: string
rawId: string rawId: string
type: "public-key" type: 'public-key'
response: { response: {
clientDataJSON: string clientDataJSON: string
attestationObject?: string attestationObject?: string
@ -55,7 +57,7 @@ export interface PublicKeyCredentialJSON {
signature?: string signature?: string
userHandle?: string userHandle?: string
} }
authenticatorAttachment?: "platform" | "cross-platform" authenticatorAttachment?: 'platform' | 'cross-platform'
clientExtensionResults: Record<string, unknown> clientExtensionResults: Record<string, unknown>
} }

View File

@ -2,11 +2,11 @@
// © AngelaMos | 2025 // © AngelaMos | 2025
// chat.ts // 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 { export interface Message {
id: string id: string
@ -41,7 +41,7 @@ export interface Participant {
user_id: string user_id: string
username: string username: string
display_name: string display_name: string
role: "owner" | "admin" | "member" role: 'owner' | 'admin' | 'member'
joined_at: string joined_at: string
} }

View File

@ -2,14 +2,14 @@
// © AngelaMos | 2025 // © AngelaMos | 2025
// components.ts // components.ts
// =================== // ===================
import type { JSX } from "solid-js" import type { JSX } from 'solid-js'
import type { MessageStatus, PresenceStatus } from "./chat" 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 { export interface ButtonProps {
variant?: ButtonVariant variant?: ButtonVariant
@ -19,14 +19,14 @@ export interface ButtonProps {
loading?: boolean loading?: boolean
leftIcon?: JSX.Element leftIcon?: JSX.Element
rightIcon?: JSX.Element rightIcon?: JSX.Element
type?: "button" | "submit" | "reset" type?: 'button' | 'submit' | 'reset'
onClick?: () => void onClick?: () => void
class?: string class?: string
children: JSX.Element children: JSX.Element
} }
export interface InputProps { export interface InputProps {
type?: "text" | "email" | "password" | "search" type?: 'text' | 'email' | 'password' | 'search'
name?: string name?: string
placeholder?: string placeholder?: string
value?: string value?: string
@ -93,7 +93,7 @@ export interface ModalProps {
export interface ToastProps { export interface ToastProps {
id: string id: string
variant: "info" | "success" | "warning" | "error" variant: 'info' | 'success' | 'warning' | 'error'
title: string title: string
description?: string description?: string
duration?: number duration?: number
@ -109,7 +109,7 @@ export interface SpinnerProps {
} }
export interface SkeletonProps { export interface SkeletonProps {
variant?: "text" | "circular" | "rectangular" variant?: 'text' | 'circular' | 'rectangular'
width?: string width?: string
height?: string height?: string
lines?: number lines?: number
@ -118,7 +118,7 @@ export interface SkeletonProps {
export interface TooltipProps { export interface TooltipProps {
content: string content: string
position?: "top" | "bottom" | "left" | "right" position?: 'top' | 'bottom' | 'left' | 'right'
delay?: number delay?: number
children: JSX.Element children: JSX.Element
} }
@ -135,7 +135,7 @@ export interface DropdownProps {
items: DropdownItem[] items: DropdownItem[]
onSelect: (value: string) => void onSelect: (value: string) => void
trigger: JSX.Element trigger: JSX.Element
align?: "left" | "right" align?: 'left' | 'right'
class?: string class?: string
} }
@ -143,7 +143,7 @@ export interface IconButtonProps {
icon: JSX.Element icon: JSX.Element
onClick?: () => void onClick?: () => void
size?: Size size?: Size
variant?: "ghost" | "subtle" variant?: 'ghost' | 'subtle'
ariaLabel: string ariaLabel: string
disabled?: boolean disabled?: boolean
loading?: boolean loading?: boolean

View File

@ -3,40 +3,40 @@
*/ */
import type { import type {
User,
Message,
Room,
MessageStatus,
PresenceStatus,
RoomType,
PreKeyBundle,
ApiErrorResponse, ApiErrorResponse,
ValidationError,
WSMessage,
EncryptedMessageWS, EncryptedMessageWS,
TypingIndicatorWS, ErrorMessageWS,
HeartbeatWS,
Message,
MessageSentWS,
MessageStatus,
PreKeyBundle,
PresenceStatus,
PresenceUpdateWS, PresenceUpdateWS,
ReadReceiptWS, ReadReceiptWS,
HeartbeatWS, Room,
ErrorMessageWS,
RoomCreatedWS, RoomCreatedWS,
MessageSentWS, RoomType,
} from "./index" TypingIndicatorWS,
User,
ValidationError,
WSMessage,
} from './index'
export function isString(value: unknown): value is string { export function isString(value: unknown): value is string {
return typeof value === "string" return typeof value === 'string'
} }
export function isNumber(value: unknown): value is number { 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 { 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> { 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[] { 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 { export function isMessageStatus(value: unknown): value is MessageStatus {
return ( return (
value === "sending" || value === 'sending' ||
value === "sent" || value === 'sent' ||
value === "delivered" || value === 'delivered' ||
value === "read" || value === 'read' ||
value === "failed" value === 'failed'
) )
} }
export function isPresenceStatus(value: unknown): value is PresenceStatus { 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 { 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 { 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) 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) 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) 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 if (!isObject(value)) return false
return ( return (
value.type === "encrypted_message" && value.type === 'encrypted_message' &&
isNonEmptyString(value.message_id) && isNonEmptyString(value.message_id) &&
isNonEmptyString(value.sender_id) && isNonEmptyString(value.sender_id) &&
isNonEmptyString(value.recipient_id) && isNonEmptyString(value.recipient_id) &&
@ -155,7 +159,7 @@ export function isTypingIndicatorWS(value: unknown): value is TypingIndicatorWS
if (!isObject(value)) return false if (!isObject(value)) return false
return ( return (
value.type === "typing" && value.type === 'typing' &&
isNonEmptyString(value.user_id) && isNonEmptyString(value.user_id) &&
isNonEmptyString(value.room_id) && isNonEmptyString(value.room_id) &&
isBoolean(value.is_typing) && isBoolean(value.is_typing) &&
@ -167,7 +171,7 @@ export function isPresenceUpdateWS(value: unknown): value is PresenceUpdateWS {
if (!isObject(value)) return false if (!isObject(value)) return false
return ( return (
value.type === "presence" && value.type === 'presence' &&
isNonEmptyString(value.user_id) && isNonEmptyString(value.user_id) &&
isPresenceStatus(value.status) && isPresenceStatus(value.status) &&
isNonEmptyString(value.last_seen) isNonEmptyString(value.last_seen)
@ -178,7 +182,7 @@ export function isReadReceiptWS(value: unknown): value is ReadReceiptWS {
if (!isObject(value)) return false if (!isObject(value)) return false
return ( return (
value.type === "receipt" && value.type === 'receipt' &&
isNonEmptyString(value.message_id) && isNonEmptyString(value.message_id) &&
isNonEmptyString(value.room_id) && isNonEmptyString(value.room_id) &&
isNonEmptyString(value.user_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 { export function isHeartbeatWS(value: unknown): value is HeartbeatWS {
if (!isObject(value)) return false if (!isObject(value)) return false
return value.type === "heartbeat" return value.type === 'heartbeat'
} }
export function isErrorMessageWS(value: unknown): value is ErrorMessageWS { export function isErrorMessageWS(value: unknown): value is ErrorMessageWS {
if (!isObject(value)) return false if (!isObject(value)) return false
return ( return (
value.type === "error" && value.type === 'error' &&
isNonEmptyString(value.error_code) && isNonEmptyString(value.error_code) &&
isNonEmptyString(value.error_message) isNonEmptyString(value.error_message)
) )
@ -206,7 +210,7 @@ export function isRoomCreatedWS(value: unknown): value is RoomCreatedWS {
if (!isObject(value)) return false if (!isObject(value)) return false
return ( return (
value.type === "room_created" && value.type === 'room_created' &&
isNonEmptyString(value.room_id) && isNonEmptyString(value.room_id) &&
isNonEmptyString(value.room_type) && isNonEmptyString(value.room_type) &&
isArray(value.participants) && isArray(value.participants) &&
@ -220,7 +224,7 @@ export function isMessageSentWS(value: unknown): value is MessageSentWS {
if (!isObject(value)) return false if (!isObject(value)) return false
return ( return (
value.type === "message_sent" && value.type === 'message_sent' &&
isNonEmptyString(value.temp_id) && isNonEmptyString(value.temp_id) &&
isNonEmptyString(value.message_id) && isNonEmptyString(value.message_id) &&
isNonEmptyString(value.room_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 return value !== null && value !== undefined
} }
export function isPublicKeyCredential(value: Credential | null): value is PublicKeyCredential { export function isPublicKeyCredential(
return value !== null && value.type === "public-key" value: Credential | null
): value is PublicKeyCredential {
return value !== null && value.type === 'public-key'
} }

View File

@ -2,10 +2,10 @@
// © AngelaMos | 2025 // © AngelaMos | 2025
// index.ts // index.ts
// =================== // ===================
export * from "./api" export * from './api'
export * from "./auth" export * from './auth'
export * from "./chat" export * from './chat'
export * from "./websocket" export * from './components'
export * from "./encryption" export * from './encryption'
export * from "./components" export * from './guards'
export * from "./guards" export * from './websocket'

View File

@ -2,17 +2,17 @@
// © AngelaMos | 2025 // © AngelaMos | 2025
// websockets.ts // websockets.ts
// =================== // ===================
import type { PresenceStatus } from "./chat" import type { PresenceStatus } from './chat'
export type WSMessageType = export type WSMessageType =
| "encrypted_message" | 'encrypted_message'
| "typing" | 'typing'
| "presence" | 'presence'
| "receipt" | 'receipt'
| "heartbeat" | 'heartbeat'
| "error" | 'error'
| "room_created" | 'room_created'
| "message_sent" | 'message_sent'
export interface BaseWSMessage { export interface BaseWSMessage {
type: WSMessageType type: WSMessageType
@ -20,7 +20,7 @@ export interface BaseWSMessage {
} }
export interface EncryptedMessageWS extends BaseWSMessage { export interface EncryptedMessageWS extends BaseWSMessage {
type: "encrypted_message" type: 'encrypted_message'
message_id: string message_id: string
sender_id: string sender_id: string
recipient_id: string recipient_id: string
@ -33,7 +33,7 @@ export interface EncryptedMessageWS extends BaseWSMessage {
} }
export interface TypingIndicatorWS extends BaseWSMessage { export interface TypingIndicatorWS extends BaseWSMessage {
type: "typing" type: 'typing'
user_id: string user_id: string
room_id: string room_id: string
is_typing: boolean is_typing: boolean
@ -41,14 +41,14 @@ export interface TypingIndicatorWS extends BaseWSMessage {
} }
export interface PresenceUpdateWS extends BaseWSMessage { export interface PresenceUpdateWS extends BaseWSMessage {
type: "presence" type: 'presence'
user_id: string user_id: string
status: PresenceStatus status: PresenceStatus
last_seen: string last_seen: string
} }
export interface ReadReceiptWS extends BaseWSMessage { export interface ReadReceiptWS extends BaseWSMessage {
type: "receipt" type: 'receipt'
message_id: string message_id: string
room_id: string room_id: string
user_id: string user_id: string
@ -56,18 +56,18 @@ export interface ReadReceiptWS extends BaseWSMessage {
} }
export interface HeartbeatWS extends BaseWSMessage { export interface HeartbeatWS extends BaseWSMessage {
type: "heartbeat" type: 'heartbeat'
} }
export interface ErrorMessageWS extends BaseWSMessage { export interface ErrorMessageWS extends BaseWSMessage {
type: "error" type: 'error'
error_code: string error_code: string
error_message: string error_message: string
details?: Record<string, unknown> details?: Record<string, unknown>
} }
export interface RoomCreatedWS extends BaseWSMessage { export interface RoomCreatedWS extends BaseWSMessage {
type: "room_created" type: 'room_created'
room_id: string room_id: string
room_type: string room_type: string
name: string | null name: string | null
@ -84,7 +84,7 @@ export interface RoomCreatedWS extends BaseWSMessage {
} }
export interface MessageSentWS extends BaseWSMessage { export interface MessageSentWS extends BaseWSMessage {
type: "message_sent" type: 'message_sent'
temp_id: string temp_id: string
message_id: string message_id: string
room_id: string room_id: string
@ -103,7 +103,7 @@ export type WSMessage =
| MessageSentWS | MessageSentWS
export interface WSOutgoingEncryptedMessage { export interface WSOutgoingEncryptedMessage {
type: "encrypted_message" type: 'encrypted_message'
recipient_id: string recipient_id: string
room_id: string room_id: string
ciphertext: string ciphertext: string
@ -113,24 +113,24 @@ export interface WSOutgoingEncryptedMessage {
} }
export interface WSOutgoingTyping { export interface WSOutgoingTyping {
type: "typing" type: 'typing'
room_id: string room_id: string
is_typing: boolean is_typing: boolean
} }
export interface WSOutgoingPresence { export interface WSOutgoingPresence {
type: "presence" type: 'presence'
status: PresenceStatus status: PresenceStatus
} }
export interface WSOutgoingReceipt { export interface WSOutgoingReceipt {
type: "receipt" type: 'receipt'
message_id: string message_id: string
room_id: string room_id: string
} }
export interface WSOutgoingHeartbeat { export interface WSOutgoingHeartbeat {
type: "heartbeat" type: 'heartbeat'
timestamp: string timestamp: string
} }

View File

@ -2,5 +2,6 @@
// © AngelaMos | 2025 // © AngelaMos | 2025
// index.ts // index.ts
// =================== // ===================
export * from "./websocket-manager"
export * from "./message-handlers" export * from './message-handlers'
export * from './websocket-manager'

View File

@ -2,47 +2,44 @@
// © AngelaMos | 2025 // © AngelaMos | 2025
// message-handlers.ts // message-handlers.ts
// =================== // ===================
import type {
WSMessage, import { cryptoService, saveDecryptedMessage, updateMessageId } from '../crypto'
EncryptedMessageWS,
TypingIndicatorWS,
PresenceUpdateWS,
ReadReceiptWS,
ErrorMessageWS,
RoomCreatedWS,
MessageSentWS,
Message,
Room,
} from "../types"
import {
isEncryptedMessageWS,
isTypingIndicatorWS,
isPresenceUpdateWS,
isReadReceiptWS,
isErrorMessageWS,
isRoomCreatedWS,
isMessageSentWS,
} from "../types/guards"
import { import {
addMessage, addMessage,
updateMessageStatus,
confirmPendingMessage, 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 { import {
addRoom, isEncryptedMessageWS,
} from "../stores/rooms.store" isErrorMessageWS,
import { isMessageSentWS,
setUserPresence, isPresenceUpdateWS,
} from "../stores/presence.store" isReadReceiptWS,
import { isRoomCreatedWS,
setUserTyping, isTypingIndicatorWS,
} from "../stores/typing.store" } from '../types/guards'
import { showToast } from "../stores/ui.store"
import { cryptoService, saveDecryptedMessage, updateMessageId } from "../crypto"
type MessageHandler<T extends WSMessage> = (message: T) => void 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 let decryptedContent: string
try { try {
@ -53,7 +50,7 @@ async function encryptedMessageHandler(message: EncryptedMessageWS): Promise<voi
message.header message.header
) )
} catch { } catch {
decryptedContent = "[Encrypted message - decryption failed]" decryptedContent = '[Encrypted message - decryption failed]'
} }
const chatMessage: Message = { const chatMessage: Message = {
@ -62,7 +59,7 @@ async function encryptedMessageHandler(message: EncryptedMessageWS): Promise<voi
sender_id: message.sender_id, sender_id: message.sender_id,
sender_username: message.sender_username, sender_username: message.sender_username,
content: decryptedContent, content: decryptedContent,
status: "delivered", status: 'delivered',
is_encrypted: true, is_encrypted: true,
encrypted_content: message.ciphertext, encrypted_content: message.ciphertext,
nonce: message.nonce, nonce: message.nonce,
@ -85,25 +82,21 @@ const typingIndicatorHandler: MessageHandler<TypingIndicatorWS> = (message) => {
} }
const presenceUpdateHandler: MessageHandler<PresenceUpdateWS> = (message) => { const presenceUpdateHandler: MessageHandler<PresenceUpdateWS> = (message) => {
setUserPresence( setUserPresence(message.user_id, message.status, message.last_seen)
message.user_id,
message.status,
message.last_seen
)
} }
const readReceiptHandler: MessageHandler<ReadReceiptWS> = (message) => { 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) => { 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 roomCreatedHandler: MessageHandler<RoomCreatedWS> = (message) => {
const room: Room = { const room: Room = {
id: message.room_id, id: message.room_id,
type: message.room_type as "direct" | "group" | "ephemeral", type: message.room_type as 'direct' | 'group' | 'ephemeral',
name: message.name, name: message.name,
participants: message.participants, participants: message.participants,
unread_count: 0, unread_count: 0,
@ -113,17 +106,17 @@ const roomCreatedHandler: MessageHandler<RoomCreatedWS> = (message) => {
} }
addRoom(room) addRoom(room)
showToast("info", "NEW CHAT", `NEW CONVERSATION STARTED`) showToast('info', 'NEW CHAT', `NEW CONVERSATION STARTED`)
} }
const messageSentHandler: MessageHandler<MessageSentWS> = (message) => { const messageSentHandler: MessageHandler<MessageSentWS> = (message) => {
const confirmedMessage: Message = { const confirmedMessage: Message = {
id: message.message_id, id: message.message_id,
room_id: message.room_id, room_id: message.room_id,
sender_id: "", sender_id: '',
sender_username: "", sender_username: '',
content: "", content: '',
status: "sent", status: 'sent',
is_encrypted: true, is_encrypted: true,
created_at: message.created_at, created_at: message.created_at,
updated_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) await encryptedMessageHandler(message)
} }

View File

@ -2,49 +2,54 @@
// ©AngelaMos | 2025 // ©AngelaMos | 2025
// websocket-manager.ts // websocket-manager.ts
// =================== // ===================
import { atom, computed } from "nanostores" import { atom, computed } from 'nanostores'
import { import { WS_HEARTBEAT_INTERVAL, WS_RECONNECT_DELAY, WS_URL } from '../config'
WS_URL, import { $userId } from '../stores'
WS_HEARTBEAT_INTERVAL,
WS_RECONNECT_DELAY,
} from "../config"
import { $userId } from "../stores"
import type { import type {
WSMessage, WSMessage,
WSOutgoingMessage, WSOutgoingEncryptedMessage,
WSOutgoingHeartbeat, WSOutgoingHeartbeat,
WSOutgoingTyping, WSOutgoingMessage,
WSOutgoingPresence, WSOutgoingPresence,
WSOutgoingReceipt, WSOutgoingReceipt,
WSOutgoingEncryptedMessage, WSOutgoingTyping,
} from "../types" } from '../types'
import { handleWSMessage } from "./message-handlers"
import { import {
isWSMessage,
isEncryptedMessageWS, isEncryptedMessageWS,
isTypingIndicatorWS, isErrorMessageWS,
isHeartbeatWS,
isMessageSentWS,
isPresenceUpdateWS, isPresenceUpdateWS,
isReadReceiptWS, isReadReceiptWS,
isHeartbeatWS,
isErrorMessageWS,
isRoomCreatedWS, isRoomCreatedWS,
isMessageSentWS, isTypingIndicatorWS,
} from "../types/guards" 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 $reconnectAttempts = atom<number>(0)
export const $lastError = atom<string | null>(null) export const $lastError = atom<string | null>(null)
export const $isConnected = computed( export const $isConnected = computed(
$connectionStatus, $connectionStatus,
(status) => status === "connected" (status) => status === 'connected'
) )
const MAX_RECONNECT_ATTEMPTS = 10 const MAX_RECONNECT_ATTEMPTS = 10
const MAX_RECONNECT_DELAY = 30000 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 { class WebSocketManager {
private ws: WebSocket | null = null private ws: WebSocket | null = null
@ -57,7 +62,7 @@ class WebSocketManager {
connect(): void { connect(): void {
const userId = $userId.get() const userId = $userId.get()
if (!userId) { if (!userId) {
$lastError.set("Cannot connect: User not authenticated") $lastError.set('Cannot connect: User not authenticated')
return return
} }
@ -70,7 +75,7 @@ class WebSocketManager {
} }
this.intentionalClose = false this.intentionalClose = false
$connectionStatus.set("connecting") $connectionStatus.set('connecting')
$lastError.set(null) $lastError.set(null)
const wsUrl = `${WS_URL}/ws?user_id=${userId}` const wsUrl = `${WS_URL}/ws?user_id=${userId}`
@ -86,7 +91,7 @@ class WebSocketManager {
this.intentionalClose = true this.intentionalClose = true
this.fatalError = false this.fatalError = false
this.cleanup() this.cleanup()
$connectionStatus.set("disconnected") $connectionStatus.set('disconnected')
$reconnectAttempts.set(0) $reconnectAttempts.set(0)
} }
@ -112,7 +117,7 @@ class WebSocketManager {
tempId: string tempId: string
): boolean { ): boolean {
const message: WSOutgoingEncryptedMessage = { const message: WSOutgoingEncryptedMessage = {
type: "encrypted_message", type: 'encrypted_message',
recipient_id: recipientId, recipient_id: recipientId,
room_id: roomId, room_id: roomId,
ciphertext: encrypted.ciphertext, ciphertext: encrypted.ciphertext,
@ -125,16 +130,16 @@ class WebSocketManager {
sendTypingIndicator(roomId: string, isTyping: boolean): boolean { sendTypingIndicator(roomId: string, isTyping: boolean): boolean {
const message: WSOutgoingTyping = { const message: WSOutgoingTyping = {
type: "typing", type: 'typing',
room_id: roomId, room_id: roomId,
is_typing: isTyping, is_typing: isTyping,
} }
return this.send(message) return this.send(message)
} }
sendPresenceUpdate(status: "online" | "away" | "offline"): boolean { sendPresenceUpdate(status: 'online' | 'away' | 'offline'): boolean {
const message: WSOutgoingPresence = { const message: WSOutgoingPresence = {
type: "presence", type: 'presence',
status, status,
} }
return this.send(message) return this.send(message)
@ -142,7 +147,7 @@ class WebSocketManager {
sendReadReceipt(messageId: string, roomId: string): boolean { sendReadReceipt(messageId: string, roomId: string): boolean {
const message: WSOutgoingReceipt = { const message: WSOutgoingReceipt = {
type: "receipt", type: 'receipt',
message_id: messageId, message_id: messageId,
room_id: roomId, room_id: roomId,
} }
@ -154,12 +159,12 @@ class WebSocketManager {
} }
private handleOpen(): void { private handleOpen(): void {
$connectionStatus.set("connected") $connectionStatus.set('connected')
$reconnectAttempts.set(0) $reconnectAttempts.set(0)
$lastError.set(null) $lastError.set(null)
this.startHeartbeat() this.startHeartbeat()
this.sendPresenceUpdate("online") this.sendPresenceUpdate('online')
this.flushMessageQueue() this.flushMessageQueue()
} }
@ -205,21 +210,23 @@ class WebSocketManager {
this.cleanup() this.cleanup()
if (this.intentionalClose) { if (this.intentionalClose) {
$connectionStatus.set("disconnected") $connectionStatus.set('disconnected')
return return
} }
if (this.fatalError) { if (this.fatalError) {
$connectionStatus.set("disconnected") $connectionStatus.set('disconnected')
return return
} }
const attempts = $reconnectAttempts.get() const attempts = $reconnectAttempts.get()
if (attempts >= MAX_RECONNECT_ATTEMPTS) { if (attempts >= MAX_RECONNECT_ATTEMPTS) {
$connectionStatus.set("disconnected") $connectionStatus.set('disconnected')
const reason = event.reason !== "" ? event.reason : "Unknown reason" const reason = event.reason !== '' ? event.reason : 'Unknown reason'
$lastError.set(`Connection failed after maximum retry attempts (code: ${event.code}, reason: ${reason})`) $lastError.set(
`Connection failed after maximum retry attempts (code: ${event.code}, reason: ${reason})`
)
return return
} }
@ -227,11 +234,11 @@ class WebSocketManager {
$lastError.set(`Connection closed unexpectedly (code: ${event.code})`) $lastError.set(`Connection closed unexpectedly (code: ${event.code})`)
} }
$connectionStatus.set("reconnecting") $connectionStatus.set('reconnecting')
$reconnectAttempts.set(attempts + 1) $reconnectAttempts.set(attempts + 1)
const delay = Math.min( const delay = Math.min(
WS_RECONNECT_DELAY * Math.pow(2, attempts), WS_RECONNECT_DELAY * 2 ** attempts,
MAX_RECONNECT_DELAY MAX_RECONNECT_DELAY
) )
@ -241,7 +248,7 @@ class WebSocketManager {
} }
private handleError(_event: Event): void { private handleError(_event: Event): void {
$lastError.set("WebSocket connection error") $lastError.set('WebSocket connection error')
} }
private startHeartbeat(): void { private startHeartbeat(): void {
@ -250,7 +257,7 @@ class WebSocketManager {
this.heartbeatInterval = setInterval(() => { this.heartbeatInterval = setInterval(() => {
if (this.ws?.readyState === WebSocket.OPEN) { if (this.ws?.readyState === WebSocket.OPEN) {
const heartbeat: WSOutgoingHeartbeat = { const heartbeat: WSOutgoingHeartbeat = {
type: "heartbeat", type: 'heartbeat',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
} }
this.ws.send(JSON.stringify(heartbeat)) this.ws.send(JSON.stringify(heartbeat))
@ -266,7 +273,10 @@ class WebSocketManager {
} }
private flushMessageQueue(): void { 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() const message = this.messageQueue.shift()
if (message !== undefined) { if (message !== undefined) {
try { try {
@ -293,7 +303,10 @@ class WebSocketManager {
this.ws.onclose = null this.ws.onclose = null
this.ws.onerror = 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.close()
} }
this.ws = null this.ws = null

View File

@ -2,45 +2,46 @@
// © AngelaMos | 2025 // © AngelaMos | 2025
// vite.config.ts // vite.config.ts
// =================== // ===================
import { defineConfig } from "vite";
import solid from "vite-plugin-solid"; import path from 'node:path'
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from '@tailwindcss/vite'
import path from "path"; import { defineConfig } from 'vite'
import solid from 'vite-plugin-solid'
export default defineConfig({ export default defineConfig({
plugins: [solid(), tailwindcss()], plugins: [solid(), tailwindcss()],
envDir: "../", envDir: '../',
resolve: { resolve: {
alias: { alias: {
"~": path.resolve(__dirname, "./src"), '~': path.resolve(__dirname, './src'),
}, },
}, },
server: { server: {
port: 5173, port: 5173,
host: true, host: true,
proxy: { proxy: {
"/api": { '/api': {
target: "http://backend:8000", target: 'http://backend:8000',
changeOrigin: true, changeOrigin: true,
}, },
"/ws": { '/ws': {
target: "ws://backend:8000", target: 'ws://backend:8000',
ws: true, ws: true,
}, },
}, },
}, },
build: { build: {
target: "esnext", target: 'esnext',
outDir: "dist", outDir: 'dist',
sourcemap: true, sourcemap: true,
rollupOptions: { rollupOptions: {
output: { output: {
manualChunks: { manualChunks: {
"solid-js": ["solid-js"], 'solid-js': ['solid-js'],
"solid-router": ["@solidjs/router"], 'solid-router': ['@solidjs/router'],
"solid-query": ["@tanstack/solid-query"], 'solid-query': ['@tanstack/solid-query'],
}, },
}, },
}, },
}, },
}); })

View File

@ -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": { "vcs": {
"enabled": true, "enabled": true,
"clientKind": "git", "clientKind": "git",

View File

@ -25,7 +25,7 @@
"zustand": "^5.0.11" "zustand": "^5.0.11"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.3.15", "@biomejs/biome": "^2.4.2",
"@types/node": "^25.2.3", "@types/node": "^25.2.3",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",

View File

@ -37,8 +37,8 @@ importers:
version: 5.0.11(@types/react@19.2.14)(react@19.2.4) version: 5.0.11(@types/react@19.2.14)(react@19.2.4)
devDependencies: devDependencies:
'@biomejs/biome': '@biomejs/biome':
specifier: ^2.3.15 specifier: ^2.4.2
version: 2.3.15 version: 2.4.2
'@types/node': '@types/node':
specifier: ^25.2.3 specifier: ^25.2.3
version: 25.2.3 version: 25.2.3
@ -162,59 +162,59 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@biomejs/biome@2.3.15': '@biomejs/biome@2.4.2':
resolution: {integrity: sha512-u+jlPBAU2B45LDkjjNNYpc1PvqrM/co4loNommS9/sl9oSxsAQKsNZejYuUztvToB5oXi1tN/e62iNd6ESiY3g==} resolution: {integrity: sha512-vVE/FqLxNLbvYnFDYg3Xfrh1UdFhmPT5i+yPT9GE2nTUgI4rkqo5krw5wK19YHBd7aE7J6r91RRmb8RWwkjy6w==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
hasBin: true hasBin: true
'@biomejs/cli-darwin-arm64@2.3.15': '@biomejs/cli-darwin-arm64@2.4.2':
resolution: {integrity: sha512-SDCdrJ4COim1r8SNHg19oqT50JfkI/xGZHSyC6mGzMfKrpNe/217Eq6y98XhNTc0vGWDjznSDNXdUc6Kg24jbw==} resolution: {integrity: sha512-3pEcKCP/1POKyaZZhXcxFl3+d9njmeAihZ17k8lL/1vk+6e0Cbf0yPzKItFiT+5Yh6TQA4uKvnlqe0oVZwRxCA==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@biomejs/cli-darwin-x64@2.3.15': '@biomejs/cli-darwin-x64@2.4.2':
resolution: {integrity: sha512-RkyeSosBtn3C3Un8zQnl9upX0Qbq4E3QmBa0qjpOh1MebRbHhNlRC16jk8HdTe/9ym5zlfnpbb8cKXzW+vlTxw==} resolution: {integrity: sha512-P7hK1jLVny+0R9UwyGcECxO6sjETxfPyBm/1dmFjnDOHgdDPjPqozByunrwh4xPKld8sxOr5eAsSqal5uKgeBg==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@biomejs/cli-linux-arm64-musl@2.3.15': '@biomejs/cli-linux-arm64-musl@2.4.2':
resolution: {integrity: sha512-SSSIj2yMkFdSkXqASzIBdjySBXOe65RJlhKEDlri7MN19RC4cpez+C0kEwPrhXOTgJbwQR9QH1F4+VnHkC35pg==} resolution: {integrity: sha512-/x04YK9+7erw6tYEcJv9WXoBHcULI/wMOvNdAyE9S3JStZZ9yJyV67sWAI+90UHuDo/BDhq0d96LDqGlSVv7WA==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl] libc: [musl]
'@biomejs/cli-linux-arm64@2.3.15': '@biomejs/cli-linux-arm64@2.4.2':
resolution: {integrity: sha512-FN83KxrdVWANOn5tDmW6UBC0grojchbGmcEz6JkRs2YY6DY63sTZhwkQ56x6YtKhDVV1Unz7FJexy8o7KwuIhg==} resolution: {integrity: sha512-DI3Mi7GT2zYNgUTDEbSjl3e1KhoP76OjQdm8JpvZYZWtVDRyLd3w8llSr2TWk1z+U3P44kUBWY3X7H9MD1/DGQ==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@biomejs/cli-linux-x64-musl@2.3.15': '@biomejs/cli-linux-x64-musl@2.4.2':
resolution: {integrity: sha512-dbjPzTh+ijmmNwojFYbQNMFp332019ZDioBYAMMJj5Ux9d8MkM+u+J68SBJGVwVeSHMYj+T9504CoxEzQxrdNw==} resolution: {integrity: sha512-wbBmTkeAoAYbOQ33f6sfKG7pcRSydQiF+dTYOBjJsnXO2mWEOQHllKlC2YVnedqZFERp2WZhFUoO7TNRwnwEHQ==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl] libc: [musl]
'@biomejs/cli-linux-x64@2.3.15': '@biomejs/cli-linux-x64@2.4.2':
resolution: {integrity: sha512-T8n9p8aiIKOrAD7SwC7opiBM1LYGrE5G3OQRXWgbeo/merBk8m+uxJ1nOXMPzfYyFLfPlKF92QS06KN1UW+Zbg==} resolution: {integrity: sha512-GK2ErnrKpWFigYP68cXiCHK4RTL4IUWhK92AFS3U28X/nuAL5+hTuy6hyobc8JZRSt+upXt1nXChK+tuHHx4mA==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@biomejs/cli-win32-arm64@2.3.15': '@biomejs/cli-win32-arm64@2.4.2':
resolution: {integrity: sha512-puMuenu/2brQdgqtQ7geNwQlNVxiABKEZJhMRX6AGWcmrMO8EObMXniFQywy2b81qmC+q+SDvlOpspNwz0WiOA==} resolution: {integrity: sha512-k2uqwLYrNNxnaoiW3RJxoMGnbKda8FuCmtYG3cOtVljs3CzWxaTR+AoXwKGHscC9thax9R4kOrtWqWN0+KdPTw==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
'@biomejs/cli-win32-x64@2.3.15': '@biomejs/cli-win32-x64@2.4.2':
resolution: {integrity: sha512-kDZr/hgg+igo5Emi0LcjlgfkoGZtgIpJKhnvKTRmMBv6FF/3SDyEV4khBwqNebZIyMZTzvpca9sQNSXJ39pI2A==} resolution: {integrity: sha512-9ma7C4g8Sq3cBlRJD2yrsHXB1mnnEBdpy7PhvFrylQWQb4PoyCmPucdX7frvsSBQuFtIiKCrolPl/8tCZrKvgQ==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
@ -1423,39 +1423,39 @@ snapshots:
'@babel/helper-string-parser': 7.27.1 '@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5 '@babel/helper-validator-identifier': 7.28.5
'@biomejs/biome@2.3.15': '@biomejs/biome@2.4.2':
optionalDependencies: optionalDependencies:
'@biomejs/cli-darwin-arm64': 2.3.15 '@biomejs/cli-darwin-arm64': 2.4.2
'@biomejs/cli-darwin-x64': 2.3.15 '@biomejs/cli-darwin-x64': 2.4.2
'@biomejs/cli-linux-arm64': 2.3.15 '@biomejs/cli-linux-arm64': 2.4.2
'@biomejs/cli-linux-arm64-musl': 2.3.15 '@biomejs/cli-linux-arm64-musl': 2.4.2
'@biomejs/cli-linux-x64': 2.3.15 '@biomejs/cli-linux-x64': 2.4.2
'@biomejs/cli-linux-x64-musl': 2.3.15 '@biomejs/cli-linux-x64-musl': 2.4.2
'@biomejs/cli-win32-arm64': 2.3.15 '@biomejs/cli-win32-arm64': 2.4.2
'@biomejs/cli-win32-x64': 2.3.15 '@biomejs/cli-win32-x64': 2.4.2
'@biomejs/cli-darwin-arm64@2.3.15': '@biomejs/cli-darwin-arm64@2.4.2':
optional: true optional: true
'@biomejs/cli-darwin-x64@2.3.15': '@biomejs/cli-darwin-x64@2.4.2':
optional: true optional: true
'@biomejs/cli-linux-arm64-musl@2.3.15': '@biomejs/cli-linux-arm64-musl@2.4.2':
optional: true optional: true
'@biomejs/cli-linux-arm64@2.3.15': '@biomejs/cli-linux-arm64@2.4.2':
optional: true optional: true
'@biomejs/cli-linux-x64-musl@2.3.15': '@biomejs/cli-linux-x64-musl@2.4.2':
optional: true optional: true
'@biomejs/cli-linux-x64@2.3.15': '@biomejs/cli-linux-x64@2.4.2':
optional: true optional: true
'@biomejs/cli-win32-arm64@2.3.15': '@biomejs/cli-win32-arm64@2.4.2':
optional: true optional: true
'@biomejs/cli-win32-x64@2.3.15': '@biomejs/cli-win32-x64@2.4.2':
optional: true optional: true
'@cacheable/memory@2.0.7': '@cacheable/memory@2.0.7':

View File

@ -469,3 +469,19 @@ def main() -> None:
if __name__ == "__main__": if __name__ == "__main__":
main() main()
"""
"""

View File

@ -1,8 +1,5 @@
{ {
"extends": [ "extends": ["stylelint-config-standard-scss", "stylelint-config-prettier-scss"],
"stylelint-config-standard-scss",
"stylelint-config-prettier-scss"
],
"rules": { "rules": {
"scss/at-rule-no-unknown": null, "scss/at-rule-no-unknown": null,
"selector-class-pattern": null "selector-class-pattern": null

View File

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

View File

@ -2,14 +2,14 @@
// © AngelaMos | 2025 // © AngelaMos | 2025
// eslint.config.js // eslint.config.js
// =================== // ===================
import js from '@eslint/js'; import js from '@eslint/js'
import tseslint from 'typescript-eslint'; import prettierConfig from 'eslint-config-prettier'
import react from 'eslint-plugin-react'; import jsxA11y from 'eslint-plugin-jsx-a11y'
import reactHooks from 'eslint-plugin-react-hooks'; import react from 'eslint-plugin-react'
import reactRefresh from 'eslint-plugin-react-refresh'; import reactHooks from 'eslint-plugin-react-hooks'
import jsxA11y from 'eslint-plugin-jsx-a11y'; import reactRefresh from 'eslint-plugin-react-refresh'
import prettierConfig from 'eslint-config-prettier'; import globals from 'globals'
import globals from 'globals'; import tseslint from 'typescript-eslint'
export default tseslint.config( export default tseslint.config(
{ {
@ -47,15 +47,28 @@ export default tseslint.config(
}, },
}, },
rules: { rules: {
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }], '@typescript-eslint/no-unused-vars': [
'@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports', fixStyle: 'inline-type-imports' }], 'error',
'@typescript-eslint/explicit-function-return-type': ['error', { {
allowExpressions: true, argsIgnorePattern: '^_',
allowTypedFunctionExpressions: true, varsIgnorePattern: '^_',
allowHigherOrderFunctions: true, caughtErrorsIgnorePattern: '^_',
allowDirectConstAssertionInArrowFunctions: true, },
allowedNames: ['Component'] ],
}], '@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/naming-convention': 'off',
'@typescript-eslint/no-non-null-assertion': 'error', '@typescript-eslint/no-non-null-assertion': 'error',
'@typescript-eslint/array-type': ['error', { default: 'array' }], '@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-confusing-void-expression': 'off',
'@typescript-eslint/no-unnecessary-condition': 'off', '@typescript-eslint/no-unnecessary-condition': 'off',
'@typescript-eslint/no-floating-promises': 'error', '@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/strict-boolean-expressions': ['error', { '@typescript-eslint/strict-boolean-expressions': [
allowString: false, 'error',
allowNumber: false, {
allowNullableObject: false, allowString: false,
allowNullableString: true, allowNumber: false,
allowAny: true allowNullableObject: false,
}], allowNullableString: true,
allowAny: true,
},
],
'@typescript-eslint/prefer-as-const': 'error', '@typescript-eslint/prefer-as-const': 'error',
'@typescript-eslint/consistent-type-definitions': ['error', 'interface'], '@typescript-eslint/consistent-type-definitions': ['error', 'interface'],
@ -78,7 +94,14 @@ export default tseslint.config(
'react/jsx-uses-react': 'off', 'react/jsx-uses-react': 'off',
'react/react-in-jsx-scope': 'off', 'react/react-in-jsx-scope': 'off',
'react/jsx-no-leaked-render': ['error', { validStrategies: ['ternary'] }], '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-no-useless-fragment': ['error', { allowExpressions: false }],
'react/jsx-pascal-case': ['error', { allowAllCaps: false }], 'react/jsx-pascal-case': ['error', { allowAllCaps: false }],
'react/no-array-index-key': 'warn', 'react/no-array-index-key': 'warn',
@ -106,8 +129,11 @@ export default tseslint.config(
'object-shorthand': 'error', 'object-shorthand': 'error',
'no-nested-ternary': 'error', 'no-nested-ternary': 'error',
'max-depth': ['error', 6], 'max-depth': ['error', 6],
'max-lines': ['error', { max: 2000, skipBlankLines: true, skipComments: true }], 'max-lines': [
'complexity': ['error', 55], '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

View File

@ -4,62 +4,66 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "vite build",
"dev": "vite", "dev": "vite",
"format": "prettier --write \"**/*.{ts,tsx,scss}\"", "build": "tsc -b && vite build",
"format:check": "prettier --check \"**/*.{ts,tsx,scss}\"", "preview": "vite preview",
"lint": "npm run lint:eslint && npm run lint:scss && npm run lint:types", "lint": "biome check .",
"lint:fix": "biome check --write .",
"lint:eslint": "eslint . --ext .ts,.tsx --max-warnings 0", "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": "stylelint \"src/**/*.css\" --max-warnings 0",
"lint:scss:fix": "stylelint \"src/**/*.css\" --fix", "lint:scss:fix": "stylelint \"src/**/*.css\" --fix"
"lint:types": "tsc --project tsconfig.app.json --noEmit"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.2.1", "@hookform/resolvers": "^5.2.2",
"@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15", "@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.19", "@tanstack/react-query": "^5.90.21",
"@tanstack/react-query-devtools": "^5.91.2", "@tanstack/react-query-devtools": "^5.91.3",
"axios": "^1.12.2", "axios": "^1.13.5",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"immer": "^11.1.3", "immer": "^11.1.4",
"react": "^19.2.3", "react": "^19.2.4",
"react-dom": "^19.2.3", "react-dom": "^19.2.4",
"react-hook-form": "^7.71.1", "react-hook-form": "^7.71.1",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router-dom": "^7.12.0", "react-router-dom": "^7.13.0",
"recharts": "^3.7.0", "recharts": "^3.7.0",
"socket.io-client": "^4.8.3", "socket.io-client": "^4.8.3",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"zod": "^4.3.6", "zod": "^4.3.6",
"zustand": "^5.0.10" "zustand": "^5.0.11"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.4.2",
"@eslint/js": "^9.39.2", "@eslint/js": "^9.39.2",
"@types/react": "^19.2.9", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2", "@vitejs/plugin-react": "^5.1.4",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26", "eslint-plugin-react-refresh": "^0.4.26",
"globals": "^17.1.0", "globals": "^17.3.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"stylelint": "^17.0.0", "stylelint": "^17.3.0",
"stylelint-config-prettier-scss": "^1.0.0", "stylelint-config-prettier-scss": "^1.0.0",
"stylelint-config-standard-scss": "^17.0.0", "stylelint-config-standard-scss": "^17.0.0",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.53.1", "typescript-eslint": "^8.56.0",
"vite": "^7.3.1", "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

View File

@ -3,41 +3,34 @@
* Main application component * Main application component
*/ */
import { useEffect } from 'react'; import { QueryClientProvider } from '@tanstack/react-query'
import { QueryClientProvider } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { useEffect } from 'react'
import { RouterProvider } from 'react-router-dom'; import { RouterProvider } from 'react-router-dom'
import { Toaster } from 'sonner'; import { Toaster } from 'sonner'
import { queryClient } from '@/lib/queryClient'; import { queryClient } from '@/lib/queryClient'
import { router } from '@/router'; import { router } from '@/router'
import { useAuthStore } from '@/store/authStore'; import { useAuthStore } from '@/store/authStore'
const AuthInitializer = (): null => { const AuthInitializer = (): null => {
const loadUserFromStorage = useAuthStore( const loadUserFromStorage = useAuthStore((state) => state.loadUserFromStorage)
(state) => state.loadUserFromStorage,
);
useEffect(() => { useEffect(() => {
loadUserFromStorage(); loadUserFromStorage()
}, [loadUserFromStorage]); }, [loadUserFromStorage])
return null; return null
}; }
function App(): React.ReactElement { function App(): React.ReactElement {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<AuthInitializer /> <AuthInitializer />
<RouterProvider router={router} /> <RouterProvider router={router} />
<Toaster <Toaster position="top-right" closeButton duration={4500} theme="dark" />
position="top-right"
closeButton
duration={4500}
theme="dark"
/>
<ReactQueryDevtools initialIsOpen={false} /> <ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider> </QueryClientProvider>
); )
} }
export default App; export default App

View File

@ -3,133 +3,125 @@
// ©AngelaMos | 2025 // ©AngelaMos | 2025
// =========================== // ===========================
import { useState, useEffect } from 'react'; import { isAxiosError } from 'axios'
import { Link } from 'react-router-dom'; import { useEffect, useState } from 'react'
import { isAxiosError } from 'axios'; import { Link } from 'react-router-dom'
import { Button } from '@/components/common/Button'; import { Button } from '@/components/common/Button'
import { Input } from '@/components/common/Input'; import { Input } from '@/components/common/Input'
import { useLogin } from '@/hooks/useAuth'; import { useLogin } from '@/hooks/useAuth'
import { useUIStore } from '@/store/uiStore'; import { loginSchema } from '@/lib/validation'
import { loginSchema } from '@/lib/validation'; import { useUIStore } from '@/store/uiStore'
import './AuthForm.css'; import './AuthForm.css'
export const LoginForm = (): React.ReactElement => { export const LoginForm = (): React.ReactElement => {
const loginFormState = useUIStore((state) => state.loginForm); const loginFormState = useUIStore((state) => state.loginForm)
const setLoginFormField = useUIStore((state) => state.setLoginFormField); const setLoginFormField = useUIStore((state) => state.setLoginFormField)
const clearLoginForm = useUIStore((state) => state.clearLoginForm); const clearLoginForm = useUIStore((state) => state.clearLoginForm)
const clearExpiredData = useUIStore((state) => state.clearExpiredData); const clearExpiredData = useUIStore((state) => state.clearExpiredData)
const [email, setEmail] = useState<string>(''); const [email, setEmail] = useState<string>('')
const [password, setPassword] = useState<string>(''); const [password, setPassword] = useState<string>('')
const [errors, setErrors] = useState<{ const [errors, setErrors] = useState<{
email?: string; email?: string
password?: string; password?: string
}>({}); }>({})
const { mutate: login, isPending, error } = useLogin(); const { mutate: login, isPending, error } = useLogin()
useEffect(() => { useEffect(() => {
clearExpiredData(); clearExpiredData()
setEmail(loginFormState.email); setEmail(loginFormState.email)
setPassword(loginFormState.password); setPassword(loginFormState.password)
}, [clearExpiredData, loginFormState.email, loginFormState.password]); }, [clearExpiredData, loginFormState.email, loginFormState.password])
const handleEmailChange = (value: string): void => { const handleEmailChange = (value: string): void => {
setEmail(value); setEmail(value)
setLoginFormField('email', value); setLoginFormField('email', value)
if (errors.email !== null && errors.email !== undefined) { if (errors.email !== null && errors.email !== undefined) {
validateField('email', value); validateField('email', value)
} }
}; }
const handlePasswordChange = (value: string): void => { const handlePasswordChange = (value: string): void => {
setPassword(value); setPassword(value)
setLoginFormField('password', value); setLoginFormField('password', value)
if (errors.password !== null && errors.password !== undefined) { if (errors.password !== null && errors.password !== undefined) {
validateField('password', value); validateField('password', value)
} }
}; }
const validateField = ( const validateField = (field: 'email' | 'password', value: string): void => {
field: 'email' | 'password',
value: string,
): void => {
const result = loginSchema.safeParse({ const result = loginSchema.safeParse({
email: field === 'email' ? value : email, email: field === 'email' ? value : email,
password: field === 'password' ? value : password, password: field === 'password' ? value : password,
}); })
if (!result.success) { if (!result.success) {
const fieldError = result.error.issues.find( const fieldError = result.error.issues.find((err) => err.path[0] === field)
(err) => err.path[0] === field,
);
if (fieldError !== null && fieldError !== undefined) { if (fieldError !== null && fieldError !== undefined) {
setErrors((prev) => ({ ...prev, [field]: fieldError.message })); setErrors((prev) => ({ ...prev, [field]: fieldError.message }))
} else { } else {
setErrors((prev) => { setErrors((prev) => {
const { [field]: _, ...rest } = prev; const { [field]: _, ...rest } = prev
return rest; return rest
}); })
} }
} else { } else {
setErrors((prev) => { setErrors((prev) => {
const { [field]: _, ...rest } = prev; const { [field]: _, ...rest } = prev
return rest; return rest
}); })
} }
}; }
const handleBlur = (field: 'email' | 'password'): void => { const handleBlur = (field: 'email' | 'password'): void => {
const value = field === 'email' ? email : password; const value = field === 'email' ? email : password
validateField(field, value); validateField(field, value)
}; }
const validateForm = (): boolean => { const validateForm = (): boolean => {
const result = loginSchema.safeParse({ const result = loginSchema.safeParse({
email, email,
password, password,
}); })
if (!result.success) { if (!result.success) {
const newErrors: { email?: string; password?: string } = {}; const newErrors: { email?: string; password?: string } = {}
result.error.issues.forEach((err) => { 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) { if (field !== null && field !== undefined) {
newErrors[field] = err.message; newErrors[field] = err.message
} }
}); })
setErrors(newErrors); setErrors(newErrors)
return false; return false
} }
setErrors({}); setErrors({})
return true; return true
}; }
const handleSubmit = (e: React.FormEvent<HTMLFormElement>): void => { const handleSubmit = (e: React.FormEvent<HTMLFormElement>): void => {
e.preventDefault(); e.preventDefault()
if (!validateForm()) { if (!validateForm()) {
return; return
} }
login( login(
{ email, password }, { email, password },
{ {
onSuccess: () => { onSuccess: () => {
clearLoginForm(); clearLoginForm()
}, },
}, }
); )
}; }
return ( return (
<form <form className="auth-form" onSubmit={handleSubmit}>
className="auth-form"
onSubmit={handleSubmit}
>
<div className="auth-form__header"> <div className="auth-form__header">
<h1 className="auth-form__title">Welcome Back</h1> <h1 className="auth-form__title">Welcome Back</h1>
<p className="auth-form__subtitle">Sign in to your account</p> <p className="auth-form__subtitle">Sign in to your account</p>
@ -162,32 +154,22 @@ export const LoginForm = (): React.ReactElement => {
</div> </div>
{error !== null && error !== undefined && isAxiosError(error) ? ( {error !== null && error !== undefined && isAxiosError(error) ? (
<div <div className="auth-form__error-message" role="alert">
className="auth-form__error-message" {(error.response?.data as { detail?: string } | undefined)?.detail ??
role="alert" 'Login failed. Please try again.'}
>
{(error.response?.data as { detail?: string } | undefined)
?.detail ?? 'Login failed. Please try again.'}
</div> </div>
) : null} ) : null}
<Button <Button type="submit" isLoading={isPending} disabled={isPending}>
type="submit"
isLoading={isPending}
disabled={isPending}
>
Sign In Sign In
</Button> </Button>
<p className="auth-form__link"> <p className="auth-form__link">
Don&apos;t have an account?{' '} Don&apos;t have an account?{' '}
<Link <Link to="/register" className="auth-form__link-text">
to="/register"
className="auth-form__link-text"
>
Sign up Sign up
</Link> </Link>
</p> </p>
</form> </form>
); )
}; }

View File

@ -3,169 +3,157 @@
// ©AngelaMos | 2025 // ©AngelaMos | 2025
// =========================== // ===========================
import { useState, useEffect } from 'react'; import { isAxiosError } from 'axios'
import { Link } from 'react-router-dom'; import { useEffect, useState } from 'react'
import { isAxiosError } from 'axios'; import { Link } from 'react-router-dom'
import { Button } from '@/components/common/Button'; import { Button } from '@/components/common/Button'
import { Input } from '@/components/common/Input'; import { Input } from '@/components/common/Input'
import { useRegister } from '@/hooks/useAuth'; import { useRegister } from '@/hooks/useAuth'
import { useUIStore } from '@/store/uiStore'; import { registerSchema } from '@/lib/validation'
import { registerSchema } from '@/lib/validation'; import { useUIStore } from '@/store/uiStore'
import './AuthForm.css'; import './AuthForm.css'
export const RegisterForm = (): React.ReactElement => { export const RegisterForm = (): React.ReactElement => {
const registerFormState = useUIStore((state) => state.registerForm); const registerFormState = useUIStore((state) => state.registerForm)
const setRegisterFormField = useUIStore( const setRegisterFormField = useUIStore((state) => state.setRegisterFormField)
(state) => state.setRegisterFormField, const clearRegisterForm = useUIStore((state) => state.clearRegisterForm)
); const clearExpiredData = useUIStore((state) => state.clearExpiredData)
const clearRegisterForm = useUIStore((state) => state.clearRegisterForm);
const clearExpiredData = useUIStore((state) => state.clearExpiredData);
const [email, setEmail] = useState<string>(''); const [email, setEmail] = useState<string>('')
const [password, setPassword] = useState<string>(''); const [password, setPassword] = useState<string>('')
const [confirmPassword, setConfirmPassword] = useState<string>(''); const [confirmPassword, setConfirmPassword] = useState<string>('')
const [errors, setErrors] = useState<{ const [errors, setErrors] = useState<{
email?: string; email?: string
password?: string; password?: string
confirmPassword?: string; confirmPassword?: string
}>({}); }>({})
const { mutate: register, isPending, error } = useRegister(); const { mutate: register, isPending, error } = useRegister()
useEffect(() => { useEffect(() => {
clearExpiredData(); clearExpiredData()
setEmail(registerFormState.email); setEmail(registerFormState.email)
setPassword(registerFormState.password); setPassword(registerFormState.password)
setConfirmPassword(registerFormState.confirmPassword); setConfirmPassword(registerFormState.confirmPassword)
}, [ }, [
clearExpiredData, clearExpiredData,
registerFormState.email, registerFormState.email,
registerFormState.password, registerFormState.password,
registerFormState.confirmPassword, registerFormState.confirmPassword,
]); ])
const handleEmailChange = (value: string): void => { const handleEmailChange = (value: string): void => {
setEmail(value); setEmail(value)
setRegisterFormField('email', value); setRegisterFormField('email', value)
if (errors.email !== null && errors.email !== undefined) { if (errors.email !== null && errors.email !== undefined) {
validateField('email', value); validateField('email', value)
} }
}; }
const handlePasswordChange = (value: string): void => { const handlePasswordChange = (value: string): void => {
setPassword(value); setPassword(value)
setRegisterFormField('password', value); setRegisterFormField('password', value)
if (errors.password !== null && errors.password !== undefined) { if (errors.password !== null && errors.password !== undefined) {
validateField('password', value); validateField('password', value)
} }
}; }
const handleConfirmPasswordChange = (value: string): void => { const handleConfirmPasswordChange = (value: string): void => {
setConfirmPassword(value); setConfirmPassword(value)
setRegisterFormField('confirmPassword', value); setRegisterFormField('confirmPassword', value)
if ( if (errors.confirmPassword !== null && errors.confirmPassword !== undefined) {
errors.confirmPassword !== null && validateField('confirmPassword', value)
errors.confirmPassword !== undefined
) {
validateField('confirmPassword', value);
} }
}; }
const validateField = ( const validateField = (
field: 'email' | 'password' | 'confirmPassword', field: 'email' | 'password' | 'confirmPassword',
value: string, value: string
): void => { ): void => {
const result = registerSchema.safeParse({ const result = registerSchema.safeParse({
email: field === 'email' ? value : email, email: field === 'email' ? value : email,
password: field === 'password' ? value : password, password: field === 'password' ? value : password,
confirmPassword: field === 'confirmPassword' ? value : confirmPassword, confirmPassword: field === 'confirmPassword' ? value : confirmPassword,
}); })
if (!result.success) { if (!result.success) {
const fieldError = result.error.issues.find( const fieldError = result.error.issues.find((err) => err.path[0] === field)
(err) => err.path[0] === field,
);
if (fieldError !== null && fieldError !== undefined) { if (fieldError !== null && fieldError !== undefined) {
setErrors((prev) => ({ ...prev, [field]: fieldError.message })); setErrors((prev) => ({ ...prev, [field]: fieldError.message }))
} else { } else {
setErrors((prev) => { setErrors((prev) => {
const { [field]: _, ...rest } = prev; const { [field]: _, ...rest } = prev
return rest; return rest
}); })
} }
} else { } else {
setErrors((prev) => { setErrors((prev) => {
const { [field]: _, ...rest } = prev; const { [field]: _, ...rest } = prev
return rest; return rest
}); })
} }
}; }
const handleBlur = ( const handleBlur = (field: 'email' | 'password' | 'confirmPassword'): void => {
field: 'email' | 'password' | 'confirmPassword', let value: string
): void => {
let value: string;
if (field === 'email') { if (field === 'email') {
value = email; value = email
} else if (field === 'password') { } else if (field === 'password') {
value = password; value = password
} else { } else {
value = confirmPassword; value = confirmPassword
} }
validateField(field, value); validateField(field, value)
}; }
const validateForm = (): boolean => { const validateForm = (): boolean => {
const result = registerSchema.safeParse({ const result = registerSchema.safeParse({
email, email,
password, password,
confirmPassword, confirmPassword,
}); })
if (!result.success) { if (!result.success) {
const newErrors: { const newErrors: {
email?: string; email?: string
password?: string; password?: string
confirmPassword?: string; confirmPassword?: string
} = {}; } = {}
result.error.issues.forEach((err) => { 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) { if (field !== null && field !== undefined) {
newErrors[field] = err.message; newErrors[field] = err.message
} }
}); })
setErrors(newErrors); setErrors(newErrors)
return false; return false
} }
setErrors({}); setErrors({})
return true; return true
}; }
const handleSubmit = (e: React.FormEvent<HTMLFormElement>): void => { const handleSubmit = (e: React.FormEvent<HTMLFormElement>): void => {
e.preventDefault(); e.preventDefault()
if (!validateForm()) { if (!validateForm()) {
return; return
} }
register( register(
{ email, password }, { email, password },
{ {
onSuccess: () => { onSuccess: () => {
clearRegisterForm(); clearRegisterForm()
}, },
}, }
); )
}; }
return ( return (
<form <form className="auth-form" onSubmit={handleSubmit}>
className="auth-form"
onSubmit={handleSubmit}
>
<div className="auth-form__header"> <div className="auth-form__header">
<h1 className="auth-form__title">Create Account</h1> <h1 className="auth-form__title">Create Account</h1>
<p className="auth-form__subtitle"> <p className="auth-form__subtitle">
@ -212,32 +200,22 @@ export const RegisterForm = (): React.ReactElement => {
</div> </div>
{error !== null && error !== undefined && isAxiosError(error) ? ( {error !== null && error !== undefined && isAxiosError(error) ? (
<div <div className="auth-form__error-message" role="alert">
className="auth-form__error-message" {(error.response?.data as { detail?: string } | undefined)?.detail ??
role="alert" 'Registration failed. Please try again.'}
>
{(error.response?.data as { detail?: string } | undefined)
?.detail ?? 'Registration failed. Please try again.'}
</div> </div>
) : null} ) : null}
<Button <Button type="submit" isLoading={isPending} disabled={isPending}>
type="submit"
isLoading={isPending}
disabled={isPending}
>
Create Account Create Account
</Button> </Button>
<p className="auth-form__link"> <p className="auth-form__link">
Already have an account?{' '} Already have an account?{' '}
<Link <Link to="/login" className="auth-form__link-text">
to="/login"
className="auth-form__link-text"
>
Sign in Sign in
</Link> </Link>
</p> </p>
</form> </form>
); )
}; }

View File

@ -3,14 +3,14 @@
// ©AngelaMos | 2025 // ©AngelaMos | 2025
// =========================== // ===========================
import { type ButtonHTMLAttributes } from 'react'; import type { ButtonHTMLAttributes } from 'react'
import './Button.css'; import './Button.css'
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'ghost'; variant?: 'primary' | 'secondary' | 'ghost'
size?: 'sm' | 'md' | 'lg'; size?: 'sm' | 'md' | 'lg'
isLoading?: boolean; isLoading?: boolean
children: React.ReactNode; children: React.ReactNode
} }
export const Button = ({ export const Button = ({
@ -30,5 +30,5 @@ export const Button = ({
> >
{isLoading ? 'Loading...' : children} {isLoading ? 'Loading...' : children}
</button> </button>
); )
}; }

View File

@ -3,50 +3,42 @@
// ©AngelaMos | 2025 // ©AngelaMos | 2025
// =========================== // ===========================
import { type InputHTMLAttributes, forwardRef } from 'react'; import { forwardRef, type InputHTMLAttributes } from 'react'
import './Input.css'; import './Input.css'
interface InputProps extends InputHTMLAttributes<HTMLInputElement> { interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label: string; label: string
error?: string | undefined; error?: string | undefined
} }
export const Input = forwardRef<HTMLInputElement, InputProps>( export const Input = forwardRef<HTMLInputElement, InputProps>(
({ label, error, id, ...props }, ref) => { ({ label, error, id, ...props }, ref) => {
const inputId = const inputId = id ?? `input-${label.toLowerCase().replace(/\s+/g, '-')}`
id ?? `input-${label.toLowerCase().replace(/\s+/g, '-')}`; const errorId = `${inputId}-error`
const errorId = `${inputId}-error`;
return ( return (
<div className="input-wrapper"> <div className="input-wrapper">
<label <label htmlFor={inputId} className="input-label">
htmlFor={inputId}
className="input-label"
>
{label} {label}
</label> </label>
<input <input
ref={ref} ref={ref}
id={inputId} id={inputId}
className={`input ${error !== null && error !== undefined ? 'input--error' : ''}`} className={`input ${error !== null && error !== undefined ? 'input--error' : ''}`}
aria-invalid={error !== null && error !== undefined ? true : false} aria-invalid={!!(error !== null && error !== undefined)}
aria-describedby={ aria-describedby={
error !== null && error !== undefined ? errorId : undefined error !== null && error !== undefined ? errorId : undefined
} }
{...props} {...props}
/> />
{error !== null && error !== undefined ? ( {error !== null && error !== undefined ? (
<p <p id={errorId} className="input-error" role="alert">
id={errorId}
className="input-error"
role="alert"
>
{error} {error}
</p> </p>
) : null} ) : null}
</div> </div>
); )
}, }
); )
Input.displayName = 'Input'; Input.displayName = 'Input'

View File

@ -3,25 +3,21 @@
// ©AngelaMos | 2025 // ©AngelaMos | 2025
// =========================== // ===========================
import { TEST_TYPE_LABELS, type ScanTestType } from '@/config/constants'; import { type ScanTestType, TEST_TYPE_LABELS } from '@/config/constants'
import './LoadingOverlay.css'; import './LoadingOverlay.css'
interface LoadingOverlayProps { interface LoadingOverlayProps {
tests: ScanTestType[]; tests: ScanTestType[]
} }
export const LoadingOverlay = ({ export const LoadingOverlay = ({
tests, tests,
}: LoadingOverlayProps): React.ReactElement => { }: LoadingOverlayProps): React.ReactElement => {
return ( return (
<div <div className="loading-overlay" role="dialog" aria-label="Scan in progress">
className="loading-overlay"
role="dialog"
aria-label="Scan in progress"
>
<div className="loading-overlay__content"> <div className="loading-overlay__content">
<div className="loading-overlay__spinner"> <div className="loading-overlay__spinner">
<div className="spinner"></div> <div className="spinner" />
</div> </div>
<h2 className="loading-overlay__title">Running Security Scan</h2> <h2 className="loading-overlay__title">Running Security Scan</h2>
<p className="loading-overlay__subtitle"> <p className="loading-overlay__subtitle">
@ -30,15 +26,12 @@ export const LoadingOverlay = ({
</p> </p>
<div className="loading-overlay__tests"> <div className="loading-overlay__tests">
{tests.map((test) => ( {tests.map((test) => (
<div <div key={test} className="loading-overlay__test">
key={test}
className="loading-overlay__test"
>
{TEST_TYPE_LABELS[test]} {TEST_TYPE_LABELS[test]}
</div> </div>
))} ))}
</div> </div>
</div> </div>
</div> </div>
); )
}; }

Some files were not shown because too many files have changed in this diff Show More