Initial commit

This commit is contained in:
Corey 2024-04-08 10:39:38 -04:00
commit ba172f599c
Signed by: coco
GPG Key ID: 051138DA6AEE3E60
72 changed files with 13243 additions and 0 deletions

17
.devcontainer/Dockerfile Normal file
View File

@ -0,0 +1,17 @@
FROM nikolaik/python-nodejs:latest
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install --no-install-recommends git \
&& pip install --upgrade pip && npm update -g npm
WORKDIR /workspace
COPY . .
RUN pip install --no-cache-dir -r /workspace/api/requirements.txt
WORKDIR /workspace/client
RUN npm install
CMD ["tail", "-f", "/dev/null"]

View File

@ -0,0 +1,75 @@
{
"name": "Ixion",
"dockerComposeFile": "docker-compose.yml",
"service": "workspace",
"containerEnv": {
"GIT_EDITOR": "code --wait"
},
"workspaceFolder": "/workspace",
"postCreateCommand": "git config --global --add safe.directory /workspace",
"shutdownAction": "stopCompose",
"forwardPorts": [8000, 3000],
"portsAttributes": {
"8000": {
"label": "api"
},
"3000": {
"label": "client"
}
},
"features": {
"ghcr.io/devcontainers/features/docker-outside-of-docker:latest": {}
},
"mounts": [
"source=vscode-extensions,target=/root/.vscode-server/extensions,type=volume",
],
"customizations": {
"vscode": {
"extensions": [
"EditorConfig.EditorConfig",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"formulahendry.auto-rename-tag",
"idered.npm",
"christian-kohler.npm-intellisense",
"xabikos.JavaScriptSnippets",
"dsznajder.es7-react-js-snippets",
"eamodio.gitlens",
"donjayamanne.githistory",
"humao.rest-client",
"ms-azuretools.vscode-docker",
"bradlc.vscode-tailwindcss",
"christian-kohler.path-intellisense",
"csstools.postcss",
// api
"ms-python.black-formatter",
"ms-python.python",
"ms-python.vscode-pylance",
"batisteo.vscode-django"
],
"settings": {
"[python]": {
"editor.defaultFormatter": "ms-python.black-formatter"
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"eslint.format.enable": true,
"eslint.lintTask.enable": true,
"eslint.workingDirectories": ["./client"],
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact"
],
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit",
"source.fixAll.eslint": "explicit"
},
"tailwindCSS.includeLanguages": {
"plaintext": "javascript"
}
}
}
}
}

View File

@ -0,0 +1,63 @@
services:
workspace:
build:
context: ..
dockerfile: .devcontainer/Dockerfile
image: ixion-dev:latest
command: sleep infinity
volumes:
- /var/run/docker.sock:/var/run/docker-host.sock
- ixion-dev-node_modules:/workspace/client/node_modules
- ixion-dev-static:/workspace/api/static
- ..:/workspace:cached
client:
image: ixion-dev:latest
working_dir: /workspace/client
command: npm run dev
volumes:
- ixion-dev-node_modules:/workspace/client/node_modules
- ../client:/workspace/client:cached
ports:
- 3000:3000
depends_on:
- workspace
- api
api:
image: ixion-dev:latest
working_dir: /workspace/api
entrypoint: /workspace/api/entrypoint.sh
command: python manage.py runserver 0.0.0.0:8000
environment:
- DEBUG=true
volumes:
- ixion-dev-static:/workspace/api/static
- ../api:/workspace/api:cached
ports:
- 8000:8000
depends_on:
- workspace
- redis
celery:
image: ixion-dev:latest
working_dir: /workspace/api
command: celery -A api worker -l info
volumes:
- ../api:/workspace/api:cached
depends_on:
- workspace
- redis
celery-beat:
image: ixion-dev:latest
working_dir: /workspace/api
command: celery -A api beat -l info
volumes:
- ../api:/workspace/api:cached
depends_on:
- workspace
- redis
redis:
image: redis:alpine
volumes:
ixion-dev-node_modules:
ixion-dev-static:

10
.editorconfig Normal file
View File

@ -0,0 +1,10 @@
# http://editorconfig.org
root = true
[*]
indent_style = space
indent_size = 2
charset = utf-8
trim_trailing_whitespace = true
end_of_line = lf
insert_final_newline = true

217
.gitattributes vendored Normal file
View File

@ -0,0 +1,217 @@
## GITATTRIBUTES FOR WEB PROJECTS
#
# These settings are for any web project.
#
# Details per file setting:
# text These files should be normalized (i.e. convert CRLF to LF).
# binary These files are binary and should be left untouched.
#
# Note that binary is a macro for -text -diff.
######################################################################
# Auto detect
## Handle line endings automatically for files detected as
## text and leave all files detected as binary untouched.
## This will handle all files NOT defined below.
* text=auto
# Source code
*.bash text eol=lf
*.bat text eol=crlf
*.cmd text eol=crlf
*.coffee text
*.css text diff=css
*.htm text diff=html
*.html text diff=html
*.inc text
*.ini text
*.js text
*.mjs text
*.cjs text
*.json text
*.jsx text
*.less text
*.ls text
*.map text -diff
*.od text
*.onlydata text
*.php text diff=php
*.pl text
*.ps1 text eol=crlf
*.py text diff=python
*.rb text diff=ruby
*.sass text
*.scm text
*.scss text diff=css
*.sh text eol=lf
.husky/* text eol=lf
*.sql text
*.styl text
*.tag text
*.ts text
*.tsx text
*.xml text
*.xhtml text diff=html
# Docker
Dockerfile text
# Documentation
*.ipynb text eol=lf
*.markdown text diff=markdown
*.md text diff=markdown
*.mdwn text diff=markdown
*.mdown text diff=markdown
*.mkd text diff=markdown
*.mkdn text diff=markdown
*.mdtxt text
*.mdtext text
*.txt text
AUTHORS text
CHANGELOG text
CHANGES text
CONTRIBUTING text
COPYING text
copyright text
*COPYRIGHT* text
INSTALL text
license text
LICENSE text
NEWS text
readme text
*README* text
TODO text
# Templates
*.dot text
*.ejs text
*.erb text
*.haml text
*.handlebars text
*.hbs text
*.hbt text
*.jade text
*.latte text
*.mustache text
*.njk text
*.phtml text
*.svelte text
*.tmpl text
*.tpl text
*.twig text
*.vue text
# Configs
*.cnf text
*.conf text
*.config text
.editorconfig text
.env text
.gitattributes text
.gitconfig text
.htaccess text
*.lock text -diff
package.json text eol=lf
package-lock.json text eol=lf -diff
pnpm-lock.yaml text eol=lf -diff
.prettierrc text
yarn.lock text -diff
*.toml text
*.yaml text
*.yml text
browserslist text
Makefile text
makefile text
# Fixes syntax highlighting on GitHub to allow comments
tsconfig.json linguist-language=JSON-with-Comments
# Heroku
Procfile text
# Graphics
*.ai binary
*.bmp binary
*.eps binary
*.gif binary
*.gifv binary
*.ico binary
*.jng binary
*.jp2 binary
*.jpg binary
*.jpeg binary
*.jpx binary
*.jxr binary
*.pdf binary
*.png binary
*.psb binary
*.psd binary
# SVG treated as an asset (binary) by default.
*.svg text
# If you want to treat it as binary,
# use the following line instead.
# *.svg binary
*.svgz binary
*.tif binary
*.tiff binary
*.wbmp binary
*.webp binary
# Audio
*.kar binary
*.m4a binary
*.mid binary
*.midi binary
*.mp3 binary
*.ogg binary
*.ra binary
# Video
*.3gpp binary
*.3gp binary
*.as binary
*.asf binary
*.asx binary
*.avi binary
*.fla binary
*.flv binary
*.m4v binary
*.mng binary
*.mov binary
*.mp4 binary
*.mpeg binary
*.mpg binary
*.ogv binary
*.swc binary
*.swf binary
*.webm binary
# Archives
*.7z binary
*.gz binary
*.jar binary
*.rar binary
*.tar binary
*.zip binary
# Fonts
*.ttf binary
*.eot binary
*.otf binary
*.woff binary
*.woff2 binary
# Executables
*.exe binary
*.pyc binary
# Prevents massive diffs caused by vendored, minified files
**/.yarn/releases/** binary
**/.yarn/plugins/** binary
# RC files (like .babelrc or .eslintrc)
*.*rc text
# Ignore files (like .npmignore or .gitignore)
*.*ignore text
# Prevents massive diffs from built files
dist/* binary

452
.gitignore vendored Normal file
View File

@ -0,0 +1,452 @@
# Created by https://www.toptal.com/developers/gitignore/api/python,django,node,react,git,visualstudiocode,dotenv
# Edit at https://www.toptal.com/developers/gitignore?templates=python,django,node,react,git,visualstudiocode,dotenv
### Django ###
*.log
*.pot
*.pyc
__pycache__/
local_settings.py
db.sqlite3
db.sqlite3-journal
media
# If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/
# in your Git repository. Update and uncomment the following line accordingly.
api/static/
### Django.Python Stack ###
# Byte-compiled / optimized / DLL files
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
# Django stuff:
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
### dotenv ###
### Git ###
# Created by git for backups. To disable backups in Git:
# $ git config --global mergetool.keepBackup false
*.orig
# Created by git when using merge tools for conflicts
*.BACKUP.*
*.BASE.*
*.LOCAL.*
*.REMOTE.*
*_BACKUP_*.txt
*_BASE_*.txt
*_LOCAL_*.txt
*_REMOTE_*.txt
### Node ###
# Logs
logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
### Node Patch ###
# Serverless Webpack directories
.webpack/
# Optional stylelint cache
# SvelteKit build / generate output
.svelte-kit
### Python ###
# Byte-compiled / optimized / DLL files
# C extensions
# Distribution / packaging
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
# Installer logs
# Unit test / coverage reports
# Translations
# Django stuff:
# Flask stuff:
# Scrapy stuff:
# Sphinx documentation
# PyBuilder
# Jupyter Notebook
# IPython
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
# Celery stuff
# SageMath parsed files
# Environments
# Spyder project settings
# Rope project settings
# mkdocs documentation
# mypy
# Pyre type checker
# pytype static type analyzer
# Cython debug symbols
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
### Python Patch ###
# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
poetry.toml
# ruff
.ruff_cache/
# LSP config files
pyrightconfig.json
### react ###
.DS_*
**/*.backup.*
**/*.back.*
node_modules
*.sublime*
psd
thumb
sketch
### VisualStudioCode ###
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
### VisualStudioCode Patch ###
# Ignore all local history of files
.history
.ionide
# End of https://www.toptal.com/developers/gitignore/api/python,django,node,react,git,visualstudiocode,dotenv

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright 2023 Corey Sotiropoulos
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

53
README.md Normal file
View File

@ -0,0 +1,53 @@
# Ixion
## Description
Ixion is designed to catalog public [LandSandBoat](https://github.com/LandSandBoat/server) servers. The API accepts POST requests with the `url` parameter which are fetched with the `/api/settings` subdirectory, looking for some required LSB settings as a json response. Valid LSB URLs are saved and periodically checked and updated accordingly. When a previously valid URL has been inactive for 24 hours, it is removed from the database.
The React client fetches server data from the API and displays it in a relevant way to users. Filtering is built into the client for now. When the API response fails or is empty, a default LSB demo server is displayed, pulling some info from GitHub.
## Features
- **Automatic Verification**: Verifies LSB server URL by sending a request to the public API. [Requires the LSB server to enable the HTTP server.](https://github.com/LandSandBoat/server/blob/df311283c4abb779d212e2b8af6734b0d8d11ad7/settings/default/network.lua#L33)
- **Automatic Deletion**: Deletes LSB servers from the database if their API is found to be invalid after a configurable period of inactivity ([default 24hrs](./api/.env.example#L8)).
- **Periodic Checks**: Runs update checks on all LSB servers in the database.
- **Server Filtering**: Filter LSB servers by different parameters.
- **Dockerized Deployment**: The application is containerized for easy deployment and development.
## Setup Instructions
### Production
#### API
In the `api` directory copy `.env.example` to `.env` and set details accordingly.
```sh
docker compose up
```
The API will be running on port 8000.
#### Client
In the `client` directory:
```sh
npm install
npm run build
npm start
```
Build output is in `dist/`. Client will be running on port 3000.
---
### Development
Download the [Dev Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) extension for VS Code and open the folder in the container. This also works well in codespaces.
If using Windows, highly recommend using WSL and storing the repo in the WSL filesystem.
## License
MIT

11
TODO.md Normal file
View File

@ -0,0 +1,11 @@
# TODO
## Frontend
- Diff server settings with default settings, show changed settings only. (Chips?)
- Display active sessions over time on graph. (Active hours?)
## api
- Track active sessions over time.
- User accounts for voting/ranking, claiming ownership.

4
api/.editorconfig Normal file
View File

@ -0,0 +1,4 @@
# http://editorconfig.org
[*]
indent_size = 4

8
api/.env.example Normal file
View File

@ -0,0 +1,8 @@
SECRET_KEY=insecure
DEBUG=False
ALLOWED_HOSTS=api.ixion.dev
CSRF_TRUSTED_ORIGINS=https://api.ixion.dev
CORS_ALLOWED_ORIGINS=https://ixion.dev
# API configuration
SERVER_INACTIVITY_TIMEOUT=24

14
api/Dockerfile Normal file
View File

@ -0,0 +1,14 @@
FROM python:3-alpine
ENV PIP_DISABLE_PIP_VERSION_CHECK 1
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
WORKDIR /app
COPY ./requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
ENTRYPOINT ["/app/entrypoint.sh"]

5
api/api/__init__.py Normal file
View File

@ -0,0 +1,5 @@
# This will make sure the app is always imported when
# Django starts so that shared_task will use this app.
from .celery import app as celery_app
__all__ = ("celery_app",)

16
api/api/asgi.py Normal file
View File

@ -0,0 +1,16 @@
"""
ASGI config for api project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.0/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "api.settings")
application = get_asgi_application()

34
api/api/celery.py Normal file
View File

@ -0,0 +1,34 @@
import os
from celery import Celery
from datetime import timedelta
import api.tasks
from dotenv import load_dotenv
load_dotenv() # take environment variables from .env.
# Set the default Django settings module for the 'celery' program.
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "api.settings")
app = Celery("api")
# Using a string here means the worker doesn't have to serialize
# the configuration object to child processes.
# - namespace='CELERY' means all celery-related configuration keys
# should have a `CELERY_` prefix.
app.config_from_object("django.conf:settings", namespace="CELERY")
# Load task modules from all registered Django apps.
app.autodiscover_tasks()
app.conf.broker_url = f"redis://redis:6379"
app.conf.result_backend = f"redis://redis:6379"
app.conf.beat_schedule = {
"verify-urls-every-hour": {
"task": "api.tasks.verify_urls_task",
"schedule": timedelta(hours=1),
# "schedule": timedelta(minutes=1),
},
}
app.conf.timezone = "UTC"

141
api/api/settings.py Normal file
View File

@ -0,0 +1,141 @@
"""
Django settings for api project.
Generated by 'django-admin startproject' using Django 5.0.
For more information on this file, see
https://docs.djangoproject.com/en/5.0/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.0/ref/settings/
"""
import os
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = os.getenv("SECRET_KEY", default="insecure")
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = os.getenv("DEBUG", default="False").lower() == "true"
ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", default="localhost,127.0.0.1,[::1]").split(
","
)
# Application definition
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"corsheaders",
"rest_framework",
"v1",
]
MIDDLEWARE = [
"corsheaders.middleware.CorsMiddleware",
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "api.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = "api.wsgi.application"
# Database
# https://docs.djangoproject.com/en/5.0/ref/settings/#databases
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
# Password validation
# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]
# Internationalization
# https://docs.djangoproject.com/en/5.0/topics/i18n/
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.0/howto/static-files/
STATIC_URL = "/static/"
STATIC_ROOT = os.path.join(BASE_DIR, "static")
# Default primary key field type
# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
CSRF_TRUSTED_ORIGINS = os.getenv(
"CSRF_TRUSTED_ORIGINS", default="http://localhost:3000"
).split(",")
CORS_ALLOWED_ORIGINS = os.getenv(
"CORS_ALLOWED_ORIGINS", default="http://localhost:3000"
).split(",")
CORS_ALLOW_ALL_ORIGINS = (
os.getenv("CORS_ALLOW_ALL_ORIGINS", default="False").lower() == "true"
)

7
api/api/tasks.py Normal file
View File

@ -0,0 +1,7 @@
from celery import shared_task
from django.core.management import call_command
@shared_task
def verify_urls_task():
call_command("verify_urls")

29
api/api/urls.py Normal file
View File

@ -0,0 +1,29 @@
"""
URL configuration for api project.
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/5.0/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path, include
from rest_framework import routers
from v1.views import server
router = routers.DefaultRouter()
router.register(r"servers", server.ServerViewSet, "v1")
urlpatterns = [
path("admin/", admin.site.urls),
path("v1/", include(router.urls)),
]

16
api/api/wsgi.py Normal file
View File

@ -0,0 +1,16 @@
"""
WSGI config for api project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.0/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "api.settings")
application = get_wsgi_application()

30
api/docker-compose.yml Normal file
View File

@ -0,0 +1,30 @@
services:
api:
build: .
command: gunicorn api.wsgi:application --bind 0.0.0.0:8000
restart: always
volumes:
- .:/app
ports:
- 8000:8000
depends_on:
- redis
celery:
build: .
command: celery -A api worker -l info
restart: always
volumes:
- .:/app
depends_on:
- redis
celery-beat:
build: .
command: celery -A api beat -l info
restart: always
volumes:
- .:/app
depends_on:
- redis
redis:
image: redis:alpine
restart: always

7
api/entrypoint.sh Executable file
View File

@ -0,0 +1,7 @@
#!/bin/sh
python manage.py collectstatic --noinput
python manage.py makemigrations
python manage.py migrate
exec "$@"

22
api/manage.py Normal file
View File

@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "api.settings")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()

8
api/requirements.txt Normal file
View File

@ -0,0 +1,8 @@
django
djangorestframework
django-cors-headers
requests
celery
redis
python-dotenv
gunicorn

0
api/v1/__init__.py Normal file
View File

11
api/v1/admin.py Normal file
View File

@ -0,0 +1,11 @@
from django.contrib import admin
from .models.server import Server
class ServerAdmin(admin.ModelAdmin):
display = "url, settings, created, updated"
# Register your models here.
admin.site.register(Server, ServerAdmin)

6
api/v1/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class V1Config(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "v1"

View File

4
api/v1/common/logging.py Normal file
View File

@ -0,0 +1,4 @@
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

View File

View File

View File

@ -0,0 +1,25 @@
# api/v1/management/commands/verify_urls.py
from django.core.management.base import BaseCommand
from v1.common.logging import logger
from v1.models.server import Server
from concurrent.futures import ThreadPoolExecutor
class Command(BaseCommand):
help = "Verify URLs and delete invalid Server objects"
def handle(self, *args, **kwargs):
logger.info("Verifying URLs...")
self.verify_urls()
def verify_urls(self):
# Define a function to process each server
def process_server(server):
if not server.save():
logger.info(f"Deleting Server object with URL: {server.url}")
server.delete()
# Use ThreadPoolExecutor to parallelize the task
with ThreadPoolExecutor() as executor:
executor.map(process_server, Server.objects.all())

View File

View File

99
api/v1/models/server.py Normal file
View File

@ -0,0 +1,99 @@
import os
import requests
from django.db import models
from django.core.validators import URLValidator
from django.core.exceptions import ValidationError
from urllib.parse import urlparse
from v1.common.logging import logger
required_settings = [
"MAIN.SERVER_NAME",
"MAIN.MAX_LEVEL",
"LOGIN.LOGIN_LIMIT",
]
class OptionalSchemeURLValidator(URLValidator):
def __call__(self, value):
if "://" not in value:
# Validate as if it were http://
value = "http://" + value
super(OptionalSchemeURLValidator, self).__call__(value)
class Server(models.Model):
url = models.CharField(
max_length=400,
null=False,
validators=[OptionalSchemeURLValidator()],
)
settings = models.JSONField(null=True, editable=False)
active_sessions = models.IntegerField(null=True, editable=False)
created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True)
inactivity_counter = models.IntegerField(null=True, editable=False)
def _str_(self):
return self.url
def save(self, *args, **kwargs):
# Prepend a default scheme if the URL does not include one
if not self.url.startswith(("http://", "https://")):
self.url = f"http://{self.url}"
# Extract the domain name from the validated URL
parsed_url = urlparse(self.url)
self.url = parsed_url.netloc.lower()
# Check if server URL is unique
existing_server = Server.objects.filter(url=self.url).first()
if existing_server and existing_server.pk != self.pk:
raise ValidationError("A server with this URL already exists.")
# Validate the server API
if not self.parse_server_api():
if (
self.inactivity_counter is None
or self.inactivity_counter
>= int(os.getenv("SERVER_INACTIVITY_TIMEOUT", default="24")) - 1
):
return False
self.inactivity_counter += 1
else:
if self.settings["API.DO_NOT_TRACK"]:
return False
self.inactivity_counter = 0
# Proceed with saving the object
logger.info(f"Saving Server object with URL: {self.url}")
super(Server, self).save(*args, **kwargs)
return True
def parse_server_api(self):
try:
# Request server settings from API
response = requests.get(f"http://{self.url}/api/settings")
response.raise_for_status() # Raise an exception for HTTP errors (4xx and 5xx)
json_data = response.json()
# Validate the JSON structure
if not isinstance(json_data, dict):
return False
for setting in required_settings:
if setting not in json_data:
return False
self.settings = json_data
# Request the active session count from API
response = requests.get(f"http://{self.url}/api/sessions")
response.raise_for_status() # Raise an exception for HTTP errors (4xx and 5xx)
session_count = response.text
if session_count.isdigit():
self.active_sessions = int(session_count)
return True
except requests.RequestException:
return False

View File

View File

@ -0,0 +1,15 @@
from rest_framework import serializers
from v1.models.server import Server
from django.core.exceptions import ValidationError
class ServerSerializer(serializers.ModelSerializer):
class Meta:
model = Server
fields = "__all__"
def create(self, validated_data):
try:
return super().create(validated_data)
except ValidationError as e:
raise serializers.ValidationError({"url": e.messages})

3
api/v1/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

0
api/v1/views/__init__.py Normal file
View File

60
api/v1/views/server.py Normal file
View File

@ -0,0 +1,60 @@
from rest_framework import viewsets, mixins
from django.db.models.fields.json import KT
from v1.serializers.server import ServerSerializer
from v1.models.server import Server
class ServerView(
mixins.CreateModelMixin,
mixins.RetrieveModelMixin,
mixins.ListModelMixin,
viewsets.GenericViewSet,
):
serializer_class = ServerSerializer
queryset = Server.objects.all()
class ServerViewSet(
mixins.CreateModelMixin,
mixins.RetrieveModelMixin,
mixins.ListModelMixin,
viewsets.GenericViewSet,
):
queryset = Server.objects.all()
serializer_class = ServerSerializer
CONFIG_MAPPING = {
"name": {
"annotate_field": "settings__MAIN.SERVER_NAME",
"filter_type": "startswith",
},
"level_cap_min": {
"annotate_field": "settings__MAIN.MAX_LEVEL",
"filter_type": "gte",
},
"level_cap_max": {
"annotate_field": "settings__MAIN.MAX_LEVEL",
"filter_type": "lte",
},
}
def get_queryset(self):
queryset = self.queryset
# Iterate through the configuration mapping and apply dynamic annotation and filtering
for param, config in self.CONFIG_MAPPING.items():
value = self.request.query_params.get(param)
if value:
annotate_field = config["annotate_field"]
filter_type = config["filter_type"]
annotate_name = f"{param}_annotated"
# Annotate the queryset with the specified annotation field and filter type
queryset = queryset.annotate(
**{annotate_name: KT(annotate_field)}
).filter(**{f"{annotate_name}__{filter_type}": value})
# TODO: This probably isn't very efficient
queryset._result_cache = None
return queryset

33
client/.eslintrc.cjs Normal file
View File

@ -0,0 +1,33 @@
module.exports = {
env: {
browser: true,
es2021: true,
},
extends: [
'airbnb',
'airbnb-typescript',
'airbnb/hooks',
'plugin:react/recommended',
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
'plugin:tailwindcss/recommended',
],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
project: ['./tsconfig.json', './tsconfig.node.json'],
tsconfigRootDir: __dirname,
},
plugins: [
'react',
'@typescript-eslint',
'prettier',
'tailwindcss',
'react-refresh',
],
rules: {
'react/react-in-jsx-scope': 0,
'react-refresh/only-export-components': 'warn',
},
};

9
client/.prettierrc.cjs Normal file
View File

@ -0,0 +1,9 @@
const config = {
trailingComma: 'es5',
tabWidth: 2,
semi: true,
singleQuote: true,
plugins: ['prettier-plugin-tailwindcss']
};
module.exports = config;

25
client/index.html Normal file
View File

@ -0,0 +1,25 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;600;700&display=swap"
/>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/icon?family=Material+Icons"
/>
<title>IXION - FFXI Private Server Directory</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

8190
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

52
client/package.json Normal file
View File

@ -0,0 +1,52 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"test": "vitest"
},
"dependencies": {
"@emotion/react": "^11.11.3",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.15.6",
"@mui/material": "^5.15.6",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.3"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.1.6",
"@testing-library/react": "^14.1.2",
"@types/jsdom": "^21.1.6",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@typescript-eslint/eslint-plugin": "^6.16.0",
"@typescript-eslint/parser": "^6.16.0",
"@vitejs/plugin-react-swc": "^3.5.0",
"autoprefixer": "^10.4.17",
"eslint": "^8.2.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^17.1.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-import": "^2.25.3",
"eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-prettier": "^5.1.2",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.3.0",
"eslint-plugin-react-refresh": "^0.4.5",
"eslint-plugin-tailwindcss": "^3.14.2",
"jsdom": "^24.0.0",
"postcss": "^8.4.33",
"prettier": "^3.1.1",
"prettier-plugin-tailwindcss": "^0.5.11",
"tailwindcss": "^3.4.1",
"typescript": "^5.2.2",
"vite": "^5.0.8",
"vitest": "^1.1.0"
}
}

6
client/postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

31
client/src/App.test.tsx Normal file
View File

@ -0,0 +1,31 @@
import { render, screen } from '@testing-library/react';
import { describe, it } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import { App, WrappedApp } from './App';
describe('App', () => {
it('Renders hello world', () => {
// ARRANGE
render(<WrappedApp />);
// ACT
// EXPECT
expect(
screen.getByRole('heading', {
level: 1,
})
).toHaveTextContent('Hello, world!');
});
it('Renders NotFound if invalid path', () => {
render(
<MemoryRouter initialEntries={['/idonotexist']}>
<App />
</MemoryRouter>
);
expect(
screen.getByRole('heading', {
level: 1,
})
).toHaveTextContent('Not Found');
});
});

77
client/src/App.tsx Normal file
View File

@ -0,0 +1,77 @@
import { Box, Container } from '@mui/material';
import { useState } from 'react';
import { HashRouter, Route, Routes } from 'react-router-dom';
import AlertComponent from './components/Alert';
import Footer from './components/Footer';
import Header from './components/Header';
import SearchState from './data/SearchState';
import ServerData from './data/ServerData';
import About from './pages/About';
import Home from './pages/Home';
import NotFound from './pages/NotFound';
export function App() {
const [alertInfo, setAlertInfo] = useState<{
message: string;
severity: 'error' | 'warning' | 'info' | 'success';
} | null>(null);
const [servers, setServers] = useState<ServerData[]>([]);
const [searchName, setSearchName] = useState('');
const [searchMultibox, setSearchMultibox] = useState<string[] | null>(null);
const [searchTrusts, setSearchTrusts] = useState<string[] | null>(null);
const [searchLevelSync, setSearchLevelSync] = useState<string[] | null>(null);
const [searchMaxLevel, setSearchMaxLevel] = useState<number[]>([1, 99]);
const [searchExpansions, setSearchExpansions] = useState<string[] | null>(
null
);
const searchState: SearchState = {
name: { value: searchName, setValue: setSearchName },
multibox: { value: searchMultibox, setValue: setSearchMultibox },
trusts: { value: searchTrusts, setValue: setSearchTrusts },
levelSync: { value: searchLevelSync, setValue: setSearchLevelSync },
maxLevel: { value: searchMaxLevel, setValue: setSearchMaxLevel },
expansions: { value: searchExpansions, setValue: setSearchExpansions },
};
return (
<div className="flex min-h-screen flex-col">
<Header
setAlertInfo={setAlertInfo}
servers={servers}
setServers={setServers}
searchState={searchState}
/>
<AlertComponent alertInfo={alertInfo} setAlertInfo={setAlertInfo} />
<Container className="grow p-0">
<Box className="m-2">
<Routes>
<Route
path="/"
element={
<Home
servers={servers}
setServers={setServers}
searchState={searchState}
setAlertInfo={setAlertInfo}
/>
}
/>
<Route path="/about" element={<About />} />
<Route path="*" element={<NotFound />} />
</Routes>
</Box>
</Container>
<Footer />
</div>
);
}
export function WrappedApp() {
return (
<HashRouter>
<App />
</HashRouter>
);
}

85
client/src/apiUtil.tsx Normal file
View File

@ -0,0 +1,85 @@
import { DemoServerData } from './data/ServerData';
export const fetchDemo = async () => {
const repoUrl = 'https://api.github.com/repos/LandSandBoat/server';
try {
const response = await fetch(repoUrl);
if (!response.ok) {
throw new Error(
`(${response.status.toString()} - ${response.statusText || 'unknown'})`
);
}
const gitHubData = await response.json();
const demoData = {
...DemoServerData,
active_sessions: gitHubData.stargazers_count,
updated: gitHubData.updated_at,
};
return demoData;
} catch (err) {
return DemoServerData;
}
};
export const fetchData = async (queryParams = {}) => {
const queryString = new URLSearchParams(queryParams).toString();
const url = `${import.meta.env.PROD ? 'https://api.ixion.dev' : 'http://localhost:8000'}/v1/servers/${queryString ? `?${queryString}` : ''}`;
try {
const response = await fetch(url, {
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
throw new Error(
`(${response.status.toString()} - ${response.statusText || 'unknown'})`
);
}
return await response.json();
} catch (err) {
if (err instanceof Error) {
throw new Error(`${err.message} - ${url}`);
} else {
throw new Error('Something went wrong.');
}
}
};
export const postData = async (inputText: string) => {
try {
const response = await fetch(
`${import.meta.env.PROD ? 'https://api.ixion.dev' : 'http://localhost:8000'}/v1/servers/`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ url: inputText }),
}
);
let message = `Server verification failed!`;
let success = false;
if (!response.ok) {
// bad url or duplicate
if (response.status === 400) {
const responseData = await response.json();
message = responseData.url;
}
return {
message,
success,
};
}
const responseData = await response.json();
if (responseData.id !== null) {
message = 'Added new server!';
success = true;
}
return { message, success, data: responseData };
} catch (err) {
let message = 'An unknown error occurred.';
if (err instanceof Error) {
message = err.message;
}
return { message, success: false };
}
};

View File

@ -0,0 +1,44 @@
/* eslint-disable react/jsx-props-no-spreading */
import { styled } from '@mui/material';
import MuiAccordion, { AccordionProps } from '@mui/material/Accordion';
import MuiAccordionDetails, {
AccordionDetailsProps,
} from '@mui/material/AccordionDetails';
import MuiAccordionSummary, {
AccordionSummaryProps,
} from '@mui/material/AccordionSummary';
export const Accordion = styled((props: AccordionProps) => (
<MuiAccordion disableGutters elevation={0} square {...props} />
))(({ theme }) => ({
border: `1px solid ${theme.palette.divider}`,
'&:not(:last-child)': {
border: 0,
},
'&::before': {
display: 'none',
},
}));
export const AccordionSummary = styled((props: AccordionSummaryProps) => (
<MuiAccordionSummary {...props} />
))(({ theme }) => ({
backgroundColor:
theme.palette.mode === 'dark'
? 'rgba(255, 255, 255, .05)'
: 'rgba(0, 0, 0, .02)',
'& .MuiAccordionSummary-content': {
margin: theme.spacing(0),
},
boxShadow: '0 0 10px rgba(0, 0, 0, 0.1)', // Adjust the size and opacity as needed
minHeight: '32px',
}));
export const AccordionDetails = styled((props: AccordionDetailsProps) => (
<MuiAccordionDetails {...props} />
))(({ theme }) => ({
backgroundColor:
theme.palette.mode === 'dark'
? 'rgba(255, 255, 255, .03)'
: 'rgba(0, 0, 0, .04)',
}));

View File

@ -0,0 +1,155 @@
import { Add } from '@mui/icons-material';
import {
Box,
CircularProgress,
IconButton,
InputBase,
Slide,
Tooltip,
alpha,
styled,
} from '@mui/material';
import { useRef, useState } from 'react';
import { postData } from '../apiUtil';
import ServerData from '../data/ServerData';
import { AlertResponse } from './Alert';
const AddServerInput = styled('div')(({ theme }) => ({
position: 'relative',
borderRadius: theme.shape.borderRadius,
backgroundColor: alpha(theme.palette.common.white, 0.15),
'&:hover': {
backgroundColor: alpha(theme.palette.common.white, 0.25),
},
marginRight: theme.spacing(2),
marginLeft: 0,
width: '100%',
[theme.breakpoints.up('sm')]: {
marginLeft: theme.spacing(3),
width: 'auto',
},
}));
const StyledInputBase = styled(InputBase)(({ theme }) => ({
color: 'inherit',
'& .MuiInputBase-input': {
padding: theme.spacing(1, 1, 1, 1),
transition: theme.transitions.create('width'),
width: '100%',
[theme.breakpoints.up('md')]: {
width: '20ch',
},
},
}));
export default function AddServer({
setAlertInfo,
servers,
setServers,
}: {
setAlertInfo: React.Dispatch<React.SetStateAction<AlertResponse>>;
servers: ServerData[];
setServers: React.Dispatch<React.SetStateAction<ServerData[]>>;
}) {
const containerRef = useRef<HTMLElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [isLoading, setIsLoading] = useState(false);
const [showAddServer, setShowAddServer] = useState(false);
const toggleShowAddServer = () => {
setShowAddServer((prev) => !prev);
};
const handleSubmit = async (event: React.KeyboardEvent<HTMLFormElement>) => {
event.preventDefault();
if (inputRef.current) {
const inputText = inputRef.current.value;
setIsLoading(true);
const response = await postData(inputText);
if (response.success) {
setAlertInfo({
message: response.message,
severity: 'success',
});
if (response.data && typeof response.data === 'object') {
setServers([response.data, ...servers]);
}
if (inputRef.current) {
inputRef.current.value = '';
toggleShowAddServer();
}
} else {
setAlertInfo({
message: response.message || 'Failed to send data to backend.',
severity: 'error',
});
}
setIsLoading(false);
}
};
const handleKeyUp = async (event: React.KeyboardEvent<HTMLInputElement>) => {
switch (event.key) {
case 'Escape': {
if (inputRef.current) {
if (inputRef.current.value === '') {
toggleShowAddServer();
} else {
inputRef.current.value = '';
}
}
break;
}
default:
}
};
return (
<>
<Box ref={containerRef} className="overflow-hidden pl-4">
<Slide
in={showAddServer}
direction="left"
container={containerRef.current}
>
<AddServerInput>
<form onSubmit={handleSubmit}>
<StyledInputBase
inputRef={inputRef}
placeholder="URL"
inputProps={{ 'aria-label': 'add-server' }}
onKeyUp={handleKeyUp}
disabled={isLoading}
endAdornment={
isLoading && (
<CircularProgress
size={20}
sx={{
position: 'absolute',
right: '10px',
color: (theme) => alpha(theme.palette.grey[500], 0.5),
}}
/>
)
}
/>
</form>
</AddServerInput>
</Slide>
</Box>
<Tooltip title="Add a server." arrow disableInteractive>
<IconButton
onClick={() => {
toggleShowAddServer();
setTimeout(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, 300);
}}
>
<Add />
</IconButton>
</Tooltip>
</>
);
}

View File

@ -0,0 +1,32 @@
import { Alert, Box, Collapse } from '@mui/material';
export type AlertResponse = {
message: string;
severity: 'error' | 'warning' | 'info' | 'success';
} | null;
export default function AlertComponent({
alertInfo,
setAlertInfo,
}: {
alertInfo: AlertResponse;
setAlertInfo: React.Dispatch<React.SetStateAction<AlertResponse>>;
}) {
return (
<Box className="m-0">
{alertInfo && (
<Collapse in>
<Alert
severity={alertInfo.severity}
onClose={() => setAlertInfo(null)}
variant="filled"
className="py-0"
square
>
{alertInfo.message}
</Alert>
</Collapse>
)}
</Box>
);
}

View File

@ -0,0 +1,16 @@
import { Card, CardContent, Typography } from '@mui/material';
export default function ErrorCard({ error }: { error: string }) {
return (
<Card className="mb-2">
<CardContent className="p-4">
<Typography align="center" variant="h6">
¯\_(ツ)_/¯
</Typography>
<Typography align="center" variant="subtitle1">
{error}
</Typography>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,32 @@
import { Brightness4, Brightness7, QuestionMark } from '@mui/icons-material';
import { AppBar, Box, Toolbar, Tooltip, useTheme } from '@mui/material';
import IconButton from '@mui/material/IconButton';
import { useContext } from 'react';
import { Link } from 'react-router-dom';
import { ThemeContext } from '../context/ThemeContext';
function scrollToTop() {
window.scrollTo({ top: 0, behavior: 'smooth' }); // Scrolls to the top of the page smoothly
}
export default function Footer() {
const theme = useTheme();
const { switchColorMode } = useContext(ThemeContext);
return (
<Box className="bottom-0 w-full flex-none">
<AppBar position="relative">
<Toolbar className="min-h-min justify-end">
<Tooltip title="About" arrow disableInteractive>
<IconButton component={Link} to="/about" onClick={scrollToTop}>
<QuestionMark />
</IconButton>
</Tooltip>
<IconButton onClick={switchColorMode}>
{theme.palette.mode === 'dark' ? <Brightness7 /> : <Brightness4 />}
</IconButton>
</Toolbar>
</AppBar>
</Box>
);
}

View File

@ -0,0 +1,115 @@
import { Refresh, Search } from '@mui/icons-material';
import {
AppBar,
Box,
LinearProgress,
Toolbar,
Tooltip,
Typography,
} from '@mui/material';
import IconButton from '@mui/material/IconButton';
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { fetchData, fetchDemo } from '../apiUtil';
import SearchState from '../data/SearchState';
import ServerData from '../data/ServerData';
import AddServer from './AddServer';
import { AlertResponse } from './Alert';
import SearchServers from './SearchServers';
export default function Header({
setAlertInfo,
servers,
setServers,
searchState,
}: {
setAlertInfo: React.Dispatch<React.SetStateAction<AlertResponse>>;
servers: ServerData[];
setServers: React.Dispatch<React.SetStateAction<ServerData[]>>;
searchState: SearchState;
}) {
const [fetchLoading, setFetchLoading] = useState(0);
const [showSearchServer, setShowSearchServer] = useState(false);
const toggleShowSearchServer = () => {
setShowSearchServer((prev) => !prev);
};
const fetchServerData = async () => {
let data: ServerData[] = [];
try {
setFetchLoading(25);
data = await fetchData();
} catch (err) {
if (err instanceof Error) {
setAlertInfo({
message: err.message,
severity: 'error',
});
} else {
setAlertInfo({
message: 'An unknown error occurred.',
severity: 'error',
});
}
}
setFetchLoading(50);
if (data.length === 0) {
data.push(await fetchDemo());
}
setFetchLoading(75);
setServers(data);
setFetchLoading(100);
setTimeout(() => {
setFetchLoading(0);
}, 500);
};
return (
<Box>
<AppBar position="static">
<Toolbar className="min-h-min py-1">
<Typography
component={Link}
to="/"
variant="h6"
sx={{
color: 'inherit',
textDecoration: 'none',
userSelect: 'none',
flexGrow: 1,
}}
>
IXION
</Typography>
<AddServer
setAlertInfo={setAlertInfo}
servers={servers}
setServers={setServers}
/>
<Tooltip arrow disableInteractive title="Refresh server data.">
<IconButton onClick={fetchServerData} disabled={fetchLoading !== 0}>
<Refresh />
</IconButton>
</Tooltip>
<Tooltip arrow disableInteractive title="Filter servers.">
<IconButton onClick={toggleShowSearchServer}>
<Search />
</IconButton>
</Tooltip>
</Toolbar>
</AppBar>
<LinearProgress
variant="determinate"
value={fetchLoading}
sx={{
visibility: fetchLoading === 0 ? 'hidden' : 'visible',
height: fetchLoading === 0 ? 0 : '1px',
}}
/>
<SearchServers
showSearchServer={showSearchServer}
searchState={searchState}
/>
</Box>
);
}

View File

@ -0,0 +1,429 @@
import {
Container,
Grid,
Input,
Slider,
TextField,
ToggleButton,
ToggleButtonGroup,
Tooltip,
Typography,
} from '@mui/material';
import { useEffect, useRef, useState } from 'react';
import SearchState from '../data/SearchState';
export default function SearchServers({
showSearchServer,
searchState,
}: {
showSearchServer: boolean;
searchState: SearchState;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const [contentHeight, setContentHeight] = useState(0);
const minDistance = 0;
useEffect(() => {
if (showSearchServer && containerRef.current) {
setContentHeight(containerRef.current.scrollHeight);
} else {
setContentHeight(0);
}
}, [showSearchServer]);
const handleSearchName = (event: React.ChangeEvent<HTMLInputElement>) => {
searchState.name.setValue(event.target.value);
};
const handleSearchMultibox = (
_event: React.MouseEvent<HTMLElement>,
newSearchMultibox: string[]
) => {
searchState.multibox.setValue(newSearchMultibox);
};
const handleSearchTrusts = (
_event: React.MouseEvent<HTMLElement>,
newSearchTrusts: string[]
) => {
searchState.trusts.setValue(newSearchTrusts);
};
const handleSearchLevelSync = (
_event: React.MouseEvent<HTMLElement>,
newSearchLevelSync: string[]
) => {
searchState.levelSync.setValue(newSearchLevelSync);
};
const handleSearchMaxLevelMin = (
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
let newValue = parseInt(event.target.value, 10);
if (Number.isNaN(newValue) || newValue < 1) {
newValue = 1;
} else if (newValue > 99) {
newValue = 99;
}
searchState.maxLevel.setValue([
Math.min(newValue, searchState.maxLevel.value[1] - minDistance),
searchState.maxLevel.value[1],
]);
};
const handleSearchMaxLevelMax = (
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
let newValue = parseInt(event.target.value, 10);
if (Number.isNaN(newValue) || newValue < 1) {
newValue = 1;
} else if (newValue > 99) {
newValue = 99;
}
searchState.maxLevel.setValue([
searchState.maxLevel.value[0],
Math.max(newValue, searchState.maxLevel.value[0] + minDistance),
]);
};
const handleSearchMaxLevel = (
_event: Event,
newValue: number | number[],
activeThumb: number
) => {
if (!Array.isArray(newValue)) {
return;
}
if (activeThumb === 0) {
searchState.maxLevel.setValue([
Math.min(newValue[0], searchState.maxLevel.value[1] - minDistance),
searchState.maxLevel.value[1],
]);
} else {
searchState.maxLevel.setValue([
searchState.maxLevel.value[0],
Math.max(newValue[1], searchState.maxLevel.value[0] + minDistance),
]);
}
};
const handleSearchExpansions = (
_event: React.MouseEvent<HTMLElement>,
newSearchExpansions: string[]
) => {
if (newSearchExpansions.length === 0) {
searchState.expansions.setValue(null);
} else if (
searchState.expansions.value &&
searchState.expansions.value.includes('none') &&
newSearchExpansions.length > 1
) {
searchState.expansions.setValue(
newSearchExpansions.filter((item) => item !== 'none')
);
} else if (
searchState.expansions.value &&
!searchState.expansions.value.includes('none') &&
newSearchExpansions.includes('none')
) {
searchState.expansions.setValue(['none']);
} else {
searchState.expansions.setValue(newSearchExpansions);
}
};
return (
<Container
ref={containerRef}
className="overflow-hidden"
sx={{
transition: 'all 0.3s ease',
maxHeight: showSearchServer ? contentHeight : 0,
backgroundColor: (theme) =>
theme.palette.mode === 'dark'
? 'rgba(255, 255, 255, .06)'
: 'rgba(0, 0, 0, .06)',
}}
>
<Grid container spacing={2} className="py-2">
{/* Name */}
<Grid item xs={12}>
<TextField
value={searchState.name.value}
onChange={handleSearchName}
variant="standard"
autoComplete="false"
fullWidth
label="Name"
size="small"
className="pb-2"
/>
</Grid>
{/* Max Level */}
<Grid
item
xs={12}
className="pt-0"
display="flex"
flexDirection="column"
alignItems="center"
>
<Typography id="max-level-slider" variant="caption">
Max Level
</Typography>
</Grid>
<Grid item xs={12} className="flex pt-0">
<Input
value={searchState.maxLevel.value[0]}
size="small"
onChange={handleSearchMaxLevelMin}
// onBlur={handleBlur}
inputProps={{
step: 1,
min: 1,
max: 99,
type: 'number',
'aria-labelledby': 'max-level-min',
}}
/>
<Slider
aria-labelledby="max-level-slider"
value={searchState.maxLevel.value}
onChange={handleSearchMaxLevel}
valueLabelDisplay="auto"
disableSwap
className="mx-4"
size="small"
min={1}
max={99}
/>
<Input
value={searchState.maxLevel.value[1]}
size="small"
onChange={handleSearchMaxLevelMax}
// onBlur={handleBlur}
inputProps={{
step: 1,
min: 1,
max: 99,
type: 'number',
'aria-labelledby': 'max-level-max',
}}
/>
</Grid>
{/* Expansion */}
<Grid
item
xs={12}
display="flex"
flexDirection="column"
alignItems="center"
>
<Tooltip
title="Enabled expansions, exact match."
arrow
disableInteractive
placement="top"
>
<Typography id="expansions-button-group" variant="caption">
Expansions
</Typography>
</Tooltip>
<ToggleButtonGroup
value={searchState.expansions.value}
onChange={handleSearchExpansions}
size="small"
aria-labelledby="expansions-button-group"
sx={{
display: 'flex',
width: '100%',
}}
>
<ToggleButton
key="none"
value="none"
aria-label="none-enabled"
className="py-0"
sx={{ width: '20%' }}
>
None
</ToggleButton>
<ToggleButton
key="rotz"
value="rotz"
aria-label="rotz-enabled"
className="py-0"
sx={{ width: '20%' }}
>
RotZ
</ToggleButton>
<ToggleButton
key="cop"
value="cop"
aria-label="cop-enabled"
className="py-0"
sx={{ width: '20%' }}
>
CoP
</ToggleButton>
<ToggleButton
key="toau"
value="toau"
aria-label="toau-enabled"
className="py-0"
sx={{ width: '20%' }}
>
ToAU
</ToggleButton>
<ToggleButton
key="wotg"
value="wotg"
aria-label="wotg-enabled"
className="py-0"
sx={{ width: '20%' }}
>
WotG
</ToggleButton>
<ToggleButton
key="soa"
value="soa"
aria-label="soa-enabled"
className="py-0"
sx={{ width: '20%' }}
>
SoA
</ToggleButton>
</ToggleButtonGroup>
</Grid>
{/* Multiboxing */}
<Grid
item
xs={12}
display="flex"
flexDirection="column"
alignItems="center"
>
<Typography id="multibox-button-group" variant="caption">
Multiboxing
</Typography>
<ToggleButtonGroup
value={searchState.multibox.value}
onChange={handleSearchMultibox}
size="small"
aria-labelledby="multibox-button-group"
sx={{
display: 'flex',
width: '100%',
}}
exclusive
>
<ToggleButton
value="none"
aria-label="no multiboxing"
className="py-0"
sx={{ width: '33.33%' }}
>
None
</ToggleButton>
<ToggleButton
value="limited"
aria-label="limited multiboxing"
className="py-0"
sx={{ width: '33.33%' }}
>
Limited
</ToggleButton>
<ToggleButton
value="unlimited"
aria-label="unlimited multiboxing"
className="py-0"
sx={{ width: '33.33%' }}
>
Unlimited
</ToggleButton>
</ToggleButtonGroup>
</Grid>
{/* Trusts */}
<Grid
item
xs={6}
display="flex"
flexDirection="column"
alignItems="center"
>
<Typography id="trust-button-group" variant="caption">
Trusts
</Typography>
<ToggleButtonGroup
value={searchState.trusts.value}
onChange={handleSearchTrusts}
size="small"
aria-labelledby="trust-button-group"
exclusive
sx={{
display: 'flex',
width: '100%',
}}
>
<ToggleButton
value="disabled"
aria-label="trust disabled"
className="py-0"
sx={{ width: '50%' }}
>
Disabled
</ToggleButton>
<ToggleButton
value="enabled"
aria-label="trust enabled"
className="py-0"
sx={{ width: '50%' }}
>
Enabled
</ToggleButton>
</ToggleButtonGroup>
</Grid>
{/* Level Sync */}
<Grid
item
xs={6}
display="flex"
flexDirection="column"
alignItems="center"
>
<Typography id="level-sync-button-group" variant="caption">
Level Sync
</Typography>
<ToggleButtonGroup
value={searchState.levelSync.value}
onChange={handleSearchLevelSync}
size="small"
aria-labelledby="level-sync-button-group"
exclusive
sx={{
display: 'flex',
width: '100%',
}}
>
<ToggleButton
value="disabled"
aria-label="level-sync-disabled"
className="py-0"
sx={{ width: '50%' }}
>
Disabled
</ToggleButton>
<ToggleButton
value="enabled"
aria-label="level-sync-enabled"
className="py-0"
sx={{ width: '50%' }}
>
Enabled
</ToggleButton>
</ToggleButtonGroup>
</Grid>
</Grid>
</Container>
);
}

View File

@ -0,0 +1,338 @@
import {
ContentCopy,
ExpandMore,
Launch,
Public,
PublicOff,
Warning,
} from '@mui/icons-material';
import {
Box,
Card,
CardContent,
Divider,
IconButton,
Table,
TableBody,
TableCell,
TableContainer,
TableRow,
ToggleButton,
ToggleButtonGroup,
Tooltip,
Typography,
alpha,
} from '@mui/material';
import { useState } from 'react';
import { Link } from 'react-router-dom';
import ServerData, { ServerSettingsInfo } from '../data/ServerData';
import { Accordion, AccordionDetails, AccordionSummary } from './Accordion';
export default function ServerCard({ server }: { server: ServerData }) {
const [clipboardTooltip, setClipboardTooltip] = useState('Copy server URL.');
const [clipboardTooltipOpen, setClipboardTooltipOpen] = useState(false);
const handleClipboardTooltipClose = () => {
setClipboardTooltipOpen(false);
};
const handleClipboardTooltipOpen = () => {
setClipboardTooltipOpen(true);
};
const copyServerUrlToClipboard = async (text: string) => {
try {
navigator.clipboard.writeText(text);
setClipboardTooltip('Copied server URL!');
handleClipboardTooltipOpen();
setTimeout(() => {
handleClipboardTooltipClose();
setClipboardTooltip('Copy Server URL.');
}, 3000);
} catch (error) {
handleClipboardTooltipOpen();
if (error instanceof Error) {
setClipboardTooltip(error.message);
} else {
setClipboardTooltip('Something went wrong!');
}
setTimeout(() => {
handleClipboardTooltipClose();
setClipboardTooltip('Copy Server URL.');
}, 3000);
}
};
function formatExternalUrl(url: string): string {
// Check if the URL starts with a valid protocol
if (!/^https?:\/\//i.test(url)) {
// If not, prepend 'https://' to the URL
return `https://${url}`;
}
// Otherwise, return the original URL
return url;
}
const expansions = [
<ToggleButton
key="rotz"
value="rotz"
aria-label="rotz-enabled"
className="py-0"
disabled
>
RotZ
</ToggleButton>,
<ToggleButton
key="cop"
value="cop"
aria-label="cop-enabled"
className="py-0"
disabled
>
CoP
</ToggleButton>,
<ToggleButton
key="toau"
value="toau"
aria-label="toau-enabled"
className="py-0"
disabled
>
ToAU
</ToggleButton>,
<ToggleButton
key="wotg"
value="wotg"
aria-label="wotg-enabled"
className="py-0"
disabled
>
WotG
</ToggleButton>,
<ToggleButton
key="soa"
value="soa"
aria-label="soa-enabled"
className="py-0"
disabled
>
SoA
</ToggleButton>,
];
return (
<Card className="mb-2">
<Accordion className="my-0">
<AccordionSummary expandIcon={<ExpandMore />}>
<Box className="flex items-center justify-center">
{server.settings['LOGIN.MAINT_MODE'] === 1 ? (
<Tooltip
arrow
disableInteractive
title="Server is undergoing maintenance."
>
<Warning color="warning" />
</Tooltip>
) : (
<div>
{server.inactivity_counter > 0 ? (
<Tooltip
arrow
disableInteractive
title={`Offline for ${server.inactivity_counter} hours.`}
>
<PublicOff color="error" />
</Tooltip>
) : (
<Tooltip arrow disableInteractive title="Server is online.">
<Public color="success" />
</Tooltip>
)}
</div>
)}
</Box>
<CardContent className="grow py-1">
<Box className="flex content-center">
<Typography
variant="h5"
color={(theme) => alpha(theme.palette.text.primary, 0.87)}
sx={{ lineHeight: 1.0 }}
>
{server.settings['MAIN.SERVER_NAME']}
</Typography>
{typeof server.settings['API.WEBSITE'] === 'string' &&
server.settings['API.WEBSITE'] !== '' && (
<Tooltip arrow disableInteractive title="Visit website.">
<IconButton
component={Link}
to={formatExternalUrl(server.settings['API.WEBSITE'])}
target="_blank"
rel="noopener"
size="small"
className="py-0"
onClick={(event) => {
event.stopPropagation();
}}
disableRipple
>
<Launch
sx={{
fontSize: (theme) =>
theme.typography.caption.fontSize,
}}
/>
</IconButton>
</Tooltip>
)}
</Box>
<Box className="flex content-center">
<Typography
variant="subtitle2"
color={(theme) => alpha(theme.palette.text.primary, 0.6)}
>
{server.url}
</Typography>
{window.isSecureContext && (
<Tooltip
arrow
disableInteractive
title={clipboardTooltip}
open={
clipboardTooltip === 'Copied server URL!' ||
clipboardTooltipOpen
}
onOpen={handleClipboardTooltipOpen}
onClose={handleClipboardTooltipClose}
>
<IconButton
size="small"
className="py-0"
onClick={(event) => {
event.stopPropagation();
copyServerUrlToClipboard(server.url);
}}
disableRipple
>
<ContentCopy
sx={{
fontSize: (theme) => theme.typography.caption.fontSize,
}}
/>
</IconButton>
</Tooltip>
)}
</Box>
<ToggleButtonGroup
size="small"
value={[
server.settings['LOGIN.RISE_OF_ZILART'] && 'rotz',
server.settings['LOGIN.CHAINS_OF_PROMATHIA'] && 'cop',
server.settings['LOGIN.TREASURES_OF_AHT_URGHAN'] && 'toau',
server.settings['LOGIN.WINGS_OF_THE_GODDESS'] && 'wotg',
server.settings['LOGIN.SEEKERS_OF_ADOULIN'] && 'soa',
]}
sx={{ '& button': { lineHeight: 1.0 } }}
>
{expansions}
</ToggleButtonGroup>
</CardContent>
<Box className="flex items-center justify-center">
<Typography
variant="h5"
color={(theme) => theme.palette.text.secondary}
>
{`Lv.${server.settings['MAIN.MAX_LEVEL']}`}
</Typography>
</Box>
</AccordionSummary>
<AccordionDetails className="p-2">
<TableContainer>
<Table size="small">
<TableBody>
<TableRow>
<TableCell>
<Tooltip
title={
ServerSettingsInfo['LOGIN.LOGIN_LIMIT'].description
}
arrow
disableInteractive
>
<Typography variant="caption" sx={{ userSelect: 'none' }}>
{`${ServerSettingsInfo['LOGIN.LOGIN_LIMIT'].name}: `}
{server.settings['LOGIN.LOGIN_LIMIT'] === 0
? 'unlimited'
: server.settings['LOGIN.LOGIN_LIMIT']}
</Typography>
</Tooltip>
</TableCell>
<TableCell>
<Tooltip
title={
ServerSettingsInfo['MAIN.ENABLE_TRUST_CASTING']
.description
}
arrow
disableInteractive
>
<Typography variant="caption" sx={{ userSelect: 'none' }}>
{`${ServerSettingsInfo['MAIN.ENABLE_TRUST_CASTING'].name}: `}
{server.settings['MAIN.ENABLE_TRUST_CASTING'] === 1
? 'Enabled'
: 'Disabled'}
</Typography>
</Tooltip>
</TableCell>
<TableCell>
<Tooltip
title={
ServerSettingsInfo['MAP.LEVEL_SYNC_ENABLE'].description
}
arrow
disableInteractive
>
<Typography variant="caption" sx={{ userSelect: 'none' }}>
{`${ServerSettingsInfo['MAP.LEVEL_SYNC_ENABLE'].name}: `}
{server.settings['MAP.LEVEL_SYNC_ENABLE']
? 'Enabled'
: 'Disabled'}
</Typography>
</Tooltip>
</TableCell>
</TableRow>
<TableRow sx={{ '& > *': { borderBottom: 'unset' } }}>
<TableCell>
<Tooltip
title={ServerSettingsInfo['MAP.SPEED_MOD'].description}
arrow
disableInteractive
>
<Typography variant="caption" sx={{ userSelect: 'none' }}>
{`${ServerSettingsInfo['MAP.SPEED_MOD'].name}: `}
{typeof server.settings['MAP.SPEED_MOD'] === 'number'
? (server.settings['MAP.SPEED_MOD'] < 0 ? '' : '+') +
((50 + server.settings['MAP.SPEED_MOD']) / 50 - 1) *
100
: '???'}
%
</Typography>
</Tooltip>
</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
</AccordionDetails>
</Accordion>
<Divider />
<CardContent className="flex justify-between py-1">
<Typography variant="caption" sx={{ userSelect: 'none' }}>
{server.active_sessions} active sessions
</Typography>
<Typography variant="caption" sx={{ userSelect: 'none' }}>
Updated: {new Date(server.updated).toLocaleString()}
</Typography>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,73 @@
import { useMediaQuery } from '@mui/material';
import { createTheme, StyledEngineProvider } from '@mui/material/styles';
import ThemeProvider from '@mui/material/styles/ThemeProvider';
import { createContext, ReactNode, useMemo, useState } from 'react';
type ThemeContextType = {
switchColorMode: () => void;
};
type ThemeProviderProps = {
children: ReactNode;
};
export const ThemeContext = createContext<ThemeContextType>({
switchColorMode: () => {},
});
export function ThemeContextProvider({ children }: ThemeProviderProps) {
const rootElement = document.getElementById('root');
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
const [mode, setMode] = useState<'light' | 'dark'>(
prefersDarkMode ? 'dark' : 'light'
);
const switchColorMode = () => {
setMode((prevMode) => (prevMode === 'light' ? 'dark' : 'light'));
};
const theme = useMemo(
() =>
createTheme({
palette: {
mode,
},
// All `Portal`-related components need to have the the main app wrapper element as a container
// so that they are in the subtree under the element used in the `important` option of the Tailwind's config.
components: {
MuiPopover: {
defaultProps: {
container: rootElement,
},
},
MuiPopper: {
defaultProps: {
container: rootElement,
},
},
MuiDialog: {
defaultProps: {
container: rootElement,
},
},
MuiModal: {
defaultProps: {
container: rootElement,
},
},
},
}),
[rootElement, mode]
);
return (
<StyledEngineProvider injectFirst>
<ThemeContext.Provider
value={
// eslint-disable-next-line react/jsx-no-constructed-context-values
{ switchColorMode }
}
>
<ThemeProvider theme={theme}>{children}</ThemeProvider>
</ThemeContext.Provider>
</StyledEngineProvider>
);
}

View File

@ -0,0 +1,13 @@
interface SearchStateTemplate<T> {
value: T;
setValue: React.Dispatch<React.SetStateAction<T>>;
}
export default interface SearchState {
name: SearchStateTemplate<string>;
multibox: SearchStateTemplate<string[] | null>;
trusts: SearchStateTemplate<string[] | null>;
levelSync: SearchStateTemplate<string[] | null>;
maxLevel: SearchStateTemplate<number[]>;
expansions: SearchStateTemplate<string[] | null>;
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,301 @@
{
"LOGIN.ACCOUNT_CREATION": true,
"LOGIN.A_CRYSTALLINE_PROPHECY": true,
"LOGIN.A_MOOGLE_KUPOD_ETAT": true,
"LOGIN.A_SHANTOTTO_ASCENSION": true,
"LOGIN.CHAINS_OF_PROMATHIA": true,
"LOGIN.CHARACTER_CREATION": true,
"LOGIN.CHARACTER_DELETION": true,
"LOGIN.CLIENT_VER": "30240327_0",
"LOGIN.DISABLE_MOB_NPC_CHAR_NAMES": false,
"LOGIN.HEROES_OF_ABYSSEA": true,
"LOGIN.LOGIN_LIMIT": 0,
"LOGIN.LOG_USER_IP": false,
"LOGIN.MAINT_MODE": 0,
"LOGIN.MOG_WARDROBE_3": true,
"LOGIN.MOG_WARDROBE_4": true,
"LOGIN.MOG_WARDROBE_5": true,
"LOGIN.MOG_WARDROBE_6": true,
"LOGIN.MOG_WARDROBE_7": true,
"LOGIN.MOG_WARDROBE_8": true,
"LOGIN.RISE_OF_ZILART": true,
"LOGIN.SCARS_OF_ABYSSEA": true,
"LOGIN.SECURE_TOKEN": false,
"LOGIN.SEEKERS_OF_ADOULIN": true,
"LOGIN.TREASURES_OF_AHT_URGHAN": true,
"LOGIN.VER_LOCK": 2,
"LOGIN.VISIONS_OF_ABYSSEA": true,
"LOGIN.WINGS_OF_THE_GODDESS": true,
"MAIN.ABSORB_SPELL_AMOUNT": 8,
"MAIN.ABSORB_SPELL_TICK": 9,
"MAIN.ABYSSEA_BONUSLIGHT_AMOUNT": 0,
"MAIN.ABYSSEA_LIGHTS_DROP_RATE": 80,
"MAIN.ACTIVATE_LAMP_TIME": 6000,
"MAIN.ADVANCED_JOB_LEVEL": 30,
"MAIN.AF1_QUEST_LEVEL": 40,
"MAIN.AF2_QUEST_LEVEL": 50,
"MAIN.AF3_QUEST_LEVEL": 50,
"MAIN.ALLOW_MULTIPLE_EXP_RINGS": 0,
"MAIN.ALL_MAPS": 0,
"MAIN.AQUAVEIL_COUNTER": 1,
"MAIN.ASSAULT_MINIMUM": 1,
"MAIN.BAYLD_RATE": 1,
"MAIN.BETWEEN_2DYNA_WAIT_TIME": 24,
"MAIN.BIO_OVERWRITE": 0,
"MAIN.BLINK_SHADOWS": 2,
"MAIN.BLUE_POWER": 1,
"MAIN.BOOK_EXP_RATE": 1,
"MAIN.BYPASS_EXP_RING_ONE_PER_WEEK": 0,
"MAIN.CAPACITY_RATE": 1,
"MAIN.CAP_CURRENCY_ACCOLADES": 99999,
"MAIN.CAP_CURRENCY_BALLISTA": 2000,
"MAIN.CAP_CURRENCY_SPARKS": 99999,
"MAIN.CAP_CURRENCY_VALOR": 50000,
"MAIN.CASKET_DROP_RATE": 0.1,
"MAIN.CHEST_MAX_ILLUSION_TIME": 3600,
"MAIN.CHEST_MIN_ILLUSION_TIME": 1800,
"MAIN.CHOCOBO_RAISING_DISABLE_RETIREMENT": false,
"MAIN.CHOCOBO_RAISING_GIL_MULTIPLIER": 1,
"MAIN.CHOCOBO_RAISING_STAT_GROWTH_CAP": 512,
"MAIN.CHOCOBO_RAISING_STAT_NEG_MULTIPLIER": 1,
"MAIN.CHOCOBO_RAISING_STAT_POS_MULTIPLIER": 1,
"MAIN.COFFER_MAX_ILLUSION_TIME": 3600,
"MAIN.COFFER_MIN_ILLUSION_TIME": 1800,
"MAIN.COSMO_CLEANSE_BASE_COST": 15000,
"MAIN.CURE_POWER": 1,
"MAIN.CURRENCY_EXCHANGE_RATE": 100,
"MAIN.DAILY_TALLY_AMOUNT": 10,
"MAIN.DAILY_TALLY_LIMIT": 50000,
"MAIN.DARK_POWER": 1,
"MAIN.DEBUG_CHOCOBO_RAISING": false,
"MAIN.DIA_OVERWRITE": 1,
"MAIN.DIGGING_RATE": 85,
"MAIN.DIG_ABUNDANCE_BONUS": 0,
"MAIN.DIG_FATIGUE": 1,
"MAIN.DIG_GRANT_BORE": 0,
"MAIN.DIG_GRANT_BURROW": 0,
"MAIN.DISABLE_INACTIVITY_WATCHDOG": false,
"MAIN.DISABLE_PARTY_EXP_PENALTY": false,
"MAIN.DIVINE_POWER": 1,
"MAIN.DYNA_LEVEL_MIN": 65,
"MAIN.DYNA_MIDNIGHT_RESET": true,
"MAIN.ELEMENTAL_DEBUFF_DURATION": 120,
"MAIN.ELEMENTAL_POWER": 1,
"MAIN.ENABLE_ABYSSEA": 1,
"MAIN.ENABLE_ACP": 1,
"MAIN.ENABLE_AMK": 1,
"MAIN.ENABLE_ASA": 1,
"MAIN.ENABLE_CHOCOBO_RAISING": false,
"MAIN.ENABLE_COP": 1,
"MAIN.ENABLE_COP_ZONE_CAP": 0,
"MAIN.ENABLE_DAILY_TALLY": 1,
"MAIN.ENABLE_EXCHANGE_100S_TO_1S": false,
"MAIN.ENABLE_EXCHANGE_LIMIT": 1,
"MAIN.ENABLE_FIELD_MANUALS": 1,
"MAIN.ENABLE_GARRISON": true,
"MAIN.ENABLE_GROUNDS_TOMES": 1,
"MAIN.ENABLE_IMMUNOBREAK": true,
"MAIN.ENABLE_LOGIN_CAMPAIGN": 0,
"MAIN.ENABLE_MAGIAN_TRIALS": 1,
"MAIN.ENABLE_MONSTROSITY": 0,
"MAIN.ENABLE_NYZUL_CASKETS": true,
"MAIN.ENABLE_ROE": 1,
"MAIN.ENABLE_ROE_TIMED": 1,
"MAIN.ENABLE_ROV": 1,
"MAIN.ENABLE_SOA": 1,
"MAIN.ENABLE_SURVIVAL_GUIDE": 1,
"MAIN.ENABLE_TOAU": 1,
"MAIN.ENABLE_TRUST_ALTER_EGO_EXPO": 0,
"MAIN.ENABLE_TRUST_ALTER_EGO_EXPO_ANNOUNCE": 0,
"MAIN.ENABLE_TRUST_ALTER_EGO_EXTRAVAGANZA": 0,
"MAIN.ENABLE_TRUST_ALTER_EGO_EXTRAVAGANZA_ANNOUNCE": 0,
"MAIN.ENABLE_TRUST_CASTING": 1,
"MAIN.ENABLE_TRUST_CUSTOM_ENGAGEMENT": 0,
"MAIN.ENABLE_TRUST_QUESTS": 1,
"MAIN.ENABLE_TVR": 1,
"MAIN.ENABLE_VIGIL_DROPS": true,
"MAIN.ENABLE_VOIDWALKER": 1,
"MAIN.ENABLE_VOIDWATCH": 1,
"MAIN.ENABLE_WOTG": 1,
"MAIN.ENM_COOLDOWN": 120,
"MAIN.EQUIP_FROM_OTHER_CONTAINERS": false,
"MAIN.EXCAVATION_BREAK_CHANCE": 33,
"MAIN.EXCAVATION_RATE": 50,
"MAIN.EXPLORER_MOOGLE_LV": 10,
"MAIN.EXP_RATE": 1,
"MAIN.FORCE_SPAWN_QM_RESET_TIME": 300,
"MAIN.FOV_REWARD_ALLIANCE": 0,
"MAIN.FREE_COP_DYNAMIS": 0,
"MAIN.FRIGICITE_TIME": 30,
"MAIN.GARRISON_LOCKOUT": 1800,
"MAIN.GARRISON_NATION_BYPASS": false,
"MAIN.GARRISON_ONCE_PER_WEEK": true,
"MAIN.GARRISON_PARTY_LIMIT": 18,
"MAIN.GARRISON_RANK": 2,
"MAIN.GARRISON_TIME_LIMIT": 1800,
"MAIN.GIL_RATE": 1,
"MAIN.GOBBIE_BOX_MIN_AGE": 45,
"MAIN.GOV_REWARD_ALLIANCE": 1,
"MAIN.HALLOWEEN_2005": 0,
"MAIN.HALLOWEEN_YEAR_ROUND": 0,
"MAIN.HARVESTING_BREAK_CHANCE": 33,
"MAIN.HARVESTING_RATE": 50,
"MAIN.HEALING_TP_CHANGE": -100,
"MAIN.HOMEPOINT_TELEPORT": 1,
"MAIN.INACTIVITY_WATCHDOG_PERIOD": 2000,
"MAIN.INITIAL_LEVEL_CAP": 50,
"MAIN.ITEM_POWER": 1,
"MAIN.LANTERNS_STAY_LIT": 1200,
"MAIN.LOGGING_BREAK_CHANCE": 33,
"MAIN.LOGGING_RATE": 50,
"MAIN.MAX_LEVEL": 99,
"MAIN.MINING_BREAK_CHANCE": 33,
"MAIN.MINING_RATE": 50,
"MAIN.MONSTROSITY_DONT_WIPE_BUFFS": 0,
"MAIN.MONSTROSITY_INFAMY_MESSAGING": 0,
"MAIN.MONSTROSITY_INFAMY_RATIO": 0.1,
"MAIN.MONSTROSITY_PVP_MODE": 0,
"MAIN.MONSTROSITY_PVP_ZONE_BYPASS": 0,
"MAIN.MONSTROSITY_TELEPORT_TO_FERETORY": 0,
"MAIN.MONSTROSITY_TRIGGER_NPCS": 0,
"MAIN.NEW_CHARACTER_CUTSCENE": 1,
"MAIN.NINJUTSU_POWER": 1,
"MAIN.NM_LOTTERY_CHANCE": 1,
"MAIN.NM_LOTTERY_COOLDOWN": 1,
"MAIN.NORMAL_MOB_MAX_LEVEL_RANGE_MAX": 0,
"MAIN.NORMAL_MOB_MAX_LEVEL_RANGE_MIN": 0,
"MAIN.NUMBER_OF_DM_EARRINGS": 1,
"MAIN.OLDSCHOOL_G1": false,
"MAIN.OLDSCHOOL_G2": false,
"MAIN.PRISMATIC_HOURGLASS_COST": 50000,
"MAIN.REGIME_REWARD_THRESHOLD": 15,
"MAIN.REGIME_WAIT": 1,
"MAIN.RELIC_2ND_UPGRADE_WAIT_TIME": 7200,
"MAIN.RELIC_3RD_UPGRADE_WAIT_TIME": 3600,
"MAIN.RESTRICT_CONTENT": 0,
"MAIN.RIVERNE_PORTERS": 120,
"MAIN.ROE_EXP_RATE": 1,
"MAIN.RUNIC_DISK_SAVE": true,
"MAIN.SERVER_MESSAGE": "Please visit https://github.com/LandSandBoat/server for the latest information on the project.?Thank you, and we hope you enjoy sailing the sands!",
"MAIN.SERVER_NAME": "Nameless",
"MAIN.SHOP_PRICE": 1,
"MAIN.SNEAK_INVIS_DURATION_MULTIPLIER": 1,
"MAIN.SPARKS_RATE": 1,
"MAIN.SPIKE_EFFECT_DURATION": 180,
"MAIN.START_GIL": 10,
"MAIN.START_INVENTORY": 30,
"MAIN.STONESKIN_CAP": 350,
"MAIN.SUBJOB_QUEST_LEVEL": 18,
"MAIN.TABS_RATE": 1,
"MAIN.TIMELESS_HOURGLASS_COST": 500000,
"MAIN.TRUST_ALTER_EGO_EXPO_MESSAGE": "? ????? The Alter Ego Expo Campaign is active! ?????Trusts gain the benefits of Increased HP, MP, and Status Resistances!",
"MAIN.TRUST_ALTER_EGO_EXTRAVAGANZA_MESSAGE": "? ????? The Alter Ego Extravaganza Campaign is active! ?????This is an excellent time to fill out your roster of Trusts!",
"MAIN.UNLOCK_OUTPOST_WARPS": 0,
"MAIN.USE_ADOULIN_WEAPON_SKILL_CHANGES": true,
"MAIN.USE_OLD_CURE_FORMULA": false,
"MAIN.USE_OLD_MAGIC_DAMAGE": false,
"MAIN.WEAPON_SKILL_POWER": 1,
"MAIN.WEEKLY_EXCHANGE_LIMIT": 100000,
"MAP.ABILITY_RECAST_MULTIPLIER": 1,
"MAP.AH_BASE_FEE_SINGLE": 1,
"MAP.AH_BASE_FEE_STACKS": 4,
"MAP.AH_LIST_LIMIT": 7,
"MAP.AH_MAX_FEE": 10000,
"MAP.AH_TAX_RATE_SINGLE": 1,
"MAP.AH_TAX_RATE_STACKS": 0.5,
"MAP.ALL_JOBS_WIDESCAN": true,
"MAP.ALL_MOBS_GIL_BONUS": 0,
"MAP.ALTER_EGO_HP_MULTIPLIER": 1,
"MAP.ALTER_EGO_MP_MULTIPLIER": 1,
"MAP.ALTER_EGO_SKILL_MULTIPLIER": 1,
"MAP.ALTER_EGO_STAT_MULTIPLIER": 1,
"MAP.ANTICHEAT_ENABLED": true,
"MAP.ANTICHEAT_JAIL_DISABLE": false,
"MAP.AUDIT_CHAT": false,
"MAP.AUDIT_GM_CMD": false,
"MAP.AUDIT_LINKSHELL": false,
"MAP.AUDIT_PARTY": false,
"MAP.AUDIT_SAY": false,
"MAP.AUDIT_SHOUT": false,
"MAP.AUDIT_TELL": false,
"MAP.AUDIT_UNITY": false,
"MAP.AUDIT_YELL": false,
"MAP.BATTLE_CAP_TWEAK": 0,
"MAP.BLOCK_OLD_SKILLUP_STYLE": false,
"MAP.BLOCK_TELL_TO_HIDDEN_GM": false,
"MAP.BLOOD_PACT_SHARED_TIMER": false,
"MAP.CAPACITY_RATE": 1,
"MAP.CRAFT_AMOUNT_MULTIPLIER": 1,
"MAP.CRAFT_CHANCE_MULTIPLIER": 1,
"MAP.CRAFT_COMMON_CAP": 700,
"MAP.CRAFT_MODERN_SYSTEM": true,
"MAP.CRAFT_SPECIALIZATION_POINTS": 400,
"MAP.DESPAWN_JUGPETS_BELOW_MINIMUM_LEVEL": false,
"MAP.DISABLE_GEAR_SCALING": false,
"MAP.DROP_RATE_MULTIPLIER": 1,
"MAP.ENABLE_ITEM_RECYCLE_BIN": true,
"MAP.ENMITY_CAP": 30000,
"MAP.EXP_LOSS_LEVEL": 31,
"MAP.EXP_LOSS_RATE": 1,
"MAP.EXP_PARTY_GAP_NO_EXP": 0,
"MAP.EXP_PARTY_GAP_PENALTIES": true,
"MAP.EXP_RATE": 1,
"MAP.EXP_RETAIN": 0,
"MAP.FAME_MULTIPLIER": 1,
"MAP.FELLOW_TP_MULTIPLIER": 1,
"MAP.FISHING_ENABLE": false,
"MAP.FISHING_SKILL_MULTIPLIER": 1,
"MAP.GARDEN_DAY_MATTERS": false,
"MAP.GARDEN_MH_AURA_MATTERS": false,
"MAP.GARDEN_MOONPHASE_MATTERS": false,
"MAP.GARDEN_POT_MATTERS": false,
"MAP.GUARD_OLD_SKILLUP_STYLE": false,
"MAP.HEALING_TICK_DELAY": 10,
"MAP.INCLUDE_MOB_SJ": false,
"MAP.KEEP_JUGPET_THROUGH_ZONING": false,
"MAP.LEVEL_SYNC_ENABLE": true,
"MAP.LIGHTLUGGAGE_BLOCK": 4,
"MAP.LV_CAP_MISSION_BCNM": false,
"MAP.MAX_GIL_BONUS": 9999,
"MAP.MAX_MERIT_POINTS": 30,
"MAP.MAX_TIME_LASTUPDATE": 60,
"MAP.MINIMUM_LEVEL_CONQUEST_INFUENCE_LOSS": 6,
"MAP.MOB_ADDITIONAL_TIME_TO_DEAGGRO": 0,
"MAP.MOB_GIL_MULTIPLIER": 1,
"MAP.MOB_HP_MULTIPLIER": 1,
"MAP.MOB_MP_MULTIPLIER": 1,
"MAP.MOB_NO_DESPAWN": false,
"MAP.MOB_SPEED_MOD": 0,
"MAP.MOB_STAT_MULTIPLIER": 1,
"MAP.MOB_TP_MULTIPLIER": 1,
"MAP.MOUNT_SPEED_MOD": 0,
"MAP.NM_HP_MULTIPLIER": 1,
"MAP.NM_MP_MULTIPLIER": 1,
"MAP.NM_STAT_MULTIPLIER": 1,
"MAP.PACKETGUARD_ENABLED": true,
"MAP.PARRY_OLD_SKILLUP_STYLE": false,
"MAP.PET_TP_MULTIPLIER": 1,
"MAP.PLAYER_HP_MULTIPLIER": 1,
"MAP.PLAYER_MP_MULTIPLIER": 1,
"MAP.PLAYER_STAT_MULTIPLIER": 1,
"MAP.PLAYER_TP_MULTIPLIER": 1,
"MAP.PREVENT_UNENGAGED_WS": false,
"MAP.REPORT_LUA_ERRORS_TO_PLAYER_LEVEL": 6,
"MAP.SETVAR_RETRY_MAX": 3,
"MAP.SJ_MP_DIVISOR": 2,
"MAP.SKILLUP_AMOUNT_MULTIPLIER": 1,
"MAP.SKILLUP_BLOODPACT": true,
"MAP.SKILLUP_CHANCE_MULTIPLIER": 1,
"MAP.SPEED_MOD": 0,
"MAP.SUBJOB_RATIO": 1,
"MAP.TRUST_TP_MULTIPLIER": 1,
"MAP.VANADIEL_TIME_EPOCH": 0,
"MAP.WS_POINTS_BASE": 1,
"MAP.WS_POINTS_SKILLCHAIN": 1,
"MAP.YELL_COOLDOWN": 30,
"SEARCH.DEBUG_OUT_PACKETS": false,
"SEARCH.EXPIRE_AUCTIONS": true,
"SEARCH.EXPIRE_DAYS": 3,
"SEARCH.EXPIRE_INTERVAL": 3600,
"SEARCH.OMIT_NO_HISTORY": false
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

8
client/src/index.css Normal file
View File

@ -0,0 +1,8 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
-webkit-user-drag: none; /* Safari */
user-drag: none;
}

21
client/src/main.tsx Normal file
View File

@ -0,0 +1,21 @@
import { CssBaseline } from '@mui/material';
import { StyledEngineProvider } from '@mui/material/styles';
import { StrictMode } from 'react';
import ReactDOM from 'react-dom/client';
import { WrappedApp } from './App';
import { ThemeContextProvider } from './context/ThemeContext';
import './index.css';
const rootElement = document.getElementById('root');
const root = ReactDOM.createRoot(rootElement!);
root.render(
<StrictMode>
<StyledEngineProvider injectFirst>
<ThemeContextProvider>
<CssBaseline />
<WrappedApp />
</ThemeContextProvider>
</StyledEngineProvider>
</StrictMode>
);

166
client/src/pages/About.tsx Normal file
View File

@ -0,0 +1,166 @@
import { ExpandMore } from '@mui/icons-material';
import {
Accordion,
AccordionDetails,
AccordionSummary,
Box,
Card,
CardContent,
Paper,
Typography,
alpha,
} from '@mui/material';
import { Link } from 'react-router-dom';
export default function About() {
return (
<>
<Card className="mb-3">
<CardContent>
<Box className="mb-3">
<Typography align="center" variant="h6">
What is this?
</Typography>
<Typography
component="p"
variant="body1"
align="justify"
color={(theme) => alpha(theme.palette.text.primary, 0.87)}
>
Ixion is a catalog of FFXI private servers running the{' '}
<Link to="https://github.com/LandSandBoat/server">
LandSandBoat
</Link>{' '}
software (or serving their API). Servers are updated every hour,
and are automatically removed after 24 hours of failed updates.
All information is pulled directly from the servers and Ixion
makes no guarantees as to how current or correct the information
is and is in no way affiliated with any listed server. Always use
a different strong and unique password for every server (and just
in general).
</Typography>
</Box>
<Box className="mb-3">
<Typography align="center" variant="h6">
How can I add a server?
</Typography>
<Typography
component="p"
variant="body1"
align="justify"
color={(theme) => alpha(theme.palette.text.primary, 0.87)}
>
Click the + button at the top of the page then enter the URL you
use to connect to the server. The server must be serving the LSB
API at the <b>/api</b> subdirectory of their URL.
</Typography>
</Box>
<Box className="mb-3">
<Typography align="center" variant="h6">
Other Resources
</Typography>
<Typography
component="p"
variant="body1"
align="justify"
color={(theme) => alpha(theme.palette.text.primary, 0.87)}
>
Additional servers can be found on the XiPrivateServers{' '}
<Link to="https://github.com/XiPrivateServers/Servers/blob/main/SERVERS.md">
GitHub
</Link>
. Discussion can be found on the{' '}
<Link to="https://www.reddit.com/r/FFXIPrivateServers/">
subreddit
</Link>{' '}
or in the <Link to="https://discord.gg/nYF6gNv">Discord</Link>. If
you want to setup your own server, check out{' '}
<Link to="https://github.com/LandSandBoat/server/wiki/Quick-Start-Guide">
the guide
</Link>{' '}
and don&apos;t forget to enable the HTTP server and list yourself
here!
</Typography>
</Box>
</CardContent>
</Card>
<Card>
<Accordion className="my-0">
<AccordionSummary expandIcon={<ExpandMore />}>
<Typography variant="h5">For server owners:</Typography>
</AccordionSummary>
<AccordionDetails>
<Box className="mb-3">
<Typography align="center" variant="h6">
Why can&apos;t I add my server?
</Typography>
<Typography
component="p"
variant="body1"
align="justify"
color={(theme) => alpha(theme.palette.text.primary, 0.87)}
>
To be searchable, you need to enable the HTTP server on the LSB
world server by setting <b>ENABLE_HTTP</b> in <b>network.lua</b>{' '}
to <b>true</b> and make sure requests to the <b>/api</b>{' '}
subdirectory of your domain reach it (e.g.
http://play.myserver.com/api/).
</Typography>
</Box>
<Box className="mb-3">
<Typography align="center" variant="h6">
What if my server isn&apos;t running LSB?
</Typography>
<Typography
component="p"
variant="body1"
align="justify"
color={(theme) => alpha(theme.palette.text.primary, 0.87)}
>
If you&apos;re not on LSB, you could fake the API response.
Right now the only required settings are <b>MAIN.SERVER_NAME</b>
, <b>MAIN.MAX_LEVEL</b>, and <b>LOGIN.LOGIN_LIMIT</b>.
You&apos;ll want to look at the LSB settings and serve whatever
relevant changes you&apos;ve made using the LSB equivalent.
Settings should be served at <b>/api/settings</b> and total
active sessions should be served at <b>/api/sessions</b>.
Enabled expansions are controlled by the LOGIN settings.
</Typography>
</Box>
<Box className="mb-3">
<Typography align="center" variant="h6">
How can I add a link to the server website?
</Typography>
<Typography
component="span"
variant="body1"
align="justify"
color={(theme) => alpha(theme.palette.text.primary, 0.87)}
>
Create a new file <b>settings/api.lua</b> in your server
directory.
<Paper elevation={2}>
<pre className="m-0 p-2">
<code>
{`xi = xi or {}
xi.settings = xi.settings or {}
xi.settings.api =
{
WEBSITE = '',
DO_NOT_TRACK = false,
}`}
</code>
</pre>
</Paper>
The world server needs to be restarted after any changes to any
of the settings. If you set <b>DO_NOT_TRACK</b> to <b>true</b>,
your server will be removed on the next hourly update.
</Typography>
</Box>
</AccordionDetails>
</Accordion>
</Card>
</>
);
}

182
client/src/pages/Home.tsx Normal file
View File

@ -0,0 +1,182 @@
import { Box } from '@mui/material';
import { useEffect, useState } from 'react';
import { fetchData, fetchDemo } from '../apiUtil';
import { AlertResponse } from '../components/Alert';
import ErrorCard from '../components/ErrorCard';
import ServerCard from '../components/ServerCard';
import SearchState from '../data/SearchState';
import ServerData from '../data/ServerData';
export default function Home({
servers,
setServers,
searchState,
setAlertInfo,
}: {
servers: ServerData[];
setServers: React.Dispatch<React.SetStateAction<ServerData[]>>;
searchState: SearchState;
setAlertInfo: React.Dispatch<React.SetStateAction<AlertResponse>>;
}) {
const [error, setError] = useState<string>('');
useEffect(() => {
const fetchServerData = async () => {
let data: ServerData[] = [];
try {
data = await fetchData();
} catch (err) {
if (err instanceof Error) {
setAlertInfo({
message: err.message,
severity: 'error',
});
} else {
setError('An unknown error occurred.');
}
}
if (data.length === 0) {
data.push(await fetchDemo());
}
setServers(data);
};
fetchServerData();
}, [setAlertInfo, setServers]);
const filterServers = (server: ServerData): boolean => {
if (
searchState.name.value.length > 0 &&
typeof server.settings['MAIN.SERVER_NAME'] === 'string'
) {
const serverName = server.settings['MAIN.SERVER_NAME'].toLowerCase();
if (!serverName.includes(searchState.name.value.toLowerCase())) {
return false;
}
}
if (typeof server.settings['MAIN.MAX_LEVEL'] === 'number') {
if (server.settings['MAIN.MAX_LEVEL'] < searchState.maxLevel.value[0]) {
return false;
}
if (server.settings['MAIN.MAX_LEVEL'] > searchState.maxLevel.value[1]) {
return false;
}
}
if (
searchState.trusts.value &&
typeof server.settings['MAIN.ENABLE_TRUST_CASTING'] === 'number'
) {
const serverTrusts = server.settings['MAIN.ENABLE_TRUST_CASTING'] === 1;
const searchEnabled = searchState.trusts.value.includes('enabled');
const searchDisabled = searchState.trusts.value.includes('disabled');
if (serverTrusts && searchDisabled && !searchEnabled) {
return false;
}
if (!serverTrusts && searchEnabled && !searchDisabled) {
return false;
}
}
if (
searchState.levelSync.value &&
typeof server.settings['MAP.LEVEL_SYNC_ENABLE'] === 'boolean'
) {
const serverLevelSync = server.settings['MAP.LEVEL_SYNC_ENABLE'];
const searchEnabled = searchState.levelSync.value.includes('enabled');
const searchDisabled = searchState.levelSync.value.includes('disabled');
if (serverLevelSync && searchDisabled && !searchEnabled) {
return false;
}
if (!serverLevelSync && searchEnabled && !searchDisabled) {
return false;
}
}
if (searchState.expansions.value) {
const searchNoneEnabled = searchState.expansions.value.includes('none');
const searchRotzEnabled = searchState.expansions.value.includes('rotz');
const serverRotzEnabled =
server.settings['LOGIN.RISE_OF_ZILART'] === true;
const searchCopEnabled = searchState.expansions.value.includes('cop');
const serverCopEnabled =
server.settings['LOGIN.CHAINS_OF_PROMATHIA'] === true;
const searchToauEnabled = searchState.expansions.value.includes('toau');
const serverToauEnabled =
server.settings['LOGIN.TREASURES_OF_AHT_URGHAN'] === true;
const searchWotgEnabled = searchState.expansions.value.includes('wotg');
const serverWotgEnabled =
server.settings['LOGIN.WINGS_OF_THE_GODDESS'] === true;
const searchSoaEnabled = searchState.expansions.value.includes('soa');
const serverSoaEnabled =
server.settings['LOGIN.SEEKERS_OF_ADOULIN'] === true;
if (
searchNoneEnabled &&
(serverRotzEnabled ||
serverCopEnabled ||
serverToauEnabled ||
serverWotgEnabled ||
serverSoaEnabled)
) {
return false;
}
if (searchRotzEnabled !== serverRotzEnabled) {
return false;
}
if (searchCopEnabled !== serverCopEnabled) {
return false;
}
if (searchToauEnabled !== serverToauEnabled) {
return false;
}
if (searchWotgEnabled !== serverWotgEnabled) {
return false;
}
if (searchSoaEnabled !== serverSoaEnabled) {
return false;
}
}
if (searchState.multibox.value) {
const serverMultibox = server.settings['LOGIN.LOGIN_LIMIT'];
if (typeof serverMultibox === 'number') {
if (
searchState.multibox.value.includes('none') &&
serverMultibox !== 1
) {
return false;
}
if (
searchState.multibox.value.includes('unlimited') &&
serverMultibox !== 0
) {
return false;
}
if (
searchState.multibox.value.includes('limited') &&
!searchState.multibox.value.includes('unlimited') &&
serverMultibox < 2
) {
return false;
}
}
}
return true;
};
const filteredServers = servers.filter(filterServers);
return (
<Box>
{error ? (
<ErrorCard error={error} />
) : (
filteredServers.map((server: ServerData) => (
<ServerCard key={server.id} server={server} />
))
)}
</Box>
);
}

View File

@ -0,0 +1,5 @@
import ErrorCard from '../components/ErrorCard';
export default function NotFound() {
return <ErrorCard error="Page not found." />;
}

2
client/src/setupTests.ts Normal file
View File

@ -0,0 +1,2 @@
// eslint-disable-next-line import/no-extraneous-dependencies
import '@testing-library/jest-dom';

1
client/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

13
client/tailwind.config.ts Normal file
View File

@ -0,0 +1,13 @@
import type { Config } from 'tailwindcss';
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
important: '#root',
theme: {
extend: {},
},
corePlugins: {
preflight: false,
},
plugins: [],
} satisfies Config;

32
client/tsconfig.json Normal file
View File

@ -0,0 +1,32 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"types": ["vitest/globals"],
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": [
"vite.config.ts",
".eslintrc.cjs",
"tailwind.config.ts",
"postcss.config.js",
"src"
],
"references": [{ "path": "./tsconfig.node.json" }]
}

10
client/tsconfig.node.json Normal file
View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

28
client/vite.config.ts Normal file
View File

@ -0,0 +1,28 @@
/* eslint-disable import/no-extraneous-dependencies */
/// <reference types="vitest" />
/// <reference types="vite/client" />
import react from '@vitejs/plugin-react-swc';
import { defineConfig } from 'vite';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
watch: {
usePolling: true,
},
host: true, // needed for the Docker Container port mapping to work
strictPort: true,
port: 3000,
hmr: {
//port: 3010,
host: 'localhost'
},
},
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/setupTests.ts'],
},
});