initial implementation
This commit is contained in:
parent
7f324efd87
commit
96d681cf1e
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
/home/yoshi/.pyenv/versions/3.12.7/bin/python
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
python
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
python
|
||||||
Binary file not shown.
|
|
@ -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())
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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 */
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
lib
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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!"
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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",
|
||||||
|
)
|
||||||
|
|
@ -87,6 +87,7 @@ class SurrealDBManager:
|
||||||
"""
|
"""
|
||||||
await self.ensure_connected()
|
await self.ensure_connected()
|
||||||
result = await self.db.create("messages", message_data)
|
result = await self.db.create("messages", message_data)
|
||||||
|
result["id"] = str(result["id"])
|
||||||
return MessageResponse(**result)
|
return MessageResponse(**result)
|
||||||
|
|
||||||
async def get_room_messages(
|
async def get_room_messages(
|
||||||
|
|
@ -123,6 +124,7 @@ class SurrealDBManager:
|
||||||
"""
|
"""
|
||||||
await self.ensure_connected()
|
await self.ensure_connected()
|
||||||
result = await self.db.create("rooms", room_data)
|
result = await self.db.create("rooms", room_data)
|
||||||
|
result["id"] = str(result["id"])
|
||||||
return RoomResponse(**result)
|
return RoomResponse(**result)
|
||||||
|
|
||||||
async def get_user_rooms(self, user_id: str) -> list[RoomResponse]:
|
async def get_user_rooms(self, user_id: str) -> list[RoomResponse]:
|
||||||
|
|
@ -235,7 +237,8 @@ class SurrealDBManager:
|
||||||
"""
|
"""
|
||||||
await self.ensure_connected()
|
await self.ensure_connected()
|
||||||
room = await self.db.create("rooms", room_data)
|
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))
|
asyncio.create_task(self._schedule_room_deletion(room_id, ttl_seconds))
|
||||||
return RoomResponse(**room)
|
return RoomResponse(**room)
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ from app.config import (
|
||||||
)
|
)
|
||||||
from app.models.Base import init_db
|
from app.models.Base import init_db
|
||||||
from app.api.auth import router as auth_router
|
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.surreal_manager import surreal_db
|
||||||
from app.core.redis_manager import redis_manager
|
from app.core.redis_manager import redis_manager
|
||||||
from app.api.encryption import router as encryption_router
|
from app.api.encryption import router as encryption_router
|
||||||
|
|
@ -106,6 +107,7 @@ def create_app() -> FastAPI:
|
||||||
return HealthResponse(status = "healthy")
|
return HealthResponse(status = "healthy")
|
||||||
|
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
|
app.include_router(rooms_router)
|
||||||
app.include_router(encryption_router)
|
app.include_router(encryption_router)
|
||||||
app.include_router(websocket_router)
|
app.include_router(websocket_router)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,12 @@ from app.schemas.websocket import (
|
||||||
WSHeartbeat,
|
WSHeartbeat,
|
||||||
)
|
)
|
||||||
from app.schemas.common import HealthResponse, RootResponse
|
from app.schemas.common import HealthResponse, RootResponse
|
||||||
|
from app.schemas.rooms import (
|
||||||
|
CreateRoomRequest,
|
||||||
|
ParticipantResponse,
|
||||||
|
RoomAPIResponse,
|
||||||
|
RoomListResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
|
@ -61,4 +67,8 @@ __all__ = [
|
||||||
"WSHeartbeat",
|
"WSHeartbeat",
|
||||||
"RootResponse",
|
"RootResponse",
|
||||||
"HealthResponse",
|
"HealthResponse",
|
||||||
|
"CreateRoomRequest",
|
||||||
|
"ParticipantResponse",
|
||||||
|
"RoomAPIResponse",
|
||||||
|
"RoomListResponse",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
@ -109,11 +109,13 @@ ignore = [
|
||||||
"RUF002", # Docstring with ambiguous unicode
|
"RUF002", # Docstring with ambiguous unicode
|
||||||
"B008", # FastAPI Depends() in defaults is standard pattern
|
"B008", # FastAPI Depends() in defaults is standard pattern
|
||||||
"ARG001", # Unused function args (lifespan protocol)
|
"ARG001", # Unused function args (lifespan protocol)
|
||||||
|
"E712", # SQLAlchemy requires == True/False for query filters
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
[tool.ruff.lint.per-file-ignores]
|
[tool.ruff.lint.per-file-ignores]
|
||||||
"__init__.py" = ["F401"]
|
"__init__.py" = ["F401"]
|
||||||
|
"tests/*" = ["ARG002"]
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
python_version = "3.13"
|
python_version = "3.13"
|
||||||
|
|
@ -152,7 +154,7 @@ disable_error_code = ["union-attr", "type-arg"]
|
||||||
|
|
||||||
[[tool.mypy.overrides]]
|
[[tool.mypy.overrides]]
|
||||||
module = "app.services.auth_service"
|
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]]
|
[[tool.mypy.overrides]]
|
||||||
module = "app.services.prekey_service"
|
module = "app.services.prekey_service"
|
||||||
|
|
@ -238,6 +240,8 @@ max-positional-arguments = 7
|
||||||
[tool.pylint."messages control"]
|
[tool.pylint."messages control"]
|
||||||
per-file-ignores = [
|
per-file-ignores = [
|
||||||
"alembic/env.py:W0611", # Unused imports needed for SQLModel metadata
|
"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
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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", "-"]
|
||||||
|
|
@ -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;"]
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
|
.context
|
||||||
|
|
||||||
|
|
@ -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/
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"singleQuote": false,
|
||||||
|
"printWidth": 95,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"plugins": ["prettier-plugin-tailwindcss"]
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
);
|
||||||
|
|
@ -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
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
// ===================
|
||||||
|
// © AngelaMos | 2025
|
||||||
|
// index.ts
|
||||||
|
// ===================
|
||||||
|
export { PasskeyButton } from "./PasskeyButton"
|
||||||
|
export { AuthCard } from "./AuthCard"
|
||||||
|
export { AuthForm } from "./AuthForm"
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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">>>></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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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" />
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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
Loading…
Reference in New Issue