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()
|
||||
result = await self.db.create("messages", message_data)
|
||||
result["id"] = str(result["id"])
|
||||
return MessageResponse(**result)
|
||||
|
||||
async def get_room_messages(
|
||||
|
|
@ -123,6 +124,7 @@ class SurrealDBManager:
|
|||
"""
|
||||
await self.ensure_connected()
|
||||
result = await self.db.create("rooms", room_data)
|
||||
result["id"] = str(result["id"])
|
||||
return RoomResponse(**result)
|
||||
|
||||
async def get_user_rooms(self, user_id: str) -> list[RoomResponse]:
|
||||
|
|
@ -235,7 +237,8 @@ class SurrealDBManager:
|
|||
"""
|
||||
await self.ensure_connected()
|
||||
room = await self.db.create("rooms", room_data)
|
||||
room_id = room["id"]
|
||||
room_id = str(room["id"])
|
||||
room["id"] = room_id
|
||||
|
||||
asyncio.create_task(self._schedule_room_deletion(room_id, ttl_seconds))
|
||||
return RoomResponse(**room)
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ from app.config import (
|
|||
)
|
||||
from app.models.Base import init_db
|
||||
from app.api.auth import router as auth_router
|
||||
from app.api.rooms import router as rooms_router
|
||||
from app.core.surreal_manager import surreal_db
|
||||
from app.core.redis_manager import redis_manager
|
||||
from app.api.encryption import router as encryption_router
|
||||
|
|
@ -106,6 +107,7 @@ def create_app() -> FastAPI:
|
|||
return HealthResponse(status = "healthy")
|
||||
|
||||
app.include_router(auth_router)
|
||||
app.include_router(rooms_router)
|
||||
app.include_router(encryption_router)
|
||||
app.include_router(websocket_router)
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,12 @@ from app.schemas.websocket import (
|
|||
WSHeartbeat,
|
||||
)
|
||||
from app.schemas.common import HealthResponse, RootResponse
|
||||
from app.schemas.rooms import (
|
||||
CreateRoomRequest,
|
||||
ParticipantResponse,
|
||||
RoomAPIResponse,
|
||||
RoomListResponse,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
|
|
@ -61,4 +67,8 @@ __all__ = [
|
|||
"WSHeartbeat",
|
||||
"RootResponse",
|
||||
"HealthResponse",
|
||||
"CreateRoomRequest",
|
||||
"ParticipantResponse",
|
||||
"RoomAPIResponse",
|
||||
"RoomListResponse",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
"B008", # FastAPI Depends() in defaults is standard pattern
|
||||
"ARG001", # Unused function args (lifespan protocol)
|
||||
"E712", # SQLAlchemy requires == True/False for query filters
|
||||
]
|
||||
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"__init__.py" = ["F401"]
|
||||
"tests/*" = ["ARG002"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.13"
|
||||
|
|
@ -152,7 +154,7 @@ disable_error_code = ["union-attr", "type-arg"]
|
|||
|
||||
[[tool.mypy.overrides]]
|
||||
module = "app.services.auth_service"
|
||||
disable_error_code = ["arg-type", "no-any-return"]
|
||||
disable_error_code = ["arg-type", "no-any-return", "attr-defined"]
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = "app.services.prekey_service"
|
||||
|
|
@ -238,6 +240,8 @@ max-positional-arguments = 7
|
|||
[tool.pylint."messages control"]
|
||||
per-file-ignores = [
|
||||
"alembic/env.py:W0611", # Unused imports needed for SQLModel metadata
|
||||
"app/services/*:C0121", # SQLAlchemy requires == True/False for query filters
|
||||
"tests/*:W0212,W0621", # Tests access protected members; pytest fixtures reuse names
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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