initial implementation

This commit is contained in:
CarterPerez-dev 2025-11-28 15:16:10 -05:00
parent 7f324efd87
commit 96d681cf1e
139 changed files with 16317 additions and 2 deletions

View File

@ -0,0 +1,63 @@
# Application
ENV=development
DEBUG=true
APP_NAME=encrypted-p2p-chat
SECRET_KEY=your-secret-key-here-change-in-production
# Docker Host Ports (change these if you have conflicts)
POSTGRES_HOST_PORT=5432
SURREAL_HOST_PORT=8001
REDIS_HOST_PORT=6379
BACKEND_HOST_PORT=8000
NGINX_HTTP_PORT=80
NGINX_HTTPS_PORT=443
# PostgreSQL (for auth data)
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
POSTGRES_DB=chat_auth
POSTGRES_USER=chat_user
POSTGRES_PASSWORD=change-this-password
DATABASE_URL=postgresql+asyncpg://chat_user:change-this-password@postgres:5432/chat_auth
DB_POOL_SIZE=20
DB_MAX_OVERFLOW=40
# SurrealDB (for real-time chat data)
SURREAL_HOST=surrealdb
SURREAL_PORT=8000
SURREAL_USER=root
SURREAL_PASSWORD=change-this-password
SURREAL_NAMESPACE=chat
SURREAL_DATABASE=production
SURREAL_URL=ws://surrealdb:8000
# Redis (for caching and rate limiting)
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_URL=redis://redis:6379
# WebAuthn / Passkeys
RP_ID=localhost
RP_NAME=Encrypted P2P Chat
RP_ORIGIN=http://localhost
# Frontend (Vite requires VITE_ prefix)
VITE_API_URL=http://localhost:8000
VITE_WS_URL=ws://localhost:8000
VITE_RP_ID=localhost
# CORS
CORS_ORIGINS=["http://localhost:3000","http://localhost:5173"]
# WebSocket
WS_HEARTBEAT_INTERVAL=30
WS_MAX_CONNECTIONS_PER_USER=5
# Encryption
KEY_ROTATION_DAYS=90
MAX_SKIPPED_MESSAGE_KEYS=1000
# Rate Limiting
RATE_LIMIT_MESSAGES_PER_MINUTE=60
RATE_LIMIT_AUTH_ATTEMPTS=5

71
PROJECTS/encrypted-p2p-chat/.gitignore vendored Normal file
View File

@ -0,0 +1,71 @@
# Environment
/.env
.env.local
backend/.env
frontend/.env
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
venv/
ENV/
env/
# Testing
.pytest_cache/
.coverage
htmlcov/
.mypy_cache/
.ruff_cache/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# Logs
*.log
logs/
# Docker
docker-compose.override.yml
# Database
*.db
*.sqlite
*.sqlite3
# macOS
.DS_Store
# Node
node_modules/
.pnpm-store/
dist/
.solid/
# Nginx
nginx/ssl/
# AI handoff documentation
.truth

View File

@ -0,0 +1,247 @@
<#
.Synopsis
Activate a Python virtual environment for the current PowerShell session.
.Description
Pushes the python executable for a virtual environment to the front of the
$Env:PATH environment variable and sets the prompt to signify that you are
in a Python virtual environment. Makes use of the command line switches as
well as the `pyvenv.cfg` file values present in the virtual environment.
.Parameter VenvDir
Path to the directory that contains the virtual environment to activate. The
default value for this is the parent of the directory that the Activate.ps1
script is located within.
.Parameter Prompt
The prompt prefix to display when this virtual environment is activated. By
default, this prompt is the name of the virtual environment folder (VenvDir)
surrounded by parentheses and followed by a single space (ie. '(.venv) ').
.Example
Activate.ps1
Activates the Python virtual environment that contains the Activate.ps1 script.
.Example
Activate.ps1 -Verbose
Activates the Python virtual environment that contains the Activate.ps1 script,
and shows extra information about the activation as it executes.
.Example
Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv
Activates the Python virtual environment located in the specified location.
.Example
Activate.ps1 -Prompt "MyPython"
Activates the Python virtual environment that contains the Activate.ps1 script,
and prefixes the current prompt with the specified string (surrounded in
parentheses) while the virtual environment is active.
.Notes
On Windows, it may be required to enable this Activate.ps1 script by setting the
execution policy for the user. You can do this by issuing the following PowerShell
command:
PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
For more information on Execution Policies:
https://go.microsoft.com/fwlink/?LinkID=135170
#>
Param(
[Parameter(Mandatory = $false)]
[String]
$VenvDir,
[Parameter(Mandatory = $false)]
[String]
$Prompt
)
<# Function declarations --------------------------------------------------- #>
<#
.Synopsis
Remove all shell session elements added by the Activate script, including the
addition of the virtual environment's Python executable from the beginning of
the PATH variable.
.Parameter NonDestructive
If present, do not remove this function from the global namespace for the
session.
#>
function global:deactivate ([switch]$NonDestructive) {
# Revert to original values
# The prior prompt:
if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) {
Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt
Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT
}
# The prior PYTHONHOME:
if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) {
Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME
Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME
}
# The prior PATH:
if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) {
Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH
Remove-Item -Path Env:_OLD_VIRTUAL_PATH
}
# Just remove the VIRTUAL_ENV altogether:
if (Test-Path -Path Env:VIRTUAL_ENV) {
Remove-Item -Path env:VIRTUAL_ENV
}
# Just remove VIRTUAL_ENV_PROMPT altogether.
if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) {
Remove-Item -Path env:VIRTUAL_ENV_PROMPT
}
# Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether:
if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) {
Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force
}
# Leave deactivate function in the global namespace if requested:
if (-not $NonDestructive) {
Remove-Item -Path function:deactivate
}
}
<#
.Description
Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the
given folder, and returns them in a map.
For each line in the pyvenv.cfg file, if that line can be parsed into exactly
two strings separated by `=` (with any amount of whitespace surrounding the =)
then it is considered a `key = value` line. The left hand string is the key,
the right hand is the value.
If the value starts with a `'` or a `"` then the first and last character is
stripped from the value before being captured.
.Parameter ConfigDir
Path to the directory that contains the `pyvenv.cfg` file.
#>
function Get-PyVenvConfig(
[String]
$ConfigDir
) {
Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg"
# Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue).
$pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue
# An empty map will be returned if no config file is found.
$pyvenvConfig = @{ }
if ($pyvenvConfigPath) {
Write-Verbose "File exists, parse `key = value` lines"
$pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath
$pyvenvConfigContent | ForEach-Object {
$keyval = $PSItem -split "\s*=\s*", 2
if ($keyval[0] -and $keyval[1]) {
$val = $keyval[1]
# Remove extraneous quotations around a string value.
if ("'""".Contains($val.Substring(0, 1))) {
$val = $val.Substring(1, $val.Length - 2)
}
$pyvenvConfig[$keyval[0]] = $val
Write-Verbose "Adding Key: '$($keyval[0])'='$val'"
}
}
}
return $pyvenvConfig
}
<# Begin Activate script --------------------------------------------------- #>
# Determine the containing directory of this script
$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
$VenvExecDir = Get-Item -Path $VenvExecPath
Write-Verbose "Activation script is located in path: '$VenvExecPath'"
Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)"
Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)"
# Set values required in priority: CmdLine, ConfigFile, Default
# First, get the location of the virtual environment, it might not be
# VenvExecDir if specified on the command line.
if ($VenvDir) {
Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values"
}
else {
Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir."
$VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/")
Write-Verbose "VenvDir=$VenvDir"
}
# Next, read the `pyvenv.cfg` file to determine any required value such
# as `prompt`.
$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir
# Next, set the prompt from the command line, or the config file, or
# just use the name of the virtual environment folder.
if ($Prompt) {
Write-Verbose "Prompt specified as argument, using '$Prompt'"
}
else {
Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value"
if ($pyvenvCfg -and $pyvenvCfg['prompt']) {
Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'"
$Prompt = $pyvenvCfg['prompt'];
}
else {
Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)"
Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'"
$Prompt = Split-Path -Path $venvDir -Leaf
}
}
Write-Verbose "Prompt = '$Prompt'"
Write-Verbose "VenvDir='$VenvDir'"
# Deactivate any currently active virtual environment, but leave the
# deactivate function in place.
deactivate -nondestructive
# Now set the environment variable VIRTUAL_ENV, used by many tools to determine
# that there is an activated venv.
$env:VIRTUAL_ENV = $VenvDir
if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) {
Write-Verbose "Setting prompt to '$Prompt'"
# Set the prompt to include the env name
# Make sure _OLD_VIRTUAL_PROMPT is global
function global:_OLD_VIRTUAL_PROMPT { "" }
Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT
New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt
function global:prompt {
Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) "
_OLD_VIRTUAL_PROMPT
}
$env:VIRTUAL_ENV_PROMPT = $Prompt
}
# Clear PYTHONHOME
if (Test-Path -Path Env:PYTHONHOME) {
Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME
Remove-Item -Path Env:PYTHONHOME
}
# Add the venv to the PATH
Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH
$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH"

View File

@ -0,0 +1,70 @@
# This file must be used with "source bin/activate" *from bash*
# You cannot run it directly
deactivate () {
# reset old environment variables
if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then
PATH="${_OLD_VIRTUAL_PATH:-}"
export PATH
unset _OLD_VIRTUAL_PATH
fi
if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then
PYTHONHOME="${_OLD_VIRTUAL_PYTHONHOME:-}"
export PYTHONHOME
unset _OLD_VIRTUAL_PYTHONHOME
fi
# Call hash to forget past commands. Without forgetting
# past commands the $PATH changes we made may not be respected
hash -r 2> /dev/null
if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then
PS1="${_OLD_VIRTUAL_PS1:-}"
export PS1
unset _OLD_VIRTUAL_PS1
fi
unset VIRTUAL_ENV
unset VIRTUAL_ENV_PROMPT
if [ ! "${1:-}" = "nondestructive" ] ; then
# Self destruct!
unset -f deactivate
fi
}
# unset irrelevant variables
deactivate nondestructive
# on Windows, a path can contain colons and backslashes and has to be converted:
if [ "${OSTYPE:-}" = "cygwin" ] || [ "${OSTYPE:-}" = "msys" ] ; then
# transform D:\path\to\venv to /d/path/to/venv on MSYS
# and to /cygdrive/d/path/to/venv on Cygwin
export VIRTUAL_ENV=$(cygpath "/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv")
else
# use the path as-is
export VIRTUAL_ENV="/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv"
fi
_OLD_VIRTUAL_PATH="$PATH"
PATH="$VIRTUAL_ENV/bin:$PATH"
export PATH
# unset PYTHONHOME if set
# this will fail if PYTHONHOME is set to the empty string (which is bad anyway)
# could use `if (set -u; : $PYTHONHOME) ;` in bash
if [ -n "${PYTHONHOME:-}" ] ; then
_OLD_VIRTUAL_PYTHONHOME="${PYTHONHOME:-}"
unset PYTHONHOME
fi
if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then
_OLD_VIRTUAL_PS1="${PS1:-}"
PS1="(.venv) ${PS1:-}"
export PS1
VIRTUAL_ENV_PROMPT="(.venv) "
export VIRTUAL_ENV_PROMPT
fi
# Call hash to forget past commands. Without forgetting
# past commands the $PATH changes we made may not be respected
hash -r 2> /dev/null

View File

@ -0,0 +1,27 @@
# This file must be used with "source bin/activate.csh" *from csh*.
# You cannot run it directly.
# Created by Davide Di Blasi <davidedb@gmail.com>.
# Ported to Python 3.3 venv by Andrew Svetlov <andrew.svetlov@gmail.com>
alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_PROMPT != 0 && set prompt="$_OLD_VIRTUAL_PROMPT" && unset _OLD_VIRTUAL_PROMPT; unsetenv VIRTUAL_ENV; unsetenv VIRTUAL_ENV_PROMPT; test "\!:*" != "nondestructive" && unalias deactivate'
# Unset irrelevant variables.
deactivate nondestructive
setenv VIRTUAL_ENV "/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv"
set _OLD_VIRTUAL_PATH="$PATH"
setenv PATH "$VIRTUAL_ENV/bin:$PATH"
set _OLD_VIRTUAL_PROMPT="$prompt"
if (! "$?VIRTUAL_ENV_DISABLE_PROMPT") then
set prompt = "(.venv) $prompt"
setenv VIRTUAL_ENV_PROMPT "(.venv) "
endif
alias pydoc python -m pydoc
rehash

View File

@ -0,0 +1,69 @@
# This file must be used with "source <venv>/bin/activate.fish" *from fish*
# (https://fishshell.com/). You cannot run it directly.
function deactivate -d "Exit virtual environment and return to normal shell environment"
# reset old environment variables
if test -n "$_OLD_VIRTUAL_PATH"
set -gx PATH $_OLD_VIRTUAL_PATH
set -e _OLD_VIRTUAL_PATH
end
if test -n "$_OLD_VIRTUAL_PYTHONHOME"
set -gx PYTHONHOME $_OLD_VIRTUAL_PYTHONHOME
set -e _OLD_VIRTUAL_PYTHONHOME
end
if test -n "$_OLD_FISH_PROMPT_OVERRIDE"
set -e _OLD_FISH_PROMPT_OVERRIDE
# prevents error when using nested fish instances (Issue #93858)
if functions -q _old_fish_prompt
functions -e fish_prompt
functions -c _old_fish_prompt fish_prompt
functions -e _old_fish_prompt
end
end
set -e VIRTUAL_ENV
set -e VIRTUAL_ENV_PROMPT
if test "$argv[1]" != "nondestructive"
# Self-destruct!
functions -e deactivate
end
end
# Unset irrelevant variables.
deactivate nondestructive
set -gx VIRTUAL_ENV "/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv"
set -gx _OLD_VIRTUAL_PATH $PATH
set -gx PATH "$VIRTUAL_ENV/bin" $PATH
# Unset PYTHONHOME if set.
if set -q PYTHONHOME
set -gx _OLD_VIRTUAL_PYTHONHOME $PYTHONHOME
set -e PYTHONHOME
end
if test -z "$VIRTUAL_ENV_DISABLE_PROMPT"
# fish uses a function instead of an env var to generate the prompt.
# Save the current fish_prompt function as the function _old_fish_prompt.
functions -c fish_prompt _old_fish_prompt
# With the original prompt function renamed, we can override with our own.
function fish_prompt
# Save the return status of the last command.
set -l old_status $status
# Output the venv prompt; color taken from the blue of the Python logo.
printf "%s%s%s" (set_color 4B8BBE) "(.venv) " (set_color normal)
# Restore the return status of the previous command.
echo "exit $old_status" | .
# Output the original/"old" prompt.
_old_fish_prompt
end
set -gx _OLD_FISH_PROMPT_OVERRIDE "$VIRTUAL_ENV"
set -gx VIRTUAL_ENV_PROMPT "(.venv) "
end

View File

@ -0,0 +1,8 @@
#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python
# -*- coding: utf-8 -*-
import re
import sys
from alembic.config import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

View File

@ -0,0 +1,8 @@
#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python
# -*- coding: utf-8 -*-
import re
import sys
from cbor2.tool import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

View File

@ -0,0 +1,8 @@
#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python
# -*- coding: utf-8 -*-
import re
import sys
from mypy.dmypy.client import console_entry
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(console_entry())

View File

@ -0,0 +1,8 @@
#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python
# -*- coding: utf-8 -*-
import re
import sys
from dotenv.__main__ import cli
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(cli())

View File

@ -0,0 +1,8 @@
#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python
# -*- coding: utf-8 -*-
import re
import sys
from fastapi.cli import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

View File

@ -0,0 +1,8 @@
#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python
# -*- coding: utf-8 -*-
import re
import sys
from httpx import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

View File

@ -0,0 +1,8 @@
#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python
# -*- coding: utf-8 -*-
import re
import sys
from identify.cli import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

View File

@ -0,0 +1,8 @@
#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python
# -*- coding: utf-8 -*-
import re
import sys
from mako.cmd import cmdline
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(cmdline())

View File

@ -0,0 +1,8 @@
#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python
# -*- coding: utf-8 -*-
import re
import sys
from mypy.__main__ import console_entry
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(console_entry())

View File

@ -0,0 +1,8 @@
#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python
# -*- coding: utf-8 -*-
import re
import sys
from mypyc.__main__ import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

View File

@ -0,0 +1,8 @@
#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python
# -*- coding: utf-8 -*-
import re
import sys
from nodeenv import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

View File

@ -0,0 +1,8 @@
#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python
# -*- coding: utf-8 -*-
import re
import sys
from charset_normalizer.cli import cli_detect
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(cli_detect())

View File

@ -0,0 +1,8 @@
#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python
# -*- coding: utf-8 -*-
import re
import sys
from pip._internal.cli.main import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

View File

@ -0,0 +1,8 @@
#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python
# -*- coding: utf-8 -*-
import re
import sys
from pip._internal.cli.main import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

View File

@ -0,0 +1,8 @@
#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python
# -*- coding: utf-8 -*-
import re
import sys
from pip._internal.cli.main import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

View File

@ -0,0 +1,8 @@
#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python
# -*- coding: utf-8 -*-
import re
import sys
from pre_commit.main import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

View File

@ -0,0 +1,8 @@
#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python
# -*- coding: utf-8 -*-
import re
import sys
from pytest import console_main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(console_main())

View File

@ -0,0 +1,8 @@
#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python
# -*- coding: utf-8 -*-
import re
import sys
from pygments.cmdline import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

View File

@ -0,0 +1,8 @@
#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python
# -*- coding: utf-8 -*-
import re
import sys
from pytest import console_main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(console_main())

View File

@ -0,0 +1 @@
/home/yoshi/.pyenv/versions/3.12.7/bin/python

View File

@ -0,0 +1 @@
python

View File

@ -0,0 +1 @@
python

Binary file not shown.

View File

@ -0,0 +1,8 @@
#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python
# -*- coding: utf-8 -*-
import re
import sys
from mypy.stubgen import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

View File

@ -0,0 +1,8 @@
#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python
# -*- coding: utf-8 -*-
import re
import sys
from mypy.stubtest import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

View File

@ -0,0 +1,8 @@
#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python
# -*- coding: utf-8 -*-
import re
import sys
from uvicorn.main import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

View File

@ -0,0 +1,8 @@
#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python
# -*- coding: utf-8 -*-
import re
import sys
from virtualenv.__main__ import run_with_catch
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(run_with_catch())

View File

@ -0,0 +1,8 @@
#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python
# -*- coding: utf-8 -*-
import re
import sys
from watchfiles.cli import cli
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(cli())

View File

@ -0,0 +1,8 @@
#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python
# -*- coding: utf-8 -*-
import re
import sys
from websockets.cli import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

View File

@ -0,0 +1,8 @@
#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python
# -*- coding: utf-8 -*-
import re
import sys
from yapf import run_main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(run_main())

View File

@ -0,0 +1,8 @@
#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python
# -*- coding: utf-8 -*-
import re
import sys
from yapf_third_party.yapf_diff.yapf_diff import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

View File

@ -0,0 +1,164 @@
/* -*- indent-tabs-mode: nil; tab-width: 4; -*- */
/* Greenlet object interface */
#ifndef Py_GREENLETOBJECT_H
#define Py_GREENLETOBJECT_H
#include <Python.h>
#ifdef __cplusplus
extern "C" {
#endif
/* This is deprecated and undocumented. It does not change. */
#define GREENLET_VERSION "1.0.0"
#ifndef GREENLET_MODULE
#define implementation_ptr_t void*
#endif
typedef struct _greenlet {
PyObject_HEAD
PyObject* weakreflist;
PyObject* dict;
implementation_ptr_t pimpl;
} PyGreenlet;
#define PyGreenlet_Check(op) (op && PyObject_TypeCheck(op, &PyGreenlet_Type))
/* C API functions */
/* Total number of symbols that are exported */
#define PyGreenlet_API_pointers 12
#define PyGreenlet_Type_NUM 0
#define PyExc_GreenletError_NUM 1
#define PyExc_GreenletExit_NUM 2
#define PyGreenlet_New_NUM 3
#define PyGreenlet_GetCurrent_NUM 4
#define PyGreenlet_Throw_NUM 5
#define PyGreenlet_Switch_NUM 6
#define PyGreenlet_SetParent_NUM 7
#define PyGreenlet_MAIN_NUM 8
#define PyGreenlet_STARTED_NUM 9
#define PyGreenlet_ACTIVE_NUM 10
#define PyGreenlet_GET_PARENT_NUM 11
#ifndef GREENLET_MODULE
/* This section is used by modules that uses the greenlet C API */
static void** _PyGreenlet_API = NULL;
# define PyGreenlet_Type \
(*(PyTypeObject*)_PyGreenlet_API[PyGreenlet_Type_NUM])
# define PyExc_GreenletError \
((PyObject*)_PyGreenlet_API[PyExc_GreenletError_NUM])
# define PyExc_GreenletExit \
((PyObject*)_PyGreenlet_API[PyExc_GreenletExit_NUM])
/*
* PyGreenlet_New(PyObject *args)
*
* greenlet.greenlet(run, parent=None)
*/
# define PyGreenlet_New \
(*(PyGreenlet * (*)(PyObject * run, PyGreenlet * parent)) \
_PyGreenlet_API[PyGreenlet_New_NUM])
/*
* PyGreenlet_GetCurrent(void)
*
* greenlet.getcurrent()
*/
# define PyGreenlet_GetCurrent \
(*(PyGreenlet * (*)(void)) _PyGreenlet_API[PyGreenlet_GetCurrent_NUM])
/*
* PyGreenlet_Throw(
* PyGreenlet *greenlet,
* PyObject *typ,
* PyObject *val,
* PyObject *tb)
*
* g.throw(...)
*/
# define PyGreenlet_Throw \
(*(PyObject * (*)(PyGreenlet * self, \
PyObject * typ, \
PyObject * val, \
PyObject * tb)) \
_PyGreenlet_API[PyGreenlet_Throw_NUM])
/*
* PyGreenlet_Switch(PyGreenlet *greenlet, PyObject *args)
*
* g.switch(*args, **kwargs)
*/
# define PyGreenlet_Switch \
(*(PyObject * \
(*)(PyGreenlet * greenlet, PyObject * args, PyObject * kwargs)) \
_PyGreenlet_API[PyGreenlet_Switch_NUM])
/*
* PyGreenlet_SetParent(PyObject *greenlet, PyObject *new_parent)
*
* g.parent = new_parent
*/
# define PyGreenlet_SetParent \
(*(int (*)(PyGreenlet * greenlet, PyGreenlet * nparent)) \
_PyGreenlet_API[PyGreenlet_SetParent_NUM])
/*
* PyGreenlet_GetParent(PyObject* greenlet)
*
* return greenlet.parent;
*
* This could return NULL even if there is no exception active.
* If it does not return NULL, you are responsible for decrementing the
* reference count.
*/
# define PyGreenlet_GetParent \
(*(PyGreenlet* (*)(PyGreenlet*)) \
_PyGreenlet_API[PyGreenlet_GET_PARENT_NUM])
/*
* deprecated, undocumented alias.
*/
# define PyGreenlet_GET_PARENT PyGreenlet_GetParent
# define PyGreenlet_MAIN \
(*(int (*)(PyGreenlet*)) \
_PyGreenlet_API[PyGreenlet_MAIN_NUM])
# define PyGreenlet_STARTED \
(*(int (*)(PyGreenlet*)) \
_PyGreenlet_API[PyGreenlet_STARTED_NUM])
# define PyGreenlet_ACTIVE \
(*(int (*)(PyGreenlet*)) \
_PyGreenlet_API[PyGreenlet_ACTIVE_NUM])
/* Macro that imports greenlet and initializes C API */
/* NOTE: This has actually moved to ``greenlet._greenlet._C_API``, but we
keep the older definition to be sure older code that might have a copy of
the header still works. */
# define PyGreenlet_Import() \
{ \
_PyGreenlet_API = (void**)PyCapsule_Import("greenlet._C_API", 0); \
}
#endif /* GREENLET_MODULE */
#ifdef __cplusplus
}
#endif
#endif /* !Py_GREENLETOBJECT_H */

View File

@ -0,0 +1 @@
lib

View File

@ -0,0 +1,5 @@
home = /home/yoshi/.pyenv/versions/3.12.7/bin
include-system-site-packages = false
version = 3.12.7
executable = /home/yoshi/.pyenv/versions/3.12.7/bin/python3.12
command = /home/yoshi/.pyenv/versions/3.12.7/bin/python -m venv /home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv

View File

@ -0,0 +1,110 @@
# ⒸAngelaMos | 2025
# Makefile
.PHONY: help setup setup-backend setup-frontend env dev prod build-dev build-prod up-dev up-prod down-dev down-prod logs-dev logs-prod test-backend clean
help:
@echo "Encrypted P2P Chat - Makefile Commands"
@echo ""
@echo "Setup:"
@echo " make setup - Complete project setup (backend + frontend)"
@echo " make setup-backend - Setup backend (venv, install deps)"
@echo " make setup-frontend - Setup frontend (install npm deps)"
@echo " make env - Copy .env.example to .env files"
@echo ""
@echo "Development:"
@echo " make dev - Start development environment"
@echo " make build-dev - Build development Docker images"
@echo " make up-dev - Start development containers"
@echo " make down-dev - Stop development containers"
@echo " make logs-dev - Follow development logs"
@echo ""
@echo "Production:"
@echo " make prod - Start production environment"
@echo " make build-prod - Build production Docker images"
@echo " make up-prod - Start production containers"
@echo " make down-prod - Stop production containers"
@echo " make logs-prod - Follow production logs"
@echo ""
@echo "Testing:"
@echo " make test-backend - Run backend tests"
@echo ""
@echo "Cleanup:"
@echo " make clean - Remove all containers, volumes, and build artifacts"
setup: setup-backend setup-frontend env
@echo "Setup complete!"
setup-backend:
@echo "Setting up backend..."
cd backend && python3 -m venv ../.venv
. .venv/bin/activate && cd backend && pip install -e .[dev]
@echo "Backend setup complete!"
setup-frontend:
@echo "Setting up frontend..."
cd frontend && npm install
@echo "Frontend setup complete!"
env:
@echo "Creating .env files..."
@if [ ! -f .env ]; then cp .env.example .env; echo "Created root .env"; fi
@if [ ! -f frontend/.env ]; then cp frontend/.env.example frontend/.env; echo "Created frontend/.env"; fi
@echo ".env files created! Please update with your values."
dev: build-dev up-dev
build-dev:
@echo "Building development Docker images..."
docker compose -f docker-compose.dev.yml build
up-dev:
@echo "Starting development environment..."
docker compose -f docker-compose.dev.yml up -d
@echo "Development environment started!"
@echo "Frontend: http://localhost:5173"
@echo "Backend: http://localhost:8000"
@echo "Nginx: http://localhost"
down-dev:
@echo "Stopping development environment..."
docker compose -f docker-compose.dev.yml down
logs-dev:
docker compose -f docker-compose.dev.yml logs -f
prod: build-prod up-prod
build-prod:
@echo "Building production Docker images..."
docker compose -f docker-compose.prod.yml build
up-prod:
@echo "Starting production environment..."
docker compose -f docker-compose.prod.yml up -d
@echo "Production environment started!"
@echo "Application: http://localhost"
down-prod:
@echo "Stopping production environment..."
docker compose -f docker-compose.prod.yml down
logs-prod:
docker compose -f docker-compose.prod.yml logs -f
test-backend:
@echo "Running backend tests..."
. .venv/bin/activate && cd backend && python -m pytest tests/ -v
clean:
@echo "Cleaning up..."
docker compose -f docker-compose.dev.yml down -v
docker compose -f docker-compose.prod.yml down -v
rm -rf frontend/node_modules
rm -rf frontend/dist
rm -rf backend/.venv
rm -rf backend/__pycache__
rm -rf backend/.pytest_cache
rm -rf backend/.mypy_cache
rm -rf backend/.ruff_cache
@echo "Cleanup complete!"

View File

@ -0,0 +1,240 @@
# Encrypted P2P Chat
End-to-end encrypted P2P chat application with Signal Protocol (Double Ratchet + X3DH) and WebAuthn/Passkeys authentication.
## Tech Stack
### Backend
- **FastAPI** - Modern Python web framework
- **PostgreSQL + SQLModel** - User and credential storage
- **SurrealDB** - Real-time messaging with live queries
- **Redis** - Challenge storage and caching
- **Double Ratchet + X3DH** - Signal Protocol encryption
- **WebAuthn** - Passwordless authentication
### Frontend
- **SolidJS 1.9** - Fine-grained reactive UI
- **TypeScript** - Type safety
- **Vite 6** - Modern build tool
- **Tailwind CSS v4** - Utility-first CSS
- **@tanstack/solid-query** - Data fetching
### Infrastructure
- **Docker Compose** - Service orchestration
- **Nginx** - Reverse proxy
- **Makefile** - Development automation
## Quick Start
### Prerequisites
- Docker and Docker Compose
- **Node.js 20.19+ or 22.12+** (required for Vite 7)
- **Python 3.13+** (latest stable)
- Make
### Setup
1. Clone the repository
2. Create environment files:
```bash
make env
```
This creates:
- `.env` (root) - Used by backend and docker-compose
- `frontend/.env` - Used by Vite frontend
3. Update `.env` files with your configuration
4. Run development environment:
```bash
make dev
```
The application will be available at:
- **Frontend**: http://localhost:3000 (Vite dev server)
- **Backend**: http://localhost:8000 (FastAPI)
- **Nginx**: http://localhost (proxies to frontend/backend)
### Development Commands
```bash
make help # Show all commands
make setup # Complete project setup
make dev # Start development environment
make logs-dev # Follow development logs
make down-dev # Stop development environment
make test-backend # Run backend tests
make clean # Clean all artifacts
```
### Production Commands
```bash
make build-prod # Build production images
make prod # Start production environment
make logs-prod # Follow production logs
make down-prod # Stop production environment
```
## Project Structure
```
encrypted-p2p-chat/
├── backend/
│ ├── app/
│ │ ├── api/ # API endpoints
│ │ │ ├── auth.py # WebAuthn authentication
│ │ │ ├── encryption.py # Prekey bundle endpoints
│ │ │ └── websocket.py # WebSocket endpoint
│ │ ├── core/
│ │ │ ├── encryption/
│ │ │ │ ├── x3dh_manager.py # X3DH key exchange
│ │ │ │ └── double_ratchet.py # Double Ratchet engine
│ │ │ ├── passkey/
│ │ │ │ └── passkey_manager.py # WebAuthn manager
│ │ │ ├── exceptions.py # Custom exceptions
│ │ │ ├── redis_manager.py # Redis client
│ │ │ ├── surreal_manager.py # SurrealDB client
│ │ │ └── websocket_manager.py # WebSocket connections
│ │ ├── models/ # SQLModel database models
│ │ ├── schemas/ # Pydantic schemas
│ │ ├── services/ # Business logic layer
│ │ ├── config.py # Configuration and constants
│ │ ├── factory.py # FastAPI app factory
│ │ └── main.py # Entry point
│ ├── tests/ # Pytest tests
│ ├── Dockerfile # Production
│ ├── Dockerfile.dev # Development
│ └── pyproject.toml
├── frontend/
│ ├── src/
│ │ ├── pages/ # SolidJS pages
│ │ ├── App.tsx # Root component with routes
│ │ ├── index.tsx # Entry point
│ │ ├── index.css # Tailwind imports
│ │ └── config.ts # Constants
│ ├── public/
│ │ └── index.html
│ ├── Dockerfile # Production
│ ├── Dockerfile.dev # Development
│ ├── vite.config.ts
│ ├── tsconfig.json
│ └── package.json
├── nginx/
│ ├── nginx.dev.conf # Development config
│ ├── nginx.prod.conf # Production config
│ └── Dockerfile
├── docker-compose.yml # Production
├── docker-compose.dev.yml # Development
├── Makefile
└── .env.example
## Features
### Authentication
- Passwordless login with WebAuthn/Passkeys
- Discoverable credentials (device-based auth)
- Multi-device support
- Signature counter verification
### Encryption
- Double Ratchet protocol (Signal)
- X3DH key exchange for async messaging
- Forward secrecy
- Break-in recovery
- Out-of-order message handling
### Real-time Messaging
- WebSocket connections
- SurrealDB live queries
- Online/offline presence
- Typing indicators
- Read receipts
- Heartbeat keep-alive
## Development
### Backend Development
```bash
cd backend
python -m venv ../.venv
source ../.venv/bin/activate
pip install -e .[dev]
python -m pytest tests/ -v
```
### Frontend Development
```bash
cd frontend
npm install
npm run dev
npm run typecheck
npm run lint
```
## Testing
### Backend Tests
```bash
make test-backend
```
Or manually:
```bash
cd backend
python -m pytest tests/ -v
```
## Environment Variables
See `.env.example` files for all configuration options.
Required variables:
- `SECRET_KEY` - Application secret key
- `POSTGRES_PASSWORD` - PostgreSQL password
- `SURREAL_PASSWORD` - SurrealDB password
## Architecture
### Backend Architecture
```
API Endpoints (thin routes)
Services (business logic)
Models (database)
PostgreSQL / SurrealDB / Redis
```
### Encryption Flow
```
X3DH Key Exchange
Shared Secret
Double Ratchet Initialization
Per-Message Encryption (AES-256-GCM)
```
### WebSocket Flow
```
Client → WebSocket → Connection Manager → Service Layer → SurrealDB
Live Queries → Broadcast
```
## License
MIT

View File

@ -0,0 +1,121 @@
"""
AngelaMos | 2025
Rooms API for creating and managing chat rooms
"""
import logging
from uuid import UUID
from datetime import UTC, datetime
from fastapi import (
APIRouter,
Depends,
HTTPException,
status,
)
from sqlmodel.ext.asyncio.session import AsyncSession
from app.schemas.rooms import (
CreateRoomRequest,
ParticipantResponse,
RoomAPIResponse,
RoomListResponse,
)
from app.core.enums import RoomType
from app.models.Base import get_session
from app.core.surreal_manager import surreal_db
from app.services.auth_service import auth_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix = "/rooms", tags = ["rooms"])
@router.post("", status_code = status.HTTP_201_CREATED)
async def create_room(
request: CreateRoomRequest,
session: AsyncSession = Depends(get_session),
) -> RoomAPIResponse:
"""
Create a new chat room
"""
participant = await auth_service.get_user_by_id(
session,
UUID(request.participant_id),
)
if not participant:
raise HTTPException(
status_code = status.HTTP_404_NOT_FOUND,
detail = "Participant not found",
)
now = datetime.now(UTC)
room_data = {
"name": participant.display_name,
"room_type": request.room_type.value,
"created_by": request.participant_id,
"created_at": now.isoformat(),
"updated_at": now.isoformat(),
"is_ephemeral": request.room_type == RoomType.EPHEMERAL,
}
room = await surreal_db.create_room(room_data)
logger.info(
"Created room %s with participant %s",
room.id,
request.participant_id,
)
return RoomAPIResponse(
id = room.id,
type = RoomType(room.room_type),
name = participant.display_name,
participants = [
ParticipantResponse(
user_id = str(participant.id),
username = participant.username,
display_name = participant.display_name,
role = "member",
joined_at = now.isoformat(),
)
],
unread_count = 0,
is_encrypted = True,
created_at = room.created_at.isoformat(),
updated_at = room.updated_at.isoformat(),
)
@router.get("", status_code = status.HTTP_200_OK)
async def list_rooms() -> RoomListResponse:
"""
List all rooms for the current user
"""
return RoomListResponse(rooms = [])
@router.get("/{room_id}", status_code = status.HTTP_200_OK)
async def get_room(room_id: str) -> RoomAPIResponse:
"""
Get a specific room
"""
raise HTTPException(
status_code = status.HTTP_404_NOT_FOUND,
detail = "Room not found",
)
@router.delete("/{room_id}", status_code = status.HTTP_204_NO_CONTENT)
async def delete_room(room_id: str) -> None:
"""
Delete a room
"""
raise HTTPException(
status_code = status.HTTP_404_NOT_FOUND,
detail = "Room not found",
)

View File

@ -87,6 +87,7 @@ class SurrealDBManager:
"""
await self.ensure_connected()
result = await self.db.create("messages", message_data)
result["id"] = str(result["id"])
return MessageResponse(**result)
async def get_room_messages(
@ -123,6 +124,7 @@ class SurrealDBManager:
"""
await self.ensure_connected()
result = await self.db.create("rooms", room_data)
result["id"] = str(result["id"])
return RoomResponse(**result)
async def get_user_rooms(self, user_id: str) -> list[RoomResponse]:
@ -235,7 +237,8 @@ class SurrealDBManager:
"""
await self.ensure_connected()
room = await self.db.create("rooms", room_data)
room_id = room["id"]
room_id = str(room["id"])
room["id"] = room_id
asyncio.create_task(self._schedule_room_deletion(room_id, ttl_seconds))
return RoomResponse(**room)

View File

@ -25,6 +25,7 @@ from app.config import (
)
from app.models.Base import init_db
from app.api.auth import router as auth_router
from app.api.rooms import router as rooms_router
from app.core.surreal_manager import surreal_db
from app.core.redis_manager import redis_manager
from app.api.encryption import router as encryption_router
@ -106,6 +107,7 @@ def create_app() -> FastAPI:
return HealthResponse(status = "healthy")
app.include_router(auth_router)
app.include_router(rooms_router)
app.include_router(encryption_router)
app.include_router(websocket_router)

View File

@ -33,6 +33,12 @@ from app.schemas.websocket import (
WSHeartbeat,
)
from app.schemas.common import HealthResponse, RootResponse
from app.schemas.rooms import (
CreateRoomRequest,
ParticipantResponse,
RoomAPIResponse,
RoomListResponse,
)
__all__ = [
@ -61,4 +67,8 @@ __all__ = [
"WSHeartbeat",
"RootResponse",
"HealthResponse",
"CreateRoomRequest",
"ParticipantResponse",
"RoomAPIResponse",
"RoomListResponse",
]

View File

@ -0,0 +1,48 @@
"""
AngelaMos | 2025
Pydantic schemas for rooms API
"""
from pydantic import BaseModel
from app.core.enums import RoomType
class CreateRoomRequest(BaseModel):
"""
Request to create a new room
"""
participant_id: str
room_type: RoomType = RoomType.DIRECT
class ParticipantResponse(BaseModel):
"""
Participant in a room
"""
user_id: str
username: str
display_name: str
role: str = "member"
joined_at: str
class RoomAPIResponse(BaseModel):
"""
Room response for API
"""
id: str
type: RoomType
name: str | None = None
participants: list[ParticipantResponse]
unread_count: int = 0
is_encrypted: bool = True
created_at: str
updated_at: str
class RoomListResponse(BaseModel):
"""
List of rooms response
"""
rooms: list[RoomAPIResponse]

View File

@ -109,11 +109,13 @@ ignore = [
"RUF002", # Docstring with ambiguous unicode
"B008", # FastAPI Depends() in defaults is standard pattern
"ARG001", # Unused function args (lifespan protocol)
"E712", # SQLAlchemy requires == True/False for query filters
]
[tool.ruff.lint.per-file-ignores]
"__init__.py" = ["F401"]
"tests/*" = ["ARG002"]
[tool.mypy]
python_version = "3.13"
@ -152,7 +154,7 @@ disable_error_code = ["union-attr", "type-arg"]
[[tool.mypy.overrides]]
module = "app.services.auth_service"
disable_error_code = ["arg-type", "no-any-return"]
disable_error_code = ["arg-type", "no-any-return", "attr-defined"]
[[tool.mypy.overrides]]
module = "app.services.prekey_service"
@ -238,6 +240,8 @@ max-positional-arguments = 7
[tool.pylint."messages control"]
per-file-ignores = [
"alembic/env.py:W0611", # Unused imports needed for SQLModel metadata
"app/services/*:C0121", # SQLAlchemy requires == True/False for query filters
"tests/*:W0212,W0621", # Tests access protected members; pytest fixtures reuse names
]

View File

@ -0,0 +1,28 @@
# ©AngelaMos | 2025
# Development FastAPI Dockerfile
# Hot reload with uvicorn, volume mounts for code
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /app
RUN apt-get update && \
apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
curl && \
rm -rf /var/lib/apt/lists/*
COPY backend/pyproject.toml ./
RUN pip install -e .[dev]
COPY backend/ .
EXPOSE 8000
CMD ["uvicorn", "app.factory:create_app", "--factory", "--host", "0.0.0.0", "--port", "8000", "--reload"]

View File

@ -0,0 +1,17 @@
# ©AngelaMos | 2025
# Development Vite Dockerfile
# HMR dev server, volume mounts for code
FROM node:22-alpine
WORKDIR /app
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ .
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]

View File

@ -0,0 +1,53 @@
# ©AngelaMos | 2025
# Production FastAPI Dockerfile
# Multi stage build, gunicorn with uvicorn workers
FROM python:3.13-slim AS builder
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /app
RUN apt-get update && \
apt-get install -y --no-install-recommends \
build-essential \
libpq-dev && \
rm -rf /var/lib/apt/lists/*
COPY backend/pyproject.toml ./
RUN pip install --user --no-warn-script-location .
RUN pip install --user --no-warn-script-location gunicorn
FROM python:3.13-slim
WORKDIR /app
RUN apt-get update && \
apt-get install -y --no-install-recommends \
libpq5 \
curl && \
rm -rf /var/lib/apt/lists/* && \
useradd -m -u 1000 appuser
COPY --from=builder /root/.local /home/appuser/.local
ENV PATH=/home/appuser/.local/bin:$PATH
COPY backend/ .
RUN chown -R appuser:appuser /app
USER appuser
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
CMD ["gunicorn", "app.main:app", \
"--workers", "4", \
"--worker-class", "uvicorn.workers.UvicornWorker", \
"--bind", "0.0.0.0:8000", \
"--access-logfile", "-", \
"--error-logfile", "-"]

View File

@ -0,0 +1,28 @@
# ©AngelaMos | 2025
# Production Vite Dockerfile
# Multi-stage: build with node, serve with nginx
FROM node:22-alpine AS builder
WORKDIR /app
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY conf/nginx/prod.nginx /etc/nginx/nginx.conf
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --quiet --tries=1 --spider http://localhost/health || exit 1
CMD ["nginx", "-g", "daemon off;"]

View File

@ -0,0 +1,81 @@
# ©AngelaMos | 2025
# Development Nginx Configuration
# - Proxies to Vite dev server (HMR/WebSocket support)
# - Proxies /api and /ws to FastAPI backend
# - No caching, verbose logging
user nginx;
worker_processes 1;
error_log /var/log/nginx/error.log debug;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
use epoll;
}
http {
include /etc/nginx/mime.types;
include /etc/nginx/http.conf;
default_type application/octet-stream;
access_log /var/log/nginx/access.log main_timed;
sendfile off;
tcp_nodelay on;
keepalive_timeout 65;
server {
listen 80;
listen [::]:80;
server_name _;
client_max_body_size 10M;
# WebSocket to backend
location /ws {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
}
# API routes
location /api {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_request_buffering off;
}
# Vite dev server with HMR
location / {
proxy_pass http://frontend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 60s;
proxy_buffering off;
}
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
}
}

View File

@ -0,0 +1,27 @@
# ©AngelaMos | 2025
# Shared HTTP configuration for dev and prod
# WebSocket upgrade handling - sets Connection to "upgrade" when Upgrade header present,
# otherwise "close" for regular HTTP requests
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
upstream backend {
server backend:8000 max_fails=3 fail_timeout=30s;
keepalive 32;
}
upstream frontend {
server frontend:5173;
}
log_format main_timed '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent" '
'rt=$request_time uct="$upstream_connect_time" '
'uht="$upstream_header_time" urt="$upstream_response_time"';
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
limit_conn_zone $binary_remote_addr zone=conn_limit:10m;

View File

@ -0,0 +1,187 @@
# ©AngelaMos | 2025
# Production Nginx Configuration
# - Serves static frontend from /usr/share/nginx/html
# - Proxies /api and /ws to FastAPI backend
# - Caching, compression, security headers, rate limiting
user nginx;
worker_processes auto;
worker_rlimit_nofile 100000;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 4096;
use epoll;
multi_accept on;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# WebSocket upgrade handling
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
upstream backend {
server backend:8000 max_fails=3 fail_timeout=30s;
keepalive 32;
}
# Rate limit zones
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
limit_conn_zone $binary_remote_addr zone=conn_limit:10m;
# Logging with timing and buffering
log_format main_timed '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent" '
'rt=$request_time urt="$upstream_response_time"';
access_log /var/log/nginx/access.log main_timed buffer=32k flush=5s;
# Performance
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
keepalive_requests 100;
types_hash_max_size 2048;
server_tokens off;
# File cache
open_file_cache max=10000 inactive=20s;
open_file_cache_valid 30s;
open_file_cache_min_uses 2;
open_file_cache_errors on;
# Buffer sizes
client_body_buffer_size 128k;
client_header_buffer_size 16k;
client_max_body_size 10m;
large_client_header_buffers 4 16k;
# Timeouts
client_body_timeout 12s;
client_header_timeout 12s;
send_timeout 10s;
# Gzip compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 256;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/json
application/javascript
application/xml+rss
application/atom+xml
font/truetype
font/opentype
application/vnd.ms-fontobject
image/svg+xml;
server {
listen 80;
listen [::]:80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
# WebSocket to backend
location /ws {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 75s;
proxy_buffering off;
}
# API routes with rate limiting
location /api {
limit_req zone=api_limit burst=20 nodelay;
limit_conn conn_limit 20;
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering on;
proxy_buffers 8 24k;
proxy_buffer_size 2k;
proxy_connect_timeout 75s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
}
# Static assets - aggressive caching
location ~* \.(jpg|jpeg|png|gif|ico|svg|webp|avif)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
location ~* \.(css|js)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
location ~* \.(woff|woff2|ttf|eot|otf)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# index.html - never cache
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
# Deny hidden files
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
}
}

View File

@ -0,0 +1,147 @@
# ©AngelaMos | 2025
# Development Docker Compose
services:
postgres:
image: postgres:16-alpine
container_name: chat-postgres-dev
environment:
POSTGRES_DB: ${POSTGRES_DB:-chat_auth}
POSTGRES_USER: ${POSTGRES_USER:-chat_user}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD required}
POSTGRES_INITDB_ARGS: "-E UTF8 --locale=C"
volumes:
- postgres_dev_data:/var/lib/postgresql/data
ports:
- "${POSTGRES_HOST_PORT:-5432}:5432"
networks:
- chat_network_dev
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-chat_user} -d ${POSTGRES_DB:-chat_auth}"]
interval: 10s
timeout: 5s
retries: 5
surrealdb:
image: surrealdb/surrealdb:latest
container_name: chat-surrealdb-dev
user: "0:0"
command: start --log trace --user root --pass ${SURREAL_PASSWORD:?SURREAL_PASSWORD required} file:/data/database.db
environment:
- SURREAL_USER=root
- SURREAL_PASS=${SURREAL_PASSWORD}
volumes:
- surreal_dev_data:/data
ports:
- "${SURREAL_HOST_PORT:-8001}:8000"
networks:
- chat_network_dev
healthcheck:
test: ["CMD", "/surreal", "isready", "--endpoint", "http://localhost:8000"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:8-alpine
container_name: chat-redis-dev
command: >
redis-server
--appendonly yes
--maxmemory 2gb
--maxmemory-policy allkeys-lru
--save 60 1000
--loglevel warning
volumes:
- redis_dev_data:/data
ports:
- "${REDIS_HOST_PORT:-6379}:6379"
networks:
- chat_network_dev
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
backend:
build:
context: .
dockerfile: conf/docker/dev/fastapi.docker
container_name: chat-backend-dev
environment:
ENV: ${ENV:-development}
DEBUG: ${DEBUG:-true}
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY required}
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-chat_user}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-chat_auth}
SURREAL_URL: ${SURREAL_URL:-ws://surrealdb:8000}
SURREAL_PASSWORD: ${SURREAL_PASSWORD}
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
RP_ID: ${RP_ID:-localhost}
RP_NAME: ${RP_NAME:-Encrypted P2P Chat}
RP_ORIGIN: ${RP_ORIGIN:-http://localhost:82}
CORS_ORIGINS: ${CORS_ORIGINS}
volumes:
- ./backend:/app
- /app/.venv
- /app/__pycache__
depends_on:
postgres:
condition: service_healthy
surrealdb:
condition: service_healthy
redis:
condition: service_healthy
networks:
- chat_network_dev
ports:
- "${BACKEND_HOST_PORT:-8000}:8000"
restart: unless-stopped
frontend:
build:
context: .
dockerfile: conf/docker/dev/vite.docker
container_name: chat-frontend-dev
environment:
- VITE_API_URL=${VITE_API_URL:-http://localhost:8000}
- VITE_WS_URL=${VITE_WS_URL:-ws://localhost:8000}
- VITE_RP_ID=${VITE_RP_ID:-localhost}
volumes:
- ./frontend:/app
- /app/node_modules
depends_on:
- backend
networks:
- chat_network_dev
ports:
- "${FRONTEND_DEV_PORT:-5173}:5173"
restart: unless-stopped
nginx:
image: nginx:alpine
container_name: chat-nginx-dev
ports:
- "${NGINX_HTTP_PORT:-80}:80"
volumes:
- ./conf/nginx/dev.nginx:/etc/nginx/nginx.conf:ro
- ./conf/nginx/http.conf:/etc/nginx/http.conf:ro
depends_on:
- backend
- frontend
networks:
- chat_network_dev
restart: unless-stopped
networks:
chat_network_dev:
driver: bridge
name: chat_network_dev
volumes:
postgres_dev_data:
name: chat_postgres_dev_data
surreal_dev_data:
name: chat_surreal_dev_data
redis_dev_data:
name: chat_redis_dev_data

View File

@ -0,0 +1,117 @@
# ©AngelaMos | 2025
# Production Docker Compose
services:
postgres:
image: postgres:16-alpine
container_name: chat-postgres
environment:
POSTGRES_DB: ${POSTGRES_DB:-chat_auth}
POSTGRES_USER: ${POSTGRES_USER:-chat_user}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD required}
POSTGRES_INITDB_ARGS: "-E UTF8 --locale=C"
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- chat_network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-chat_user}"]
interval: 10s
timeout: 5s
retries: 5
restart: always
surrealdb:
image: surrealdb/surrealdb:latest
container_name: chat-surrealdb
command: start --log trace --user root --pass ${SURREAL_PASSWORD:?SURREAL_PASSWORD required} file:/data/database.db
environment:
- SURREAL_USER=root
- SURREAL_PASS=${SURREAL_PASSWORD}
volumes:
- surreal_data:/data
networks:
- chat_network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 10s
timeout: 5s
retries: 5
restart: always
redis:
image: redis:8-alpine
container_name: chat-redis
command: >
redis-server
--appendonly yes
--maxmemory 2gb
--maxmemory-policy allkeys-lru
--save 60 1000
--loglevel warning
volumes:
- redis_data:/data
networks:
- chat_network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
restart: always
backend:
build:
context: .
dockerfile: conf/docker/prod/fastapi.docker
container_name: chat-backend
environment:
ENV: ${ENV:-production}
DEBUG: ${DEBUG:-false}
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY required}
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-chat_user}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-chat_auth}
SURREAL_URL: ws://surrealdb:8000
SURREAL_PASSWORD: ${SURREAL_PASSWORD}
REDIS_URL: redis://redis:6379
RP_ID: ${RP_ID:-localhost}
RP_NAME: ${RP_NAME:-Encrypted P2P Chat}
RP_ORIGIN: ${RP_ORIGIN:-https://localhost}
expose:
- "8000"
depends_on:
postgres:
condition: service_healthy
surrealdb:
condition: service_healthy
redis:
condition: service_healthy
networks:
- chat_network
restart: always
frontend:
build:
context: .
dockerfile: conf/docker/prod/vite.docker
container_name: chat-nginx
ports:
- "${NGINX_HTTP_PORT:-80}:80"
- "${NGINX_HTTPS_PORT:-443}:443"
depends_on:
- backend
networks:
- chat_network
restart: always
networks:
chat_network:
driver: bridge
name: chat_network
volumes:
postgres_data:
name: chat_postgres_data
surreal_data:
name: chat_surreal_data
redis_data:
name: chat_redis_data

View File

@ -0,0 +1,8 @@
node_modules
dist
dist-ssr
*.local
.env
.DS_Store
.context

View File

@ -0,0 +1,24 @@
# Dependencies
node_modules/
# Build outputs
dist/
build/
# Logs
*.log
npm-debug.log*
# Runtime data
pids
*.pid
*.seed
# Coverage directory used by tools like istanbul
coverage/
# Other
.DS_Store
.env*
.vscode/
.idea/

View File

@ -0,0 +1,9 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": false,
"printWidth": 95,
"tabWidth": 2,
"useTabs": false,
"plugins": ["prettier-plugin-tailwindcss"]
}

View File

@ -0,0 +1,50 @@
# Encrypted P2P Chat - Frontend
SolidJS frontend with Vite, TypeScript, and Tailwind CSS v4.
## Requirements
- **Node.js 20.19+ or 22.12+** (required for Vite 7)
## Tech Stack
- **SolidJS 1.9.10** - Fine-grained reactivity
- **TypeScript 5.9** - Type safety
- **Vite 7** - Build tool (ESM only, requires Node 20.19+)
- **Tailwind CSS v4.1** - Utility-first CSS (stable release)
- **@solidjs/router** - Client-side routing
- **@tanstack/solid-query 5.90** - Data fetching and caching
- **nanostores 1.1** - State management
## Development
```bash
npm install
npm run dev
```
Frontend runs on http://localhost:3000
## Build
```bash
npm run build
```
Output in `dist/` directory.
## SolidJS Routing
Unlike React Router, SolidJS uses:
- `<A>` component for navigation (not `<Link>`)
- `useNavigate()` for programmatic navigation
- `useParams()` for route parameters
- `useSearchParams()` for query parameters
## Environment Variables
Copy `.env.example` to `.env` and configure:
- `VITE_API_URL` - Backend API URL
- `VITE_WS_URL` - WebSocket URL
- `VITE_RP_ID` - WebAuthn Relying Party ID

View File

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

View File

@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="description" content="End-to-end encrypted P2P chat application" />
<title>Encrypted P2P Chat</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,43 @@
{
"name": "encrypted-p2p-chat-frontend",
"version": "1.0.0",
"description": "End-to-end encrypted P2P chat frontend with SolidJS",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"lint:eslint": "eslint .",
"lint:eslint:fix": "eslint . --fix",
"lint:types": "tsc --noEmit",
"lint": "npm run lint:eslint && npm run lint:types",
"format": "prettier --write \"src/**/*.{ts,tsx,css}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,css}\""
},
"dependencies": {
"@nanostores/persistent": "^1.2.0",
"@nanostores/solid": "^1.1.1",
"@solidjs/router": "^0.15.3",
"@tanstack/solid-query": "^5.90.13",
"nanostores": "^1.1.0",
"solid-js": "^1.9.10"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@tailwindcss/vite": "^4.1.17",
"@types/node": "^22.10.2",
"eslint": "^9.17.0",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-solid": "^0.14.5",
"globals": "^16.2.0",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.9",
"tailwindcss": "^4.1.17",
"typescript": "^5.9.3",
"typescript-eslint": "^8.20.0",
"vite": "^7.2.4",
"vite-plugin-solid": "^2.11.10"
}
}

View File

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

View File

@ -0,0 +1,61 @@
/**
* 8-bit styled auth card container
*/
import type { ParentProps, JSX } from "solid-js"
import { Show } from "solid-js"
interface AuthCardProps extends ParentProps {
title: string
subtitle?: string
}
export function AuthCard(props: AuthCardProps): JSX.Element {
return (
<div class="w-full max-w-md">
<div
class="
bg-black border-4 border-orange
shadow-[8px_8px_0_var(--color-orange)]
p-6
"
>
<div class="mb-6">
<div class="flex items-center gap-3 mb-4">
<LockIcon />
<h1 class="font-pixel text-lg text-orange uppercase">
{props.title}
</h1>
</div>
<Show when={props.subtitle}>
<p class="font-pixel text-[10px] text-gray leading-relaxed">
{props.subtitle}
</p>
</Show>
</div>
{props.children}
</div>
<div class="mt-4 flex justify-center">
<span class="font-pixel text-[8px] text-gray">
END-TO-END ENCRYPTED
</span>
</div>
</div>
)
}
function LockIcon(): JSX.Element {
return (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<rect x="7" y="4" width="10" height="2" fill="currentColor" class="text-orange" />
<rect x="5" y="6" width="2" height="5" fill="currentColor" class="text-orange" />
<rect x="17" y="6" width="2" height="5" fill="currentColor" class="text-orange" />
<rect x="4" y="11" width="16" height="2" fill="currentColor" class="text-orange" />
<rect x="4" y="13" width="16" height="8" fill="currentColor" class="text-orange" />
<rect x="11" y="15" width="2" height="4" fill="currentColor" class="text-black" />
</svg>
)
}

View File

@ -0,0 +1,177 @@
// ===================
// © AngelaMos | 2025
// AuthForm.tsx
// ===================
import { createSignal, Show } from "solid-js"
import type { JSX } from "solid-js"
import { A } from "@solidjs/router"
import { Input } from "../UI/Input"
import { PasskeyButton } from "./PasskeyButton"
import { AuthCard } from "./AuthCard"
import {
validateUsername,
validateDisplayName,
} from "../../lib/validators"
import { authService } from "../../services"
import { showToast } from "../../stores"
import { ApiError } from "../../types"
type AuthMode = "login" | "register"
interface AuthFormProps {
mode: AuthMode
}
export function AuthForm(props: AuthFormProps): JSX.Element {
const [username, setUsername] = createSignal("")
const [displayName, setDisplayName] = createSignal("")
const [loading, setLoading] = createSignal(false)
const [usernameError, setUsernameError] = createSignal<string | undefined>()
const [displayNameError, setDisplayNameError] = createSignal<string | undefined>()
const isRegister = (): boolean => props.mode === "register"
const title = (): string => isRegister() ? "CREATE ACCOUNT" : "WELCOME BACK"
const subtitle = (): string =>
isRegister()
? "REGISTER WITH A PASSKEY FOR SECURE, PASSWORDLESS AUTHENTICATION"
: "SIGN IN WITH YOUR PASSKEY TO CONTINUE"
const validateForm = (): boolean => {
let valid = true
if (isRegister()) {
const usernameResult = validateUsername(username())
if (!usernameResult.valid) {
setUsernameError(usernameResult.error)
valid = false
} else {
setUsernameError(undefined)
}
const displayNameResult = validateDisplayName(displayName())
if (!displayNameResult.valid) {
setDisplayNameError(displayNameResult.error)
valid = false
} else {
setDisplayNameError(undefined)
}
}
return valid
}
const handleSubmit = async (): Promise<void> => {
if (!validateForm()) {
return
}
setLoading(true)
try {
if (isRegister()) {
await authService.register(username(), displayName())
showToast("success", "REGISTRATION COMPLETE", "YOUR PASSKEY HAS BEEN CREATED")
} else {
const trimmedUsername = username().trim()
const usernameValue = trimmedUsername === "" ? undefined : trimmedUsername
await authService.login(usernameValue)
showToast("success", "LOGIN SUCCESSFUL", "WELCOME BACK")
}
} catch (error) {
let message = "AN UNEXPECTED ERROR OCCURRED"
if (error instanceof ApiError) {
message = error.message.toUpperCase()
} else if (error instanceof Error) {
if (error.name === "NotAllowedError") {
message = "PASSKEY OPERATION CANCELLED"
} else if (error.name === "InvalidStateError") {
message = "PASSKEY ALREADY REGISTERED"
} else {
message = error.message.toUpperCase()
}
}
showToast("error", isRegister() ? "REGISTRATION FAILED" : "LOGIN FAILED", message)
} finally {
setLoading(false)
}
}
return (
<AuthCard title={title()} subtitle={subtitle()}>
<div class="space-y-4">
<Show when={isRegister()}>
<Input
name="username"
label="USERNAME"
placeholder="ENTER USERNAME"
value={username()}
onInput={setUsername}
error={usernameError()}
required
disabled={loading()}
fullWidth
/>
<Input
name="displayName"
label="DISPLAY NAME"
placeholder="ENTER DISPLAY NAME"
value={displayName()}
onInput={setDisplayName}
error={displayNameError()}
required
disabled={loading()}
fullWidth
/>
</Show>
<Show when={!isRegister()}>
<Input
name="username"
label="USERNAME (OPTIONAL)"
placeholder="LEAVE BLANK FOR DISCOVERABLE"
value={username()}
onInput={setUsername}
hint="LEAVE BLANK TO USE DISCOVERABLE CREDENTIALS"
disabled={loading()}
fullWidth
/>
</Show>
<div class="pt-2">
<PasskeyButton
mode={props.mode}
onClick={handleSubmit}
loading={loading()}
/>
</div>
</div>
<div class="mt-6 pt-4 border-t-2 border-dark-gray">
<p class="font-pixel text-[10px] text-center text-gray">
<Show
when={isRegister()}
fallback={
<>
DON'T HAVE AN ACCOUNT?{" "}
<A href="/register" class="text-orange hover:underline">
REGISTER
</A>
</>
}
>
ALREADY HAVE AN ACCOUNT?{" "}
<A href="/login" class="text-orange hover:underline">
SIGN IN
</A>
</Show>
</p>
</div>
</AuthCard>
)
}

View File

@ -0,0 +1,95 @@
// ===================
// © AngelaMos | 2025
// PasskeyButton.tsx
// ===================
import {
Show,
createSignal,
onMount,
} from "solid-js"
import type { JSX } from "solid-js"
import { Button } from "../UI/Button"
import {
isWebAuthnSupported,
isPlatformAuthenticatorAvailable,
} from "../../services"
interface PasskeyButtonProps {
mode: "register" | "login"
onClick: () => void | Promise<void>
loading?: boolean
disabled?: boolean
class?: string
}
export function PasskeyButton(props: PasskeyButtonProps): JSX.Element {
const [webAuthnSupported, setWebAuthnSupported] = createSignal(true)
const [platformAvailable, setPlatformAvailable] = createSignal(false)
onMount(() => {
const supported = isWebAuthnSupported()
setWebAuthnSupported(supported)
if (supported) {
void isPlatformAuthenticatorAvailable().then((available) => {
setPlatformAvailable(available)
})
}
})
const buttonText = (): string => {
if (!webAuthnSupported()) {
return "WEBAUTHN NOT SUPPORTED"
}
return props.mode === "register" ? "REGISTER WITH PASSKEY" : "LOGIN WITH PASSKEY"
}
const isDisabled = (): boolean => {
return !webAuthnSupported() || (props.disabled ?? false)
}
return (
<div class={`flex flex-col gap-2 ${props.class ?? ""}`}>
<Button
variant="primary"
size="lg"
fullWidth
loading={props.loading}
disabled={isDisabled()}
onClick={props.onClick}
leftIcon={<PasskeyIcon />}
>
{buttonText()}
</Button>
<Show when={!webAuthnSupported()}>
<p class="font-pixel text-[8px] text-error text-center">
YOUR BROWSER DOES NOT SUPPORT PASSKEYS
</p>
</Show>
<Show when={webAuthnSupported() && platformAvailable()}>
<p class="font-pixel text-[8px] text-success text-center">
BIOMETRIC AUTH AVAILABLE
</p>
</Show>
</div>
)
}
function PasskeyIcon(): JSX.Element {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<rect x="2" y="6" width="2" height="6" />
<rect x="4" y="4" width="2" height="2" />
<rect x="6" y="2" width="4" height="2" />
<rect x="10" y="4" width="2" height="2" />
<rect x="12" y="6" width="2" height="2" />
<rect x="6" y="8" width="4" height="2" />
<rect x="4" y="10" width="2" height="2" />
<rect x="10" y="10" width="2" height="2" />
<rect x="6" y="12" width="4" height="2" />
</svg>
)
}

View File

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

View File

@ -0,0 +1,127 @@
// ===================
// © AngelaMos | 2025
// ChatHeader.tsx
// ===================
import { Show } from "solid-js"
import type { JSX } from "solid-js"
import type { Room, Participant } from "../../types"
import { OnlineStatus } from "./OnlineStatus"
import { EncryptionBadge } from "./EncryptionBadge"
import { getUserStatus } from "../../stores"
import { IconButton } from "../UI/IconButton"
interface ChatHeaderProps {
room: Room | null
onSettingsClick?: () => void
onInfoClick?: () => void
class?: string
}
export function ChatHeader(props: ChatHeaderProps): JSX.Element {
const otherParticipant = (): Participant | null => {
if (props.room?.type !== "direct") return null
return props.room.participants[0] ?? null
}
const displayName = (): string => {
const room = props.room
if (room === null) return "CHAT"
if (room.name !== undefined && room.name !== null) return room.name
const other = otherParticipant()
return other?.display_name ?? other?.username ?? "CHAT"
}
const initials = (): string => {
const name = displayName()
return name.slice(0, 2).toUpperCase()
}
return (
<div class={`flex-shrink-0 px-4 py-3 border-b-2 border-orange ${props.class ?? ""}`}>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="relative">
<div class="w-10 h-10 bg-black border-2 border-orange flex items-center justify-center">
<span class="font-pixel text-[10px] text-orange">
{initials()}
</span>
</div>
<Show when={otherParticipant()} keyed>
{(participant) => (
<div class="absolute -bottom-1 -right-1">
<OnlineStatus
status={getUserStatus(participant.user_id)}
size="sm"
/>
</div>
)}
</Show>
</div>
<div>
<h2 class="font-pixel text-xs text-white">
{displayName()}
</h2>
<div class="flex items-center gap-2 mt-0.5">
<Show when={props.room?.is_encrypted}>
<EncryptionBadge isEncrypted showLabel size="sm" />
</Show>
<Show when={props.room?.type === "group"}>
<span class="font-pixel text-[8px] text-gray">
{props.room?.participants.length ?? 0} MEMBERS
</span>
</Show>
<Show when={otherParticipant()} keyed>
{(participant) => (
<span class="font-pixel text-[8px] text-gray uppercase">
{getUserStatus(participant.user_id)}
</span>
)}
</Show>
</div>
</div>
</div>
<div class="flex items-center gap-2">
<Show when={props.onInfoClick}>
<IconButton
icon={<InfoIcon />}
onClick={props.onInfoClick}
ariaLabel="Room info"
size="sm"
/>
</Show>
<Show when={props.onSettingsClick}>
<IconButton
icon={<SettingsIcon />}
onClick={props.onSettingsClick}
ariaLabel="Room settings"
size="sm"
/>
</Show>
</div>
</div>
</div>
)
}
function InfoIcon(): JSX.Element {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<rect x="7" y="3" width="2" height="2" />
<rect x="7" y="7" width="2" height="6" />
</svg>
)
}
function SettingsIcon(): JSX.Element {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<rect x="7" y="1" width="2" height="3" />
<rect x="7" y="12" width="2" height="3" />
<rect x="1" y="7" width="3" height="2" />
<rect x="12" y="7" width="3" height="2" />
<rect x="5" y="5" width="6" height="6" />
</svg>
)
}

View File

@ -0,0 +1,132 @@
// ===================
// © AngelaMos | 2025
// ChatInput.tsx
// ===================
import { createSignal, Show, onCleanup } from "solid-js"
import type { JSX } from "solid-js"
import { Button } from "../UI/Button"
import { wsManager } from "../../websocket"
import { MESSAGE_MAX_LENGTH } from "../../types"
interface ChatInputProps {
roomId: string
recipientId: string
disabled?: boolean
onSend?: (content: string) => void
class?: string
}
export function ChatInput(props: ChatInputProps): JSX.Element {
const [message, setMessage] = createSignal("")
const [isTyping, setIsTyping] = createSignal(false)
let typingTimeout: ReturnType<typeof setTimeout> | undefined
let inputRef: HTMLInputElement | undefined
const charCount = (): number => message().length
const isOverLimit = (): boolean => charCount() > MESSAGE_MAX_LENGTH
const canSend = (): boolean => message().trim().length > 0 && !isOverLimit() && (props.disabled !== true)
const handleInput = (e: Event): void => {
const target = e.target as HTMLInputElement
setMessage(target.value)
if (!isTyping()) {
setIsTyping(true)
wsManager.sendTypingIndicator(props.roomId, true)
}
if (typingTimeout !== undefined) {
clearTimeout(typingTimeout)
}
typingTimeout = setTimeout(() => {
setIsTyping(false)
wsManager.sendTypingIndicator(props.roomId, false)
}, 2000)
}
const handleSend = (): void => {
if (!canSend()) return
const content = message().trim()
props.onSend?.(content)
setMessage("")
if (isTyping()) {
setIsTyping(false)
wsManager.sendTypingIndicator(props.roomId, false)
}
inputRef?.focus()
}
const handleKeyDown = (e: KeyboardEvent): void => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
onCleanup(() => {
if (typingTimeout !== undefined) {
clearTimeout(typingTimeout)
}
if (isTyping()) {
wsManager.sendTypingIndicator(props.roomId, false)
}
})
return (
<div class={`flex-shrink-0 p-4 border-t-2 border-orange ${props.class ?? ""}`}>
<div class="flex gap-2">
<div class="flex-1 flex items-center bg-black border-2 border-orange">
<span class="font-pixel text-[10px] text-orange px-2">&gt;&gt;&gt;</span>
<input
ref={inputRef}
type="text"
value={message()}
onInput={handleInput}
onKeyDown={handleKeyDown}
placeholder="TYPE YOUR MESSAGE..."
disabled={props.disabled}
maxLength={MESSAGE_MAX_LENGTH + 100}
class="flex-1 bg-transparent font-pixel text-[10px] text-white py-2 pr-3 focus:outline-none placeholder:text-gray disabled:opacity-50"
/>
</div>
<Button
variant="primary"
size="md"
onClick={handleSend}
disabled={!canSend()}
leftIcon={<SendIcon />}
>
SEND
</Button>
</div>
<div class="flex items-center justify-between mt-2">
<Show when={isOverLimit()}>
<span class="font-pixel text-[8px] text-error">
MESSAGE TOO LONG
</span>
</Show>
<span class={`font-pixel text-[8px] ml-auto ${isOverLimit() ? "text-error" : "text-gray"}`}>
{charCount()}/{MESSAGE_MAX_LENGTH}
</span>
</div>
</div>
)
}
function SendIcon(): JSX.Element {
return (
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
<rect x="0" y="5" width="8" height="2" />
<rect x="6" y="3" width="2" height="2" />
<rect x="6" y="7" width="2" height="2" />
<rect x="8" y="1" width="2" height="4" />
<rect x="8" y="7" width="2" height="4" />
<rect x="10" y="3" width="2" height="6" />
</svg>
)
}

View File

@ -0,0 +1,115 @@
// ===================
// © AngelaMos | 2025
// ConversationItem.tsx
// ===================
import { Show } from "solid-js"
import type { JSX } from "solid-js"
import type { Room, Participant } from "../../types"
import { OnlineStatus } from "./OnlineStatus"
import { EncryptionBadge } from "./EncryptionBadge"
import { getUserStatus } from "../../stores"
import { formatRelativeTime } from "../../lib/date"
interface ConversationItemProps {
room: Room
isActive: boolean
onClick: () => void
class?: string
}
export function ConversationItem(props: ConversationItemProps): JSX.Element {
const otherParticipant = (): Participant | null => {
if (props.room.type !== "direct") return null
return props.room.participants[0] ?? null
}
const displayName = (): string => {
if (props.room.name) return props.room.name
const other = otherParticipant()
return other?.display_name ?? other?.username ?? "CHAT"
}
const initials = (): string => {
const name = displayName()
return name.slice(0, 2).toUpperCase()
}
const lastMessagePreview = (): string => {
const msg = props.room.last_message
if (msg === null || msg === undefined) return "NO MESSAGES"
if (msg.is_encrypted) return "ENCRYPTED MESSAGE"
const content = msg.content.slice(0, 40)
return content.length < msg.content.length ? `${content}...` : content
}
const lastMessageTime = (): string => {
const msg = props.room.last_message
if (msg === null || msg === undefined) return ""
return formatRelativeTime(msg.created_at)
}
const containerClasses = (): string => {
const base = "w-full p-3 border-2 transition-colors cursor-pointer"
if (props.isActive) {
return `${base} bg-orange text-black border-orange`
}
return `${base} bg-black text-white border-dark-gray hover:border-orange`
}
return (
<button
type="button"
class={`${containerClasses()} ${props.class ?? ""}`}
onClick={() => props.onClick()}
>
<div class="flex items-start gap-3">
<div class="relative flex-shrink-0">
<div class={`w-10 h-10 border-2 flex items-center justify-center ${props.isActive ? "border-black" : "border-orange"}`}>
<span class={`font-pixel text-[10px] ${props.isActive ? "text-black" : "text-orange"}`}>
{initials()}
</span>
</div>
<Show when={otherParticipant()} keyed>
{(participant) => (
<div class="absolute -bottom-1 -right-1">
<OnlineStatus
status={getUserStatus(participant.user_id)}
size="sm"
/>
</div>
)}
</Show>
</div>
<div class="flex-1 min-w-0 text-left">
<div class="flex items-center justify-between gap-2">
<span class={`font-pixel text-[10px] truncate ${props.isActive ? "text-black" : "text-white"}`}>
{displayName()}
</span>
<span class={`font-pixel text-[8px] flex-shrink-0 ${props.isActive ? "text-black/60" : "text-gray"}`}>
{lastMessageTime()}
</span>
</div>
<div class="flex items-center justify-between gap-2 mt-1">
<span class={`font-pixel text-[8px] truncate ${props.isActive ? "text-black/80" : "text-gray"}`}>
{lastMessagePreview()}
</span>
<div class="flex items-center gap-1 flex-shrink-0">
<Show when={props.room.is_encrypted}>
<EncryptionBadge isEncrypted size="sm" />
</Show>
<Show when={props.room.unread_count > 0}>
<div class="min-w-[18px] h-[18px] bg-orange flex items-center justify-center">
<span class="font-pixel text-[8px] text-black">
{props.room.unread_count > 99 ? "99+" : props.room.unread_count}
</span>
</div>
</Show>
</div>
</div>
</div>
</div>
</button>
)
}

View File

@ -0,0 +1,136 @@
// ===================
// © AngelaMos | 2025
// ConversationList.tsx
// ===================
import { Show, For, createMemo } from "solid-js"
import type { JSX } from "solid-js"
import { useStore } from "@nanostores/solid"
import { $rooms, $activeRoomId, setActiveRoom } from "../../stores"
import { ConversationItem } from "./ConversationItem"
import { Spinner } from "../UI/Spinner"
import type { Room } from "../../types"
interface ConversationListProps {
loading?: boolean
onNewChat?: () => void
class?: string
}
export function ConversationList(props: ConversationListProps): JSX.Element {
const rooms = useStore($rooms)
const activeRoomId = useStore($activeRoomId)
const sortedRooms = createMemo((): Room[] => {
const roomsMap = rooms()
const roomList: Room[] = Object.values(roomsMap)
return roomList.sort((a, b) => {
const aTime = a.last_message?.created_at ?? a.updated_at
const bTime = b.last_message?.created_at ?? b.updated_at
return new Date(bTime).getTime() - new Date(aTime).getTime()
})
})
const handleRoomClick = (roomId: string): void => {
setActiveRoom(roomId)
}
return (
<div class={`flex flex-col h-full ${props.class ?? ""}`}>
<div class="flex items-center justify-between px-4 py-3 border-b-2 border-dark-gray">
<h2 class="font-pixel text-[10px] text-orange">
CONVERSATIONS
</h2>
<Show when={props.onNewChat}>
<button
type="button"
onClick={() => props.onNewChat?.()}
class="w-6 h-6 flex items-center justify-center border-2 border-orange text-orange hover:bg-orange hover:text-black transition-colors"
aria-label="New conversation"
>
<PlusIcon />
</button>
</Show>
</div>
<div class="flex-1 overflow-y-auto scrollbar-pixel">
<Show
when={props.loading !== true}
fallback={
<div class="flex items-center justify-center py-8">
<Spinner size="md" />
</div>
}
>
<Show
when={sortedRooms().length > 0}
fallback={<EmptyConversations onNewChat={props.onNewChat} />}
>
<div class="p-2 space-y-2">
<For each={sortedRooms()}>
{(room) => (
<ConversationItem
room={room}
isActive={room.id === activeRoomId()}
onClick={() => handleRoomClick(room.id)}
/>
)}
</For>
</div>
</Show>
</Show>
</div>
</div>
)
}
interface EmptyConversationsProps {
onNewChat?: () => void
}
function EmptyConversations(props: EmptyConversationsProps): JSX.Element {
return (
<div class="flex flex-col items-center justify-center py-12 px-4">
<ChatIcon />
<p class="font-pixel text-[10px] text-gray mt-4 text-center">
NO CONVERSATIONS
</p>
<p class="font-pixel text-[8px] text-gray mt-1 text-center">
START A NEW CHAT TO BEGIN
</p>
<Show when={props.onNewChat}>
<button
type="button"
onClick={() => props.onNewChat?.()}
class="mt-4 px-4 py-2 border-2 border-orange text-orange font-pixel text-[10px] hover:bg-orange hover:text-black transition-colors"
>
NEW CHAT
</button>
</Show>
</div>
)
}
function PlusIcon(): JSX.Element {
return (
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
<rect x="5" y="1" width="2" height="10" />
<rect x="1" y="5" width="10" height="2" />
</svg>
)
}
function ChatIcon(): JSX.Element {
return (
<svg width="40" height="40" viewBox="0 0 40 40" fill="currentColor" class="text-dark-gray">
<rect x="6" y="6" width="28" height="3" />
<rect x="3" y="9" width="3" height="20" />
<rect x="34" y="9" width="3" height="20" />
<rect x="6" y="29" width="10" height="3" />
<rect x="24" y="29" width="10" height="3" />
<rect x="16" y="32" width="3" height="3" />
<rect x="13" y="35" width="3" height="3" />
<rect x="10" y="14" width="20" height="2" />
<rect x="10" y="20" width="14" height="2" />
</svg>
)
}

View File

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

View File

@ -0,0 +1,155 @@
// ===================
// © AngelaMos | 2025
// MessageBubble.tsx
// ===================
import { Show, Switch, Match } from "solid-js"
import type { JSX } from "solid-js"
import type { Message, MessageStatus } from "../../types"
import { EncryptionBadge } from "./EncryptionBadge"
import { formatTime } from "../../lib/date"
interface MessageBubbleProps {
message: Message
isOwnMessage: boolean
showSender?: boolean
class?: string
}
export function MessageBubble(props: MessageBubbleProps): JSX.Element {
const bubbleClasses = (): string => {
const base = "max-w-[80%] p-3"
if (props.isOwnMessage) {
return `${base} bg-orange text-black ml-auto`
}
return `${base} bg-black border-2 border-orange text-white`
}
return (
<div class={`flex ${props.isOwnMessage ? "justify-end" : "justify-start"} ${props.class ?? ""}`}>
<div class={bubbleClasses()}>
<Show when={props.showSender === true && !props.isOwnMessage}>
<div class="font-pixel text-[8px] text-orange mb-1">
{props.message.sender_username}
</div>
</Show>
<div class="font-pixel text-[10px] break-words whitespace-pre-wrap">
{props.message.content}
</div>
<div class="flex items-center justify-end gap-2 mt-2">
<Show when={props.message.is_encrypted}>
<EncryptionBadge isEncrypted size="sm" />
</Show>
<span class={`font-pixel text-[8px] ${props.isOwnMessage ? "text-black/60" : "text-gray"}`}>
{formatTime(props.message.created_at)}
</span>
<Show when={props.isOwnMessage}>
<MessageStatusIcon status={props.message.status} />
</Show>
</div>
</div>
</div>
)
}
interface MessageStatusIconProps {
status: MessageStatus
}
function MessageStatusIcon(props: MessageStatusIconProps): JSX.Element {
return (
<Switch fallback={<></>}>
<Match when={props.status === "sending"}>
<ClockIcon class="w-3 h-3 text-black/40" />
</Match>
<Match when={props.status === "sent"}>
<CheckIcon class="w-3 h-3 text-black/60" />
</Match>
<Match when={props.status === "delivered"}>
<DoubleCheckIcon class="w-3 h-3 text-black/60" />
</Match>
<Match when={props.status === "read"}>
<DoubleCheckIcon class="w-3 h-3 text-success" />
</Match>
<Match when={props.status === "failed"}>
<ErrorIcon class="w-3 h-3 text-error" />
</Match>
</Switch>
)
}
interface IconProps {
class?: string
}
function ClockIcon(props: IconProps): JSX.Element {
return (
<svg viewBox="0 0 12 12" fill="currentColor" class={props.class}>
<rect x="5" y="1" width="2" height="1" />
<rect x="3" y="2" width="2" height="1" />
<rect x="7" y="2" width="2" height="1" />
<rect x="2" y="3" width="1" height="2" />
<rect x="9" y="3" width="1" height="2" />
<rect x="1" y="5" width="1" height="2" />
<rect x="10" y="5" width="1" height="2" />
<rect x="2" y="7" width="1" height="2" />
<rect x="9" y="7" width="1" height="2" />
<rect x="3" y="9" width="2" height="1" />
<rect x="7" y="9" width="2" height="1" />
<rect x="5" y="10" width="2" height="1" />
<rect x="5" y="3" width="2" height="3" />
<rect x="5" y="5" width="3" height="2" />
</svg>
)
}
function CheckIcon(props: IconProps): JSX.Element {
return (
<svg viewBox="0 0 12 12" fill="currentColor" class={props.class}>
<rect x="2" y="6" width="2" height="2" />
<rect x="4" y="8" width="2" height="2" />
<rect x="6" y="6" width="2" height="2" />
<rect x="8" y="4" width="2" height="2" />
<rect x="10" y="2" width="2" height="2" />
</svg>
)
}
function DoubleCheckIcon(props: IconProps): JSX.Element {
return (
<svg viewBox="0 0 16 12" fill="currentColor" class={props.class}>
<rect x="0" y="6" width="2" height="2" />
<rect x="2" y="8" width="2" height="2" />
<rect x="4" y="6" width="2" height="2" />
<rect x="6" y="4" width="2" height="2" />
<rect x="8" y="2" width="2" height="2" />
<rect x="4" y="6" width="2" height="2" />
<rect x="6" y="8" width="2" height="2" />
<rect x="8" y="6" width="2" height="2" />
<rect x="10" y="4" width="2" height="2" />
<rect x="12" y="2" width="2" height="2" />
</svg>
)
}
function ErrorIcon(props: IconProps): JSX.Element {
return (
<svg viewBox="0 0 12 12" fill="currentColor" class={props.class}>
<rect x="5" y="1" width="2" height="1" />
<rect x="3" y="2" width="2" height="1" />
<rect x="7" y="2" width="2" height="1" />
<rect x="2" y="3" width="1" height="2" />
<rect x="9" y="3" width="1" height="2" />
<rect x="1" y="5" width="1" height="2" />
<rect x="10" y="5" width="1" height="2" />
<rect x="2" y="7" width="1" height="2" />
<rect x="9" y="7" width="1" height="2" />
<rect x="3" y="9" width="2" height="1" />
<rect x="7" y="9" width="2" height="1" />
<rect x="5" y="10" width="2" height="1" />
<rect x="5" y="3" width="2" height="4" />
<rect x="5" y="8" width="2" height="1" />
</svg>
)
}

View File

@ -0,0 +1,174 @@
// ===================
// © AngelaMos | 2025
// MessageList.tsx
// ===================
import { Show, For, createEffect, createSignal, onMount } from "solid-js"
import type { JSX } from "solid-js"
import { useStore } from "@nanostores/solid"
import { $activeRoomMessages, $activeRoomPendingMessages } from "../../stores"
import { $userId } from "../../stores"
import { MessageBubble } from "./MessageBubble"
import { TypingIndicator } from "./TypingIndicator"
import { Spinner } from "../UI/Spinner"
import type { Message } from "../../types"
interface MessageListProps {
roomId: string
onLoadMore?: () => void
hasMore?: boolean
loading?: boolean
class?: string
}
export function MessageList(props: MessageListProps): JSX.Element {
let containerRef: HTMLDivElement | undefined
const [autoScroll, setAutoScroll] = createSignal(true)
const messages = useStore($activeRoomMessages)
const pendingMessages = useStore($activeRoomPendingMessages)
const userId = useStore($userId)
const allMessages = (): Message[] => {
return [...messages(), ...pendingMessages()]
}
const handleScroll = (): void => {
if (containerRef === undefined) return
const { scrollTop, scrollHeight, clientHeight } = containerRef
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50
setAutoScroll(isAtBottom)
if (scrollTop < 100 && props.hasMore === true && props.loading !== true && props.onLoadMore !== undefined) {
props.onLoadMore()
}
}
createEffect(() => {
allMessages()
if (autoScroll() && containerRef !== undefined) {
containerRef.scrollTop = containerRef.scrollHeight
}
})
onMount(() => {
if (containerRef !== undefined) {
containerRef.scrollTop = containerRef.scrollHeight
}
})
const groupMessagesByDate = (msgs: Message[]): Map<string, Message[]> => {
const groups = new Map<string, Message[]>()
for (const msg of msgs) {
const date = new Date(msg.created_at).toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
})
const existing = groups.get(date) ?? []
groups.set(date, [...existing, msg])
}
return groups
}
return (
<div
ref={containerRef}
class={`flex-1 overflow-y-auto scrollbar-pixel p-4 ${props.class ?? ""}`}
onScroll={handleScroll}
>
<Show when={props.loading}>
<div class="flex justify-center py-4">
<Spinner size="sm" />
</div>
</Show>
<Show
when={allMessages().length > 0}
fallback={<EmptyMessages />}
>
<For each={Array.from(groupMessagesByDate(allMessages()).entries())}>
{([date, dateMessages]) => (
<div class="mb-6">
<DateSeparator date={date} />
<div class="space-y-3">
<For each={dateMessages}>
{(message, index) => {
const prevMessage = (): Message | undefined =>
index() > 0 ? dateMessages[index() - 1] : undefined
const showSender = (): boolean => {
const prev = prevMessage()
return prev === undefined || prev.sender_id !== message.sender_id
}
return (
<MessageBubble
message={message}
isOwnMessage={message.sender_id === userId()}
showSender={showSender()}
/>
)
}}
</For>
</div>
</div>
)}
</For>
</Show>
<div class="h-6">
<TypingIndicator />
</div>
</div>
)
}
function EmptyMessages(): JSX.Element {
return (
<div class="h-full flex flex-col items-center justify-center py-12">
<MessageIcon />
<p class="font-pixel text-[10px] text-gray mt-4">
NO MESSAGES YET
</p>
<p class="font-pixel text-[8px] text-gray mt-1">
START THE CONVERSATION
</p>
</div>
)
}
interface DateSeparatorProps {
date: string
}
function DateSeparator(props: DateSeparatorProps): JSX.Element {
return (
<div class="flex items-center gap-4 my-4">
<div class="flex-1 h-px bg-dark-gray" />
<span class="font-pixel text-[8px] text-gray uppercase">
{props.date}
</span>
<div class="flex-1 h-px bg-dark-gray" />
</div>
)
}
function MessageIcon(): JSX.Element {
return (
<svg width="48" height="48" viewBox="0 0 48 48" fill="currentColor" class="text-dark-gray">
<rect x="8" y="8" width="32" height="4" />
<rect x="4" y="12" width="4" height="24" />
<rect x="40" y="12" width="4" height="24" />
<rect x="8" y="36" width="12" height="4" />
<rect x="28" y="36" width="12" height="4" />
<rect x="20" y="40" width="4" height="4" />
<rect x="16" y="44" width="4" height="4" />
<rect x="12" y="18" width="24" height="2" />
<rect x="12" y="24" width="16" height="2" />
<rect x="12" y="30" width="20" height="2" />
</svg>
)
}

View File

@ -0,0 +1,141 @@
// ===================
// © AngelaMos | 2025
// NewConversation.tsx
// ===================
import { createSignal, Show } from "solid-js"
import type { JSX } from "solid-js"
import { Modal } from "../UI/Modal"
import { Button } from "../UI/Button"
import { UserSearch } from "./UserSearch"
import type { User } from "../../types"
import { showToast } from "../../stores"
interface NewConversationProps {
isOpen: boolean
onClose: () => void
onCreateRoom: (userId: string) => Promise<void>
}
export function NewConversation(props: NewConversationProps): JSX.Element {
const [selectedUser, setSelectedUser] = createSignal<User | null>(null)
const [loading, setLoading] = createSignal(false)
const handleUserSelect = (user: User): void => {
setSelectedUser(user)
}
const handleCreate = async (): Promise<void> => {
const user = selectedUser()
if (user === null) return
setLoading(true)
try {
await props.onCreateRoom(user.id)
showToast("success", "CHAT CREATED", `STARTED CONVERSATION WITH ${user.display_name.toUpperCase()}`)
handleClose()
} catch (_error: unknown) {
showToast("error", "FAILED TO CREATE CHAT", "PLEASE TRY AGAIN")
} finally {
setLoading(false)
}
}
const handleClose = (): void => {
setSelectedUser(null)
setLoading(false)
props.onClose()
}
return (
<Modal
isOpen={props.isOpen}
onClose={handleClose}
title="NEW CONVERSATION"
size="md"
>
<div class="space-y-4">
<div>
<label class="font-pixel text-[10px] text-gray block mb-2">
SEARCH FOR A USER
</label>
<UserSearch
onSelect={handleUserSelect}
placeholder="ENTER USERNAME..."
/>
</div>
<Show when={selectedUser()} keyed>
{(user) => (
<div class="p-3 border-2 border-orange">
<p class="font-pixel text-[8px] text-gray mb-2">SELECTED USER</p>
<div class="flex items-center gap-3">
<div class="w-10 h-10 border-2 border-orange flex items-center justify-center">
<span class="font-pixel text-[10px] text-orange">
{user.display_name.slice(0, 2).toUpperCase()}
</span>
</div>
<div>
<div class="font-pixel text-[10px] text-white">
{user.display_name}
</div>
<div class="font-pixel text-[8px] text-gray">
@{user.username}
</div>
</div>
<button
type="button"
onClick={() => setSelectedUser(null)}
class="ml-auto w-6 h-6 flex items-center justify-center border-2 border-gray text-gray hover:border-error hover:text-error transition-colors"
aria-label="Remove selection"
>
<CloseIcon />
</button>
</div>
</div>
)}
</Show>
<div class="flex items-center gap-3 pt-4 border-t-2 border-dark-gray">
<Button
variant="secondary"
size="md"
onClick={handleClose}
fullWidth
>
CANCEL
</Button>
<Button
variant="primary"
size="md"
onClick={() => void handleCreate()}
disabled={selectedUser() === null || loading()}
loading={loading()}
fullWidth
>
START CHAT
</Button>
</div>
</div>
</Modal>
)
}
function CloseIcon(): JSX.Element {
return (
<svg width="10" height="10" viewBox="0 0 10 10" fill="currentColor">
<rect x="1" y="2" width="2" height="2" />
<rect x="2" y="3" width="2" height="2" />
<rect x="3" y="4" width="2" height="2" />
<rect x="4" y="3" width="2" height="2" />
<rect x="5" y="2" width="2" height="2" />
<rect x="6" y="3" width="2" height="2" />
<rect x="7" y="2" width="2" height="2" />
<rect x="4" y="5" width="2" height="2" />
<rect x="3" y="6" width="2" height="2" />
<rect x="2" y="7" width="2" height="2" />
<rect x="5" y="6" width="2" height="2" />
<rect x="6" y="7" width="2" height="2" />
</svg>
)
}

View File

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

View File

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

View File

@ -0,0 +1,176 @@
// ===================
// © AngelaMos | 2025
// UserSearch.tsx
// ===================
import { createSignal, Show, For, onCleanup } from "solid-js"
import type { JSX } from "solid-js"
import { Input } from "../UI/Input"
import { Spinner } from "../UI/Spinner"
import { api } from "../../lib/api-client"
import { USER_SEARCH_MIN_LENGTH, USER_SEARCH_DEFAULT_LIMIT } from "../../config"
import type { User } from "../../types"
interface UserSearchProps {
onSelect: (user: User) => void
excludeIds?: string[]
placeholder?: string
class?: string
}
interface SearchResult {
users: User[]
loading: boolean
error: string | null
}
export function UserSearch(props: UserSearchProps): JSX.Element {
const [query, setQuery] = createSignal("")
const [result, setResult] = createSignal<SearchResult>({
users: [],
loading: false,
error: null,
})
const [isFocused, setIsFocused] = createSignal(false)
let searchTimeout: ReturnType<typeof setTimeout> | undefined
const handleInput = (value: string): void => {
setQuery(value)
if (searchTimeout !== undefined) {
clearTimeout(searchTimeout)
}
if (value.trim().length < USER_SEARCH_MIN_LENGTH) {
setResult({ users: [], loading: false, error: null })
return
}
setResult((prev) => ({ ...prev, loading: true }))
searchTimeout = setTimeout(() => {
void searchUsers(value.trim())
}, 300)
}
const searchUsers = async (searchQuery: string): Promise<void> => {
try {
const response = await api.users.search({
query: searchQuery,
limit: USER_SEARCH_DEFAULT_LIMIT,
})
setResult({ users: response.users, loading: false, error: null })
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Search failed"
setResult({ users: [], loading: false, error: errorMessage })
}
}
const handleSelect = (user: User): void => {
props.onSelect(user)
setQuery("")
setResult({ users: [], loading: false, error: null })
setIsFocused(false)
}
const filteredUsers = (): User[] => {
const excludeSet = new Set(props.excludeIds ?? [])
return result().users.filter((u) => !excludeSet.has(u.id))
}
const showResults = (): boolean => {
return isFocused() && (result().loading || filteredUsers().length > 0 || result().error !== null)
}
onCleanup(() => {
if (searchTimeout !== undefined) {
clearTimeout(searchTimeout)
}
})
return (
<div class={`relative ${props.class ?? ""}`}>
<Input
name="user-search"
placeholder={props.placeholder ?? "SEARCH USERS..."}
value={query()}
onInput={handleInput}
onFocus={() => setIsFocused(true)}
onBlur={() => setTimeout(() => setIsFocused(false), 200)}
fullWidth
leftIcon={<SearchIcon />}
/>
<Show when={showResults()}>
<div class="absolute top-full left-0 right-0 mt-1 bg-black border-2 border-orange max-h-64 overflow-y-auto scrollbar-pixel z-50">
<Show when={result().loading}>
<div class="flex items-center justify-center py-4">
<Spinner size="sm" />
</div>
</Show>
<Show when={result().error}>
<div class="p-3">
<span class="font-pixel text-[10px] text-error">
{result().error}
</span>
</div>
</Show>
<Show when={!result().loading && !result().error}>
<For each={filteredUsers()}>
{(user) => (
<button
type="button"
class="w-full p-3 flex items-center gap-3 hover:bg-orange hover:text-black transition-colors"
onClick={() => handleSelect(user)}
>
<div class="w-8 h-8 border-2 border-orange flex items-center justify-center flex-shrink-0">
<span class="font-pixel text-[8px] text-orange">
{user.display_name.slice(0, 2).toUpperCase()}
</span>
</div>
<div class="text-left min-w-0">
<div class="font-pixel text-[10px] truncate">
{user.display_name}
</div>
<div class="font-pixel text-[8px] text-gray truncate">
@{user.username}
</div>
</div>
</button>
)}
</For>
</Show>
<Show when={!result().loading && !result().error && filteredUsers().length === 0 && query().length >= USER_SEARCH_MIN_LENGTH}>
<div class="p-3">
<span class="font-pixel text-[10px] text-gray">
NO USERS FOUND
</span>
</div>
</Show>
</div>
</Show>
</div>
)
}
function SearchIcon(): JSX.Element {
return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor" class="text-gray">
<rect x="4" y="1" width="4" height="1" />
<rect x="2" y="2" width="2" height="1" />
<rect x="8" y="2" width="2" height="1" />
<rect x="1" y="3" width="1" height="2" />
<rect x="10" y="3" width="1" height="2" />
<rect x="1" y="5" width="1" height="2" />
<rect x="10" y="5" width="1" height="2" />
<rect x="2" y="7" width="2" height="1" />
<rect x="8" y="7" width="2" height="1" />
<rect x="4" y="8" width="4" height="1" />
<rect x="9" y="9" width="2" height="2" />
<rect x="11" y="11" width="2" height="2" />
</svg>
)
}

View File

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

View File

@ -0,0 +1,79 @@
/**
* Main application shell with sidebar and content area
*/
import type { ParentProps, JSX } from "solid-js"
import { Show } from "solid-js"
import { useStore } from "@nanostores/solid"
import { $sidebarOpen, $isMobile } from "../../stores"
import { Sidebar } from "./Sidebar"
import { Header } from "./Header"
interface AppShellProps extends ParentProps {
showSidebar?: boolean
showHeader?: boolean
}
export function AppShell(props: AppShellProps): JSX.Element {
const sidebarOpen = useStore($sidebarOpen)
const isMobile = useStore($isMobile)
const showSidebar = (): boolean => props.showSidebar ?? true
const showHeader = (): boolean => props.showHeader ?? true
const getSidebarClasses = (): string => {
if (isMobile()) {
return sidebarOpen() ? "fixed inset-y-0 left-0 z-40 w-72" : "hidden"
}
return sidebarOpen() ? "w-72" : "w-0 overflow-hidden"
}
const handleBackdropClick = (): void => {
$sidebarOpen.set(false)
}
const handleBackdropKeyDown = (e: KeyboardEvent): void => {
if (e.key === "Enter" || e.key === " " || e.key === "Escape") {
e.preventDefault()
$sidebarOpen.set(false)
}
}
return (
<div class="h-screen flex flex-col bg-black overflow-hidden">
<Show when={showHeader()}>
<Header />
</Show>
<div class="flex-1 flex overflow-hidden">
<Show when={showSidebar()}>
<aside
class={`
flex-shrink-0 h-full
border-r-2 border-orange
transition-all duration-100
${getSidebarClasses()}
`}
>
<Sidebar />
</aside>
<Show when={isMobile() && sidebarOpen()}>
<div
class="fixed inset-0 z-30 bg-black/80"
role="button"
tabIndex={0}
onClick={handleBackdropClick}
onKeyDown={handleBackdropKeyDown}
aria-label="Close sidebar"
/>
</Show>
</Show>
<main class="flex-1 overflow-hidden bg-black">
{props.children}
</main>
</div>
</div>
)
}

View File

@ -0,0 +1,131 @@
/**
* 8-bit styled header/navbar component
*/
import type { JSX } from "solid-js"
import { Show } from "solid-js"
import { A } from "@solidjs/router"
import { useStore } from "@nanostores/solid"
import {
$isAuthenticated,
$sidebarOpen,
toggleSidebar,
} from "../../stores"
import { IconButton } from "../UI/IconButton"
import { Badge } from "../UI/Badge"
export function Header(): JSX.Element {
const isAuthenticated = useStore($isAuthenticated)
const sidebarOpen = useStore($sidebarOpen)
return (
<header class="flex-shrink-0 bg-black border-b-4 border-orange">
<div class="flex items-center justify-between h-14 px-4">
<div class="flex items-center gap-3">
<Show when={isAuthenticated()}>
<IconButton
icon={sidebarOpen() ? <CloseMenuIcon /> : <MenuIcon />}
ariaLabel={sidebarOpen() ? "Close menu" : "Open menu"}
onClick={toggleSidebar}
size="sm"
/>
</Show>
<A href="/" class="flex items-center gap-2 hover:no-underline">
<LockIcon />
<h1 class="font-pixel text-sm text-orange uppercase hidden sm:block">
ENCRYPTED CHAT
</h1>
<h1 class="font-pixel text-sm text-orange uppercase sm:hidden">
E-CHAT
</h1>
</A>
<Badge variant="primary" size="xs">
E2EE
</Badge>
</div>
<nav class="flex items-center gap-2">
<Show
when={isAuthenticated()}
fallback={
<>
<A
href="/login"
class="font-pixel text-[10px] text-white hover:text-orange px-3 py-2"
>
LOGIN
</A>
<A
href="/register"
class="font-pixel text-[10px] bg-orange text-black px-3 py-2 hover:bg-white"
>
REGISTER
</A>
</>
}
>
<IconButton
icon={<SearchIcon />}
ariaLabel="Search"
size="sm"
/>
</Show>
</nav>
</div>
</header>
)
}
function MenuIcon(): JSX.Element {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<rect x="2" y="3" width="12" height="2" />
<rect x="2" y="7" width="12" height="2" />
<rect x="2" y="11" width="12" height="2" />
</svg>
)
}
function CloseMenuIcon(): JSX.Element {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<rect x="3" y="3" width="2" height="2" />
<rect x="5" y="5" width="2" height="2" />
<rect x="7" y="7" width="2" height="2" />
<rect x="9" y="9" width="2" height="2" />
<rect x="11" y="11" width="2" height="2" />
<rect x="11" y="3" width="2" height="2" />
<rect x="9" y="5" width="2" height="2" />
<rect x="5" y="9" width="2" height="2" />
<rect x="3" y="11" width="2" height="2" />
</svg>
)
}
function LockIcon(): JSX.Element {
return (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<rect x="6" y="3" width="8" height="2" fill="currentColor" class="text-orange" />
<rect x="4" y="5" width="2" height="4" fill="currentColor" class="text-orange" />
<rect x="14" y="5" width="2" height="4" fill="currentColor" class="text-orange" />
<rect x="3" y="9" width="14" height="2" fill="currentColor" class="text-orange" />
<rect x="3" y="11" width="14" height="6" fill="currentColor" class="text-orange" />
<rect x="9" y="12" width="2" height="4" fill="currentColor" class="text-black" />
</svg>
)
}
function SearchIcon(): JSX.Element {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<rect x="4" y="2" width="6" height="2" />
<rect x="2" y="4" width="2" height="6" />
<rect x="10" y="4" width="2" height="6" />
<rect x="4" y="10" width="6" height="2" />
<rect x="10" y="10" width="2" height="2" />
<rect x="12" y="12" width="2" height="2" />
</svg>
)
}

View File

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

View File

@ -0,0 +1,188 @@
/**
* 8-bit styled sidebar component
*/
import type { JSX } from "solid-js"
import { Show, For } from "solid-js"
import type { Room, Participant } from "../../types"
import { A, useLocation } from "@solidjs/router"
import { useStore } from "@nanostores/solid"
import {
$currentUser,
$rooms,
$activeRoomId,
$totalUnreadCount,
setActiveRoom,
} from "../../stores"
import { Avatar } from "../UI/Avatar"
import { Badge } from "../UI/Badge"
import { IconButton } from "../UI/IconButton"
export function Sidebar(): JSX.Element {
const currentUser = useStore($currentUser)
const rooms = useStore($rooms)
const activeRoomId = useStore($activeRoomId)
const totalUnread = useStore($totalUnreadCount)
const location = useLocation()
const roomList = (): Room[] => {
const roomsObj = rooms()
const roomArray: Room[] = Object.values(roomsObj)
return roomArray.sort((a, b) => {
const aTime = a.last_message?.created_at ?? a.updated_at
const bTime = b.last_message?.created_at ?? b.updated_at
return new Date(bTime).getTime() - new Date(aTime).getTime()
})
}
const isActive = (path: string): boolean => location.pathname === path
return (
<div class="h-full flex flex-col bg-black">
<div class="p-4 border-b-2 border-orange">
<div class="flex items-center justify-between">
<h2 class="font-pixel text-[10px] text-orange uppercase">
Messages
</h2>
<Show when={totalUnread() > 0}>
<Badge variant="primary" size="xs">
{totalUnread()}
</Badge>
</Show>
</div>
</div>
<div class="p-2">
<IconButton
icon={<NewChatIcon />}
ariaLabel="New conversation"
variant="subtle"
size="sm"
class="w-full justify-start gap-2 px-3"
/>
</div>
<nav class="flex-1 overflow-y-auto scrollbar-pixel">
<div class="p-2 space-y-1">
<For each={roomList()}>
{(room) => {
const isSelected = (): boolean => activeRoomId() === room.id
const otherParticipant = (): Participant | undefined =>
room.participants?.find((p: Participant) => p.user_id !== currentUser()?.id)
return (
<button
type="button"
onClick={() => setActiveRoom(room.id)}
class={`
w-full flex items-center gap-3 p-3
border-2 transition-colors duration-100
${isSelected()
? "bg-orange text-black border-orange"
: "bg-black text-white border-transparent hover:border-orange"
}
`}
>
<Avatar
alt={room.name ?? otherParticipant()?.display_name ?? "Chat"}
size="sm"
fallback={room.name?.slice(0, 2) ?? otherParticipant()?.display_name?.slice(0, 2)}
/>
<div class="flex-1 min-w-0 text-left">
<div class="flex items-center justify-between">
<span class="font-pixel text-[10px] truncate">
{room.name ?? otherParticipant()?.display_name ?? "Chat"}
</span>
<Show when={room.unread_count > 0}>
<Badge variant="primary" size="xs">
{room.unread_count}
</Badge>
</Show>
</div>
<Show when={room.last_message} keyed>
{(lastMsg) => (
<p class={`font-pixel text-[8px] truncate mt-0.5 ${
isSelected() ? "text-black/70" : "text-gray"
}`}>
{lastMsg.content}
</p>
)}
</Show>
</div>
</button>
)
}}
</For>
</div>
<Show when={roomList().length === 0}>
<div class="p-4 text-center">
<p class="font-pixel text-[8px] text-gray">
NO CONVERSATIONS YET
</p>
<p class="font-pixel text-[8px] text-gray mt-2">
START A NEW CHAT TO BEGIN
</p>
</div>
</Show>
</nav>
<div class="p-3 border-t-2 border-orange">
<Show when={currentUser()} keyed>
{(user) => (
<A
href="/settings"
class={`
flex items-center gap-3 p-2
border-2 transition-colors duration-100
${isActive("/settings")
? "bg-orange text-black border-orange"
: "border-transparent hover:border-orange"
}
`}
>
<Avatar
alt={user.display_name}
size="sm"
fallback={user.display_name.slice(0, 2)}
/>
<div class="flex-1 min-w-0">
<span class="font-pixel text-[10px] truncate block">
{user.display_name}
</span>
<span class={`font-pixel text-[8px] ${
isActive("/settings") ? "text-black/70" : "text-gray"
}`}>
@{user.username}
</span>
</div>
<SettingsIcon />
</A>
)}
</Show>
</div>
</div>
)
}
function NewChatIcon(): JSX.Element {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<rect x="7" y="2" width="2" height="12" />
<rect x="2" y="7" width="12" height="2" />
</svg>
)
}
function SettingsIcon(): JSX.Element {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" class="text-gray">
<rect x="7" y="1" width="2" height="2" />
<rect x="5" y="3" width="6" height="2" />
<rect x="7" y="5" width="2" height="2" />
<rect x="7" y="9" width="2" height="2" />
<rect x="5" y="11" width="6" height="2" />
<rect x="7" y="13" width="2" height="2" />
</svg>
)
}

View File

@ -0,0 +1,8 @@
// ===================
// © AngelaMos | 2025
// index.ts
// ===================
export { AppShell } from "./AppShell"
export { Sidebar } from "./Sidebar"
export { Header } from "./Header"
export { ProtectedRoute, GuestRoute } from "./ProtectedRoute"

View File

@ -0,0 +1,95 @@
/**
* 8-bit styled avatar component
*/
import { Show, createSignal, onMount } from "solid-js"
import type { JSX } from "solid-js"
import type { AvatarProps, Size, PresenceStatus } from "../../types"
const SIZE_CLASSES: Record<Size, string> = {
xs: "w-6 h-6 text-[8px]",
sm: "w-8 h-8 text-[10px]",
md: "w-10 h-10 text-xs",
lg: "w-12 h-12 text-sm",
xl: "w-16 h-16 text-base",
}
const STATUS_COLORS: Record<PresenceStatus, string> = {
online: "bg-success",
away: "bg-orange",
offline: "bg-gray",
}
const STATUS_SIZE: Record<Size, string> = {
xs: "w-2 h-2",
sm: "w-2.5 h-2.5",
md: "w-3 h-3",
lg: "w-3.5 h-3.5",
xl: "w-4 h-4",
}
export function Avatar(props: AvatarProps): JSX.Element {
const [imageError, setImageError] = createSignal(false)
let imgRef: HTMLImageElement | undefined
const size = (): Size => props.size ?? "md"
const showStatus = (): boolean => props.showStatus ?? false
const getFallbackInitials = (): string => {
if (props.fallback) {
return props.fallback.slice(0, 2).toUpperCase()
}
return props.alt.slice(0, 2).toUpperCase()
}
onMount(() => {
if (imgRef !== undefined) {
imgRef.addEventListener("error", () => setImageError(true))
}
})
const shouldShowImage = (): boolean => {
return Boolean(props.src) && !imageError()
}
return (
<div
class={`
relative inline-flex items-center justify-center
bg-black border-2 border-orange
${SIZE_CLASSES[size()]}
${props.class ?? ""}
`}
>
<Show
when={shouldShowImage()}
fallback={
<span class="font-pixel text-orange select-none">
{getFallbackInitials()}
</span>
}
>
<img
ref={imgRef}
src={props.src}
alt={props.alt}
class="w-full h-full object-cover"
style={{ "image-rendering": "pixelated" }}
/>
</Show>
<Show when={showStatus() ? props.status : undefined} keyed>
{(status) => (
<span
class={`
absolute -bottom-0.5 -right-0.5
border-2 border-black
${STATUS_SIZE[size()]}
${STATUS_COLORS[status]}
`}
/>
)}
</Show>
</div>
)
}

View File

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

View File

@ -0,0 +1,105 @@
/**
* 8-bit styled button component
*/
import { Show, splitProps } from "solid-js"
import type { JSX } from "solid-js"
import type { ButtonProps, Size, ButtonVariant } from "../../types"
import { Spinner } from "./Spinner"
const SIZE_CLASSES: Record<Size, string> = {
xs: "px-2 py-1 text-[8px]",
sm: "px-3 py-1.5 text-[10px]",
md: "px-4 py-2 text-xs",
lg: "px-6 py-3 text-sm",
xl: "px-8 py-4 text-base",
}
const VARIANT_CLASSES: Record<ButtonVariant, string> = {
primary: `
bg-black text-white border-2 border-orange
hover:bg-orange hover:text-black
active:translate-x-[2px] active:translate-y-[2px]
shadow-pixel hover:shadow-none active:shadow-none
`,
secondary: `
bg-black text-orange border-2 border-dark-gray
hover:border-orange hover:text-white
active:translate-x-[2px] active:translate-y-[2px]
`,
ghost: `
bg-transparent text-orange border-2 border-transparent
hover:border-orange hover:bg-black/50
active:translate-x-[2px] active:translate-y-[2px]
`,
danger: `
bg-black text-white border-2 border-error
hover:bg-error hover:text-white
active:translate-x-[2px] active:translate-y-[2px]
shadow-[4px_4px_0_var(--color-error)] hover:shadow-none active:shadow-none
`,
}
const DISABLED_CLASSES = `
opacity-50 cursor-not-allowed
hover:bg-black hover:text-white
active:translate-x-0 active:translate-y-0
shadow-none
`
export function Button(props: ButtonProps): JSX.Element {
const [local, rest] = splitProps(props, [
"variant",
"size",
"fullWidth",
"disabled",
"loading",
"leftIcon",
"rightIcon",
"type",
"onClick",
"class",
"children",
])
const variant = (): ButtonVariant => local.variant ?? "primary"
const size = (): Size => local.size ?? "md"
const isDisabled = (): boolean => local.disabled ?? false
const isLoading = (): boolean => local.loading ?? false
const handleClick = (): void => {
if (!isDisabled() && !isLoading() && local.onClick !== undefined) {
local.onClick()
}
}
return (
<button
type={local.type ?? "button"}
disabled={isDisabled() || isLoading()}
onClick={handleClick}
class={`
font-pixel inline-flex items-center justify-center gap-2
transition-all duration-100 select-none
focus:outline-none focus:ring-2 focus:ring-orange focus:ring-offset-2 focus:ring-offset-black
${SIZE_CLASSES[size()]}
${VARIANT_CLASSES[variant()]}
${(isDisabled() || isLoading()) ? DISABLED_CLASSES : ""}
${local.fullWidth === true ? "w-full" : ""}
${local.class ?? ""}
`}
{...rest}
>
<Show when={isLoading()}>
<Spinner size="xs" />
</Show>
<Show when={!isLoading() && local.leftIcon}>
{local.leftIcon}
</Show>
<span>{local.children}</span>
<Show when={!isLoading() && local.rightIcon}>
{local.rightIcon}
</Show>
</button>
)
}

View File

@ -0,0 +1,121 @@
/**
* 8-bit styled dropdown menu component
*/
import { Show, For, createSignal, onCleanup, createEffect } from "solid-js"
import type { JSX } from "solid-js"
import type { DropdownProps, DropdownItem } from "../../types"
type DropdownAlign = "left" | "right"
export function Dropdown(props: DropdownProps): JSX.Element {
const [isOpen, setIsOpen] = createSignal(false)
let containerRef: HTMLDivElement | undefined
const align = (): DropdownAlign => props.align ?? "left"
const getItemClasses = (item: DropdownItem): string => {
if (item.disabled === true) {
return "text-gray cursor-not-allowed"
}
if (item.danger === true) {
return "text-error hover:bg-error hover:text-white"
}
return "text-white hover:bg-orange hover:text-black"
}
const getItemActiveClass = (item: DropdownItem): string => {
return item.disabled === true ? "" : "active:translate-x-[1px] active:translate-y-[1px]"
}
const handleToggle = (): void => {
setIsOpen(!isOpen())
}
const handleSelect = (item: DropdownItem): void => {
if (item.disabled === true) return
props.onSelect(item.value)
setIsOpen(false)
}
const handleClickOutside = (e: MouseEvent): void => {
if (containerRef !== undefined && !containerRef.contains(e.target as Node)) {
setIsOpen(false)
}
}
const handleKeyDown = (e: KeyboardEvent): void => {
if (e.key === "Escape") {
setIsOpen(false)
}
}
createEffect(() => {
if (isOpen()) {
document.addEventListener("click", handleClickOutside)
document.addEventListener("keydown", handleKeyDown)
}
onCleanup(() => {
document.removeEventListener("click", handleClickOutside)
document.removeEventListener("keydown", handleKeyDown)
})
})
return (
<div
ref={containerRef}
class={`relative inline-block ${props.class ?? ""}`}
>
<div
role="button"
tabIndex={0}
onClick={handleToggle}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
handleToggle()
}
}}
>
{props.trigger}
</div>
<Show when={isOpen()}>
<div
class={`
absolute z-40 mt-1 min-w-[160px]
bg-black border-2 border-orange
shadow-[4px_4px_0_var(--color-orange)]
animate-scale-in origin-top
${align() === "right" ? "right-0" : "left-0"}
`}
role="menu"
>
<For each={props.items}>
{(item) => (
<button
type="button"
role="menuitem"
disabled={item.disabled}
onClick={() => handleSelect(item)}
class={`
w-full flex items-center gap-2 px-3 py-2
font-pixel text-[10px] text-left
transition-colors duration-100
${getItemClasses(item)}
${getItemActiveClass(item)}
`}
>
<Show when={item.icon}>
<span class="flex-shrink-0">{item.icon}</span>
</Show>
<span class="flex-1">{item.label}</span>
</button>
)}
</For>
</div>
</Show>
</div>
)
}

View File

@ -0,0 +1,93 @@
/**
* 8-bit styled icon button component
*/
import { Show, splitProps } from "solid-js"
import type { JSX } from "solid-js"
import type { IconButtonProps, Size } from "../../types"
import { Spinner } from "./Spinner"
type IconButtonVariant = "ghost" | "subtle"
const SIZE_CLASSES: Record<Size, string> = {
xs: "w-6 h-6",
sm: "w-8 h-8",
md: "w-10 h-10",
lg: "w-12 h-12",
xl: "w-14 h-14",
}
const ICON_SIZE_CLASSES: Record<Size, string> = {
xs: "w-3 h-3",
sm: "w-4 h-4",
md: "w-5 h-5",
lg: "w-6 h-6",
xl: "w-7 h-7",
}
const VARIANT_CLASSES: Record<IconButtonVariant, string> = {
ghost: `
bg-transparent text-orange
hover:bg-orange/10 hover:text-orange
active:bg-orange/20
`,
subtle: `
bg-dark-gray text-white
hover:bg-orange hover:text-black
active:bg-orange/80
`,
}
const DISABLED_CLASSES = "opacity-50 cursor-not-allowed hover:bg-transparent"
export function IconButton(props: IconButtonProps): JSX.Element {
const [local, rest] = splitProps(props, [
"icon",
"onClick",
"size",
"variant",
"ariaLabel",
"disabled",
"loading",
"class",
])
const size = (): Size => local.size ?? "md"
const variant = (): IconButtonVariant => local.variant ?? "ghost"
const isDisabled = (): boolean => local.disabled ?? false
const isLoading = (): boolean => local.loading ?? false
const handleClick = (): void => {
if (!isDisabled() && !isLoading() && local.onClick !== undefined) {
local.onClick()
}
}
return (
<button
type="button"
aria-label={local.ariaLabel}
disabled={isDisabled() || isLoading()}
onClick={handleClick}
class={`
inline-flex items-center justify-center
transition-all duration-100
focus:outline-none focus:ring-2 focus:ring-orange focus:ring-offset-1 focus:ring-offset-black
${SIZE_CLASSES[size()]}
${VARIANT_CLASSES[variant()]}
${(isDisabled() || isLoading()) ? DISABLED_CLASSES : ""}
${local.class ?? ""}
`}
{...rest}
>
<Show
when={!isLoading()}
fallback={<Spinner size={size() === "xs" ? "xs" : "sm"} />}
>
<span class={ICON_SIZE_CLASSES[size()]}>
{local.icon}
</span>
</Show>
</button>
)
}

View File

@ -0,0 +1,126 @@
/**
* 8-bit styled input component
*/
import { Show, splitProps, createSignal } from "solid-js"
import type { JSX } from "solid-js"
import type { InputProps } from "../../types"
export function Input(props: InputProps): JSX.Element {
const [local, rest] = splitProps(props, [
"type",
"name",
"placeholder",
"value",
"onInput",
"onChange",
"onFocus",
"onBlur",
"disabled",
"error",
"label",
"hint",
"leftIcon",
"rightIcon",
"fullWidth",
"maxLength",
"minLength",
"required",
"autofocus",
"class",
])
const [focused, setFocused] = createSignal(false)
const handleInput: JSX.EventHandler<HTMLInputElement, InputEvent> = (e) => {
if (local.onInput !== undefined) {
local.onInput(e.currentTarget.value)
}
}
const handleChange: JSX.EventHandler<HTMLInputElement, Event> = (e) => {
if (local.onChange !== undefined) {
local.onChange(e.currentTarget.value)
}
}
const hasError = (): boolean => Boolean(local.error)
const isDisabled = (): boolean => local.disabled ?? false
return (
<div class={`flex flex-col gap-1 ${local.fullWidth === true ? "w-full" : ""}`}>
<Show when={local.label}>
<label
class="font-pixel text-[10px] text-orange uppercase tracking-wider"
for={local.name}
>
{local.label}
<Show when={local.required}>
<span class="text-error ml-1">*</span>
</Show>
</label>
</Show>
<div
class={`
relative flex items-center
bg-black border-2
transition-all duration-100
${focused() ? "border-orange shadow-[0_0_0_2px_var(--color-orange)]" : "border-dark-gray"}
${hasError() ? "border-error shadow-[0_0_0_2px_var(--color-error)]" : ""}
${isDisabled() ? "opacity-50 cursor-not-allowed" : ""}
`}
>
<Show when={local.leftIcon}>
<span class="pl-3 text-gray">{local.leftIcon}</span>
</Show>
<input
type={local.type ?? "text"}
name={local.name}
id={local.name}
placeholder={local.placeholder}
value={local.value ?? ""}
disabled={isDisabled()}
maxLength={local.maxLength}
minLength={local.minLength}
required={local.required}
autofocus={local.autofocus}
onInput={handleInput}
onChange={handleChange}
onFocus={() => {
setFocused(true)
local.onFocus?.()
}}
onBlur={() => {
setFocused(false)
local.onBlur?.()
}}
class={`
w-full bg-transparent font-pixel text-[16px] text-white
px-3 py-2
placeholder:font-placeholder placeholder:text-[16px] placeholder:text-gray
focus:outline-none
disabled:cursor-not-allowed
${local.leftIcon !== undefined ? "pl-1" : ""}
${local.rightIcon !== undefined ? "pr-1" : ""}
${local.class ?? ""}
`}
{...rest}
/>
<Show when={local.rightIcon}>
<span class="pr-3 text-gray">{local.rightIcon}</span>
</Show>
</div>
<Show when={local.error}>
<span class="font-pixel text-[8px] text-error">{local.error}</span>
</Show>
<Show when={local.hint && !local.error}>
<span class="font-pixel text-[8px] text-gray">{local.hint}</span>
</Show>
</div>
)
}

View File

@ -0,0 +1,136 @@
/**
* 8-bit styled modal component
*/
import { Show, createEffect, onCleanup } from "solid-js"
import type { JSX } from "solid-js"
import type { ModalProps, Size } from "../../types"
import { IconButton } from "./IconButton"
const SIZE_CLASSES: Record<Size, string> = {
xs: "max-w-xs",
sm: "max-w-sm",
md: "max-w-md",
lg: "max-w-lg",
xl: "max-w-xl",
}
export function Modal(props: ModalProps): JSX.Element {
const size = (): Size => props.size ?? "md"
const closeOnOverlayClick = (): boolean => props.closeOnOverlayClick ?? true
const showCloseButton = (): boolean => props.showCloseButton ?? true
const handleOverlayClick = (e: MouseEvent): void => {
if (e.target === e.currentTarget && closeOnOverlayClick()) {
props.onClose()
}
}
const handleKeyDown = (e: KeyboardEvent): void => {
if (e.key === "Escape") {
props.onClose()
}
}
createEffect(() => {
if (props.isOpen) {
document.addEventListener("keydown", handleKeyDown)
document.body.style.overflow = "hidden"
}
onCleanup(() => {
document.removeEventListener("keydown", handleKeyDown)
document.body.style.overflow = ""
})
})
const handleOverlayKeyDown = (e: KeyboardEvent): void => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
if (closeOnOverlayClick()) {
props.onClose()
}
}
}
return (
<Show when={props.isOpen}>
<div
class="fixed inset-0 z-50 flex items-center justify-center p-4"
role="button"
tabIndex={0}
onClick={handleOverlayClick}
onKeyDown={handleOverlayKeyDown}
aria-label="Close modal overlay"
>
<div class="absolute inset-0 bg-black/80 animate-fade-in" />
<div
class={`
relative z-10 w-full
bg-black border-4 border-orange
shadow-[8px_8px_0_var(--color-orange)]
animate-scale-in
${SIZE_CLASSES[size()]}
`}
role="dialog"
aria-modal="true"
aria-labelledby={props.title ? "modal-title" : undefined}
>
<Show when={(props.title !== undefined && props.title !== "") || showCloseButton()}>
<div class="flex items-start justify-between p-4 border-b-2 border-orange">
<div class="flex-1">
<Show when={props.title}>
<h2
id="modal-title"
class="font-pixel text-sm text-orange uppercase"
>
{props.title}
</h2>
</Show>
<Show when={props.description}>
<p class="font-pixel text-[10px] text-gray mt-1">
{props.description}
</p>
</Show>
</div>
<Show when={showCloseButton()}>
<IconButton
icon={<CloseIcon />}
ariaLabel="Close modal"
onClick={props.onClose}
size="sm"
variant="ghost"
/>
</Show>
</div>
</Show>
<div class="p-4">
{props.children}
</div>
</div>
</div>
</Show>
)
}
function CloseIcon(): JSX.Element {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2 2L14 14M14 2L2 14"
stroke="currentColor"
stroke-width="2"
stroke-linecap="square"
/>
</svg>
)
}

View File

@ -0,0 +1,90 @@
/**
* 8-bit styled skeleton loading component
*/
import { For } from "solid-js"
import type { JSX } from "solid-js"
import type { SkeletonProps } from "../../types"
type SkeletonVariant = "text" | "circular" | "rectangular"
export function Skeleton(props: SkeletonProps): JSX.Element {
const variant = (): SkeletonVariant => props.variant ?? "text"
const lines = (): number => props.lines ?? 1
const getVariantClasses = (): string => {
switch (variant()) {
case "circular":
return "rounded-none aspect-square"
case "rectangular":
return ""
case "text":
default:
return "h-4"
}
}
const getStyle = (): JSX.CSSProperties => {
const style: JSX.CSSProperties = {}
if (props.width) {
style.width = props.width
}
if (props.height) {
style.height = props.height
}
return style
}
return (
<div class={`flex flex-col gap-2 ${props.class ?? ""}`}>
<For each={Array(lines()).fill(0)}>
{(_, index) => (
<div
class={`
bg-dark-gray
animate-pixel-pulse
${getVariantClasses()}
${index() === lines() - 1 && variant() === "text" ? "w-3/4" : "w-full"}
`}
style={getStyle()}
/>
)}
</For>
</div>
)
}
export function MessageSkeleton(): JSX.Element {
return (
<div class="flex gap-3 p-3">
<Skeleton variant="circular" width="40px" height="40px" />
<div class="flex-1">
<Skeleton variant="text" width="120px" />
<div class="mt-2">
<Skeleton variant="text" lines={2} />
</div>
</div>
</div>
)
}
export function ConversationSkeleton(): JSX.Element {
return (
<div class="flex gap-3 p-3 border-b border-dark-gray">
<Skeleton variant="circular" width="48px" height="48px" />
<div class="flex-1">
<Skeleton variant="text" width="140px" />
<div class="mt-1">
<Skeleton variant="text" width="200px" />
</div>
</div>
</div>
)
}
export function AvatarSkeleton(): JSX.Element {
return <Skeleton variant="circular" width="40px" height="40px" />
}

View File

@ -0,0 +1,59 @@
/**
* 8-bit styled spinner component
*/
import type { JSX } from "solid-js"
import type { SpinnerProps, Size } from "../../types"
const SIZE_CLASSES: Record<Size, string> = {
xs: "w-3 h-3",
sm: "w-4 h-4",
md: "w-6 h-6",
lg: "w-8 h-8",
xl: "w-12 h-12",
}
export function Spinner(props: SpinnerProps): JSX.Element {
const size = (): Size => props.size ?? "md"
return (
<div
class={`
inline-block animate-pixel-spin
${SIZE_CLASSES[size()]}
${props.class ?? ""}
`}
role="status"
aria-label="Loading"
>
<svg
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="w-full h-full"
>
<rect x="6" y="0" width="4" height="4" fill="currentColor" class="text-orange" />
<rect x="10" y="2" width="4" height="4" fill="currentColor" class="text-orange/80" />
<rect x="12" y="6" width="4" height="4" fill="currentColor" class="text-orange/60" />
<rect x="10" y="10" width="4" height="4" fill="currentColor" class="text-orange/40" />
<rect x="6" y="12" width="4" height="4" fill="currentColor" class="text-orange/30" />
<rect x="2" y="10" width="4" height="4" fill="currentColor" class="text-orange/20" />
<rect x="0" y="6" width="4" height="4" fill="currentColor" class="text-orange/10" />
<rect x="2" y="2" width="4" height="4" fill="currentColor" class="text-orange/90" />
</svg>
</div>
)
}
export function LoadingOverlay(): JSX.Element {
return (
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/90">
<div class="flex flex-col items-center gap-4">
<Spinner size="xl" />
<span class="font-pixel text-xs text-orange animate-pulse">
LOADING...
</span>
</div>
</div>
)
}

View File

@ -0,0 +1,129 @@
/**
* 8-bit styled textarea component
*/
import { Show, splitProps, createSignal, createEffect, onMount } from "solid-js"
import type { JSX } from "solid-js"
import type { TextAreaProps } from "../../types"
export function TextArea(props: TextAreaProps): JSX.Element {
const [local, rest] = splitProps(props, [
"name",
"placeholder",
"value",
"onInput",
"disabled",
"error",
"label",
"rows",
"maxLength",
"autoResize",
"class",
])
const [focused, setFocused] = createSignal(false)
let textareaRef: HTMLTextAreaElement | undefined
const handleInput: JSX.EventHandler<HTMLTextAreaElement, InputEvent> = (e) => {
if (local.onInput !== undefined) {
local.onInput(e.currentTarget.value)
}
if (local.autoResize === true && textareaRef !== undefined) {
adjustHeight()
}
}
const adjustHeight = (): void => {
if (textareaRef !== undefined) {
textareaRef.style.height = "auto"
textareaRef.style.height = `${textareaRef.scrollHeight}px`
}
}
onMount(() => {
if (local.autoResize === true && textareaRef !== undefined) {
adjustHeight()
}
})
createEffect(() => {
if (local.autoResize === true && local.value !== undefined && textareaRef !== undefined) {
adjustHeight()
}
})
const hasError = (): boolean => Boolean(local.error)
const isDisabled = (): boolean => local.disabled ?? false
const currentLength = (): number => (local.value ?? "").length
return (
<div class="flex flex-col gap-1 w-full">
<Show when={local.label}>
<label
class="font-pixel text-[10px] text-orange uppercase tracking-wider"
for={local.name}
>
{local.label}
</label>
</Show>
<div
class={`
relative
bg-black border-2
transition-all duration-100
${focused() ? "border-orange shadow-[0_0_0_2px_var(--color-orange)]" : "border-dark-gray"}
${hasError() ? "border-error shadow-[0_0_0_2px_var(--color-error)]" : ""}
${isDisabled() ? "opacity-50 cursor-not-allowed" : ""}
`}
>
<textarea
ref={textareaRef}
name={local.name}
id={local.name}
placeholder={local.placeholder}
value={local.value ?? ""}
disabled={isDisabled()}
rows={local.rows ?? 3}
maxLength={local.maxLength}
onInput={handleInput}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
class={`
w-full bg-transparent font-pixel text-[16px] text-white
px-3 py-2
placeholder:font-placeholder placeholder:text-[16px] placeholder:text-gray
focus:outline-none
disabled:cursor-not-allowed
resize-none
${local.autoResize === true ? "overflow-hidden" : ""}
${local.class ?? ""}
`}
{...rest}
/>
</div>
<div class="flex justify-between">
<Show when={local.error}>
<span class="font-pixel text-[8px] text-error">{local.error}</span>
</Show>
<Show when={!local.error}>
<span />
</Show>
<Show when={local.maxLength}>
<span
class={`font-pixel text-[8px] ${
currentLength() > (local.maxLength ?? 0) * 0.9
? "text-error"
: "text-gray"
}`}
>
{currentLength()}/{local.maxLength}
</span>
</Show>
</div>
</div>
)
}

View File

@ -0,0 +1,151 @@
/**
* 8-bit styled toast notification component
*/
import { For, Show } from "solid-js"
import type { JSX } from "solid-js"
import type { ToastProps } from "../../types"
import { $toasts, dismissToast } from "../../stores"
import { useStore } from "@nanostores/solid"
import { IconButton } from "./IconButton"
type ToastVariant = "info" | "success" | "warning" | "error"
const VARIANT_CLASSES: Record<ToastVariant, string> = {
info: "border-info text-info",
success: "border-success text-success",
warning: "border-orange text-orange",
error: "border-error text-error",
}
const VARIANT_ICONS: Record<ToastVariant, JSX.Element> = {
info: <InfoIcon />,
success: <CheckIcon />,
warning: <WarningIcon />,
error: <ErrorIcon />,
}
export function Toast(props: ToastProps): JSX.Element {
const handleDismiss = (): void => {
dismissToast(props.id)
}
return (
<div
class={`
flex items-start gap-3 p-4
bg-black border-2
shadow-[4px_4px_0_currentColor]
animate-slide-in-right
${VARIANT_CLASSES[props.variant]}
`}
role="alert"
>
<span class="flex-shrink-0 mt-0.5">
{VARIANT_ICONS[props.variant]}
</span>
<div class="flex-1 min-w-0">
<p class="font-pixel text-[10px] text-white uppercase">
{props.title}
</p>
<Show when={props.description}>
<p class="font-pixel text-[8px] text-gray mt-1">
{props.description}
</p>
</Show>
<Show when={props.action} keyed>
{(action) => (
<button
onClick={() => action.onClick()}
class="font-pixel text-[8px] text-orange hover:underline mt-2"
>
{action.label}
</button>
)}
</Show>
</div>
<IconButton
icon={<CloseIcon />}
ariaLabel="Dismiss"
onClick={handleDismiss}
size="xs"
variant="ghost"
/>
</div>
)
}
export function ToastContainer(): JSX.Element {
const toasts = useStore($toasts)
return (
<div class="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-sm">
<For each={toasts()}>
{(toast) => <Toast {...toast} />}
</For>
</div>
)
}
function InfoIcon(): JSX.Element {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<rect x="7" y="3" width="2" height="2" />
<rect x="7" y="7" width="2" height="6" />
</svg>
)
}
function CheckIcon(): JSX.Element {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<rect x="3" y="8" width="2" height="2" />
<rect x="5" y="10" width="2" height="2" />
<rect x="7" y="8" width="2" height="2" />
<rect x="9" y="6" width="2" height="2" />
<rect x="11" y="4" width="2" height="2" />
</svg>
)
}
function WarningIcon(): JSX.Element {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<rect x="7" y="2" width="2" height="8" />
<rect x="7" y="12" width="2" height="2" />
</svg>
)
}
function ErrorIcon(): JSX.Element {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<rect x="3" y="3" width="2" height="2" />
<rect x="5" y="5" width="2" height="2" />
<rect x="7" y="7" width="2" height="2" />
<rect x="9" y="9" width="2" height="2" />
<rect x="11" y="11" width="2" height="2" />
<rect x="11" y="3" width="2" height="2" />
<rect x="9" y="5" width="2" height="2" />
<rect x="5" y="9" width="2" height="2" />
<rect x="3" y="11" width="2" height="2" />
</svg>
)
}
function CloseIcon(): JSX.Element {
return (
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
<rect x="2" y="2" width="2" height="2" />
<rect x="4" y="4" width="2" height="2" />
<rect x="6" y="4" width="2" height="2" />
<rect x="8" y="2" width="2" height="2" />
<rect x="2" y="8" width="2" height="2" />
<rect x="4" y="6" width="2" height="2" />
<rect x="6" y="6" width="2" height="2" />
<rect x="8" y="8" width="2" height="2" />
</svg>
)
}

View File

@ -0,0 +1,96 @@
/**
* 8-bit styled tooltip component
*/
import { Show, createSignal, onCleanup } from "solid-js"
import type { JSX } from "solid-js"
import type { TooltipProps } from "../../types"
type TooltipPosition = "top" | "bottom" | "left" | "right"
const POSITION_CLASSES: Record<TooltipPosition, string> = {
top: "bottom-full left-1/2 -translate-x-1/2 mb-2",
bottom: "top-full left-1/2 -translate-x-1/2 mt-2",
left: "right-full top-1/2 -translate-y-1/2 mr-2",
right: "left-full top-1/2 -translate-y-1/2 ml-2",
}
const ARROW_CLASSES: Record<TooltipPosition, string> = {
top: "top-full left-1/2 -translate-x-1/2 border-t-orange border-x-transparent border-b-transparent",
bottom: "bottom-full left-1/2 -translate-x-1/2 border-b-orange border-x-transparent border-t-transparent",
left: "left-full top-1/2 -translate-y-1/2 border-l-orange border-y-transparent border-r-transparent",
right: "right-full top-1/2 -translate-y-1/2 border-r-orange border-y-transparent border-l-transparent",
}
const DEFAULT_DELAY_MS = 300
export function Tooltip(props: TooltipProps): JSX.Element {
const [visible, setVisible] = createSignal(false)
let timeoutId: ReturnType<typeof setTimeout> | undefined
const position = (): TooltipPosition => props.position ?? "top"
const delay = (): number => props.delay ?? DEFAULT_DELAY_MS
const showTooltip = (): void => {
timeoutId = setTimeout(() => {
setVisible(true)
}, delay())
}
const hideTooltip = (): void => {
if (timeoutId !== undefined) {
clearTimeout(timeoutId)
}
setVisible(false)
}
onCleanup(() => {
if (timeoutId !== undefined) {
clearTimeout(timeoutId)
}
})
return (
<div
class="relative inline-block"
role="presentation"
onMouseEnter={showTooltip}
onMouseLeave={hideTooltip}
onFocus={showTooltip}
onBlur={hideTooltip}
>
{props.children}
<Show when={visible()}>
<div
class={`
absolute z-50 pointer-events-none
${POSITION_CLASSES[position()]}
`}
role="tooltip"
>
<div
class={`
relative
px-2 py-1
bg-black border-2 border-orange
font-pixel text-[8px] text-white
whitespace-nowrap
shadow-[2px_2px_0_var(--color-orange)]
animate-fade-in
`}
>
{props.content}
<span
class={`
absolute w-0 h-0
border-4
${ARROW_CLASSES[position()]}
`}
/>
</div>
</div>
</Show>
</div>
)
}

View File

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

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