mirror of
https://gitea.tendokyu.moe/ppc/amnet.git
synced 2026-10-06 13:48:10 +03:00
Compare commits
133
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d05a972ef | ||
|
|
3ef3c2937b | ||
|
|
6019eb1920 | ||
|
|
95f066817f | ||
|
|
bff0416f00 | ||
|
|
701a92dea5 | ||
|
|
3f17f9b432 | ||
|
|
6146f1c409 | ||
|
|
9b2c62c035 | ||
|
|
e77f401e0c | ||
|
|
e4353fca97 | ||
|
|
27693cda7c | ||
|
|
fdf8a248e3 | ||
|
|
041050dba8 | ||
|
|
572f5ed672 | ||
|
|
ded0109486 | ||
|
|
0b44d9bf84 | ||
|
|
0ee84c168e | ||
|
|
3cae734dbd | ||
|
|
59d1a8bd3e | ||
|
|
6630496280 | ||
|
|
d078bea395 | ||
|
|
8c5ba567e4 | ||
|
|
46b5f1ab16 | ||
|
|
d7a5911e64 | ||
|
|
c1876cf4bf | ||
|
|
3ac5fe4c78 | ||
|
|
857e718dfd | ||
|
|
dcc8cd365e | ||
|
|
a9d6d36eea | ||
|
|
4a7aea406a | ||
|
|
d043f57ad0 | ||
|
|
5c1360ecdc | ||
|
|
2125753e37 | ||
|
|
96a9e2a498 | ||
|
|
563362eea5 | ||
|
|
839dfe6e00 | ||
|
|
a03abbde4e | ||
|
|
9bf1602d5d | ||
|
|
1075253573 | ||
|
|
6c692b71fa | ||
|
|
8021ee3a7a | ||
|
|
622f87242a | ||
|
|
8bceb4d422 | ||
|
|
1ce3cea61e | ||
|
|
63847543ca | ||
|
|
81ca4a73f9 | ||
|
|
5dc7880c58 | ||
|
|
d708c6e2c5 | ||
|
|
e2cd595e8e | ||
|
|
88cc6833f2 | ||
|
|
e5e158f8d2 | ||
|
|
f032fc248a | ||
|
|
130e4c3789 | ||
|
|
ab766de83a | ||
|
|
889e492bb0 | ||
|
|
97d0a0e437 | ||
|
|
591680d521 | ||
|
|
1d0e40961c | ||
|
|
435dbad3a5 | ||
|
|
f0125be89d | ||
|
|
3511efcc04 | ||
|
|
0edd7e13fa | ||
|
|
95be807720 | ||
|
|
78668a1b6a | ||
|
|
d917f8832a | ||
|
|
e39a117018 | ||
|
|
40fbf475f4 | ||
|
|
4e69f9a4b1 | ||
|
|
672e44593f | ||
|
|
f0d6bd6c95 | ||
|
|
83de0a558b | ||
|
|
2e831831a2 | ||
|
|
318e113445 | ||
|
|
c6f9251cf2 | ||
|
|
faf688fc0b | ||
|
|
1b73b60efa | ||
|
|
f2550a84e0 | ||
|
|
71f343e9b5 | ||
|
|
270c65cddb | ||
|
|
21766ae449 | ||
|
|
0bb34f04be | ||
|
|
1e5e8b3a4d | ||
|
|
805af19006 | ||
|
|
7f51e4a0ee | ||
|
|
4d43943568 | ||
|
|
d8d29c3f84 | ||
|
|
0a204b249a | ||
|
|
ae7483b156 | ||
|
|
5bb477741d | ||
|
|
3fbea0f925 | ||
|
|
2bc9c9f673 | ||
|
|
5b19207a91 | ||
|
|
bc48917fe8 | ||
|
|
13cee9a431 | ||
|
|
874ba06e29 | ||
|
|
16c0ff54c6 | ||
|
|
73d333cc23 | ||
|
|
393965d252 | ||
|
|
29b303cde8 | ||
|
|
e83287c7b7 | ||
|
|
9324b30e7b | ||
|
|
d0c7616ed1 | ||
|
|
99fd999c06 | ||
|
|
0a42a0a912 | ||
|
|
a6ad82ffda | ||
|
|
31c56d2449 | ||
|
|
b33670d849 | ||
|
|
9c8c4fd286 | ||
|
|
0a80da6614 | ||
|
|
489b56015c | ||
|
|
89338c794a | ||
|
|
aae5039660 | ||
|
|
690a5b591c | ||
|
|
f9b2326853 | ||
|
|
b2be622271 | ||
|
|
e0ba6c04f2 | ||
|
|
6c7479225c | ||
|
|
42cb241487 | ||
|
|
297ad685a7 | ||
|
|
d52757f134 | ||
|
|
a4d4e03eb9 | ||
|
|
3e3755471d | ||
|
|
adb60dd928 | ||
|
|
c8763dd65e | ||
|
|
2d3b129126 | ||
|
|
b729b4e31b | ||
|
|
377fe93645 | ||
|
|
cbd2825475 | ||
|
|
b9bbcc9228 | ||
|
|
61fb81c75c | ||
|
|
d3b96d3017 | ||
|
|
9b136006a0 |
@@ -0,0 +1,8 @@
|
|||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
**/*.tsbuildinfo
|
||||||
|
.git
|
||||||
|
target
|
||||||
|
amnet-native
|
||||||
|
amnet-server-test
|
||||||
|
res
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
name: Release
|
name: Release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
# push:
|
|
||||||
# tags:
|
|
||||||
# - '^v?(?:[0-9]+.?){1,4}$'
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
tag:
|
tag:
|
||||||
@@ -33,26 +30,31 @@ jobs:
|
|||||||
needs: get-upload-url
|
needs: get-upload-url
|
||||||
name: Build amnet-server
|
name: Build amnet-server
|
||||||
|
|
||||||
env:
|
strategy:
|
||||||
DOTNET_CLI_TELEMETRY_OPTOUT: 1
|
matrix:
|
||||||
|
include:
|
||||||
|
- { target: "x86_64-pc-windows-msvc", filename: "amnet.dll", include_test_server: true }
|
||||||
|
- { target: "i686-pc-windows-msvc", filename: "amnet_x86.dll", include_test_server: false }
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-dotnet@v4
|
|
||||||
with:
|
|
||||||
dotnet-version: '8.x'
|
|
||||||
|
|
||||||
- name: Set version
|
- name: Set target
|
||||||
run: |
|
run: |
|
||||||
$tag="${{ github.event.inputs.tag || github.ref_name }}".TrimStart("v")
|
rustup target add ${{ matrix.target }}
|
||||||
echo "VERSION=$tag" >> $env:GITHUB_ENV
|
rustup set default-host ${{ matrix.target }}
|
||||||
|
|
||||||
- name: Publish
|
- name: Perform build
|
||||||
run: dotnet publish -c Release -r win-x64 -o .\build-out -p:Version=$env:VERSION AMNet.Server\AMNet.Server.csproj
|
run: cargo build --release
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload .dll
|
||||||
run: |
|
run: |
|
||||||
curl -s -X POST -H "Authorization: token ${{ secrets.GITEA_TOKEN }}" -H "Content-Type: application/octet-stream" --data-binary "@.\build-out\amnet.dll" "${{ needs.get-upload-url.outputs.upload_url }}?name=amnet.dll"
|
curl -s -X POST -H "Authorization: token ${{ secrets.GITEA_TOKEN }}" -H "Content-Type: application/octet-stream" --data-binary "@.\amnet-server\target\release\amnet.dll" "${{ needs.get-upload-url.outputs.upload_url }}?name=${{ matrix.filename }}"
|
||||||
|
|
||||||
|
- name: Upload test server
|
||||||
|
if: "matrix.include_test_server"
|
||||||
|
run: |
|
||||||
|
curl -s -X POST -H "Authorization: token ${{ secrets.GITEA_TOKEN }}" -H "Content-Type: application/octet-stream" --data-binary "@.\amnet-server\target\release\amnet-server-test.exe" "${{ needs.get-upload-url.outputs.upload_url }}?name=amnet-server.exe"
|
||||||
|
|
||||||
build-website:
|
build-website:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
+15
-120
@@ -1,126 +1,6 @@
|
|||||||
## Ignore Visual Studio temporary files, build results, and
|
|
||||||
## files generated by popular Visual Studio add-ons.
|
|
||||||
##
|
|
||||||
## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
|
|
||||||
|
|
||||||
# User-specific files
|
|
||||||
*.rsuser
|
|
||||||
*.suo
|
|
||||||
*.user
|
|
||||||
*.userosscache
|
|
||||||
*.sln.docstates
|
|
||||||
|
|
||||||
# User-specific files (MonoDevelop/Xamarin Studio)
|
|
||||||
*.userprefs
|
|
||||||
|
|
||||||
# Mono auto generated files
|
|
||||||
mono_crash.*
|
|
||||||
|
|
||||||
# Build results
|
|
||||||
[Dd]ebug/
|
|
||||||
[Dd]ebugPublic/
|
|
||||||
[Rr]elease/
|
|
||||||
[Rr]eleases/
|
|
||||||
x64/
|
|
||||||
x86/
|
|
||||||
[Ww][Ii][Nn]32/
|
|
||||||
[Aa][Rr][Mm]/
|
|
||||||
[Aa][Rr][Mm]64/
|
|
||||||
bld/
|
|
||||||
[Bb]in/
|
|
||||||
[Oo]bj/
|
|
||||||
[Ll]og/
|
|
||||||
[Ll]ogs/
|
|
||||||
|
|
||||||
# Visual Studio 2015/2017 cache/options directory
|
|
||||||
.vs/
|
|
||||||
# Uncomment if you have tasks that create the project's static files in wwwroot
|
|
||||||
#wwwroot/
|
|
||||||
|
|
||||||
# Visual Studio 2017 auto generated files
|
|
||||||
Generated\ Files/
|
|
||||||
|
|
||||||
# MSTest test Results
|
|
||||||
[Tt]est[Rr]esult*/
|
|
||||||
[Bb]uild[Ll]og.*
|
|
||||||
|
|
||||||
# NUnit
|
|
||||||
*.VisualState.xml
|
|
||||||
TestResult.xml
|
|
||||||
nunit-*.xml
|
|
||||||
|
|
||||||
# Benchmark Results
|
|
||||||
BenchmarkDotNet.Artifacts/
|
|
||||||
|
|
||||||
# .NET Core
|
|
||||||
project.lock.json
|
|
||||||
project.fragment.lock.json
|
|
||||||
artifacts/
|
|
||||||
|
|
||||||
# StyleCop
|
|
||||||
StyleCopReport.xml
|
|
||||||
|
|
||||||
# Files built by Visual Studio
|
|
||||||
*_i.c
|
|
||||||
*_p.c
|
|
||||||
*_h.h
|
|
||||||
*.ilk
|
|
||||||
*.meta
|
|
||||||
*.obj
|
|
||||||
*.iobj
|
|
||||||
*.pch
|
|
||||||
*.pdb
|
|
||||||
*.ipdb
|
|
||||||
*.pgc
|
|
||||||
*.pgd
|
|
||||||
*.rsp
|
|
||||||
*.sbr
|
|
||||||
*.tlb
|
|
||||||
*.tli
|
|
||||||
*.tlh
|
|
||||||
*.tmp
|
|
||||||
*.tmp_proj
|
|
||||||
*_wpftmp.csproj
|
|
||||||
*.tlog
|
|
||||||
*.vspscc
|
|
||||||
*.vssscc
|
|
||||||
.builds
|
|
||||||
*.pidb
|
|
||||||
*.svclog
|
|
||||||
*.scc
|
|
||||||
|
|
||||||
# Visual Studio profiler
|
|
||||||
*.psess
|
|
||||||
*.vsp
|
|
||||||
*.vspx
|
|
||||||
*.sap
|
|
||||||
|
|
||||||
# Visual Studio Trace Files
|
|
||||||
*.e2e
|
|
||||||
|
|
||||||
# ReSharper
|
|
||||||
_ReSharper*/
|
|
||||||
*.[Rr]e[Ss]harper
|
|
||||||
*.DotSettings.user
|
|
||||||
|
|
||||||
# DotCover is a Code Coverage Tool
|
|
||||||
*.dotCover
|
|
||||||
|
|
||||||
# Click-Once directory
|
|
||||||
publish/
|
|
||||||
|
|
||||||
# Visual Studio cache files
|
|
||||||
# files ending in .cache can be ignored
|
|
||||||
*.[Cc]ache
|
|
||||||
# but keep track of directories ending in .cache
|
|
||||||
!?*.[Cc]ache/
|
|
||||||
|
|
||||||
# Local History for Visual Studio Code
|
# Local History for Visual Studio Code
|
||||||
.history/
|
.history/
|
||||||
|
|
||||||
# JetBrains Rider
|
|
||||||
*.sln.iml
|
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
@@ -275,3 +155,18 @@ docs/pages/versions/*/react-native/*.diff
|
|||||||
|
|
||||||
# Project-Specific
|
# Project-Specific
|
||||||
/amnet-web/dist
|
/amnet-web/dist
|
||||||
|
|
||||||
|
# Generated by Cargo
|
||||||
|
# will have compiled files and executables
|
||||||
|
debug
|
||||||
|
target
|
||||||
|
|
||||||
|
# These are backup files generated by rustfmt
|
||||||
|
**/*.rs.bk
|
||||||
|
|
||||||
|
# MSVC Windows builds of rustc generate these, which store debugging information
|
||||||
|
*.pdb
|
||||||
|
|
||||||
|
# Generated by cargo mutants
|
||||||
|
# Contains mutation testing data
|
||||||
|
**/mutants.out*/
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<PublishAot>true</PublishAot>
|
|
||||||
<OutputType>Library</OutputType>
|
|
||||||
<EventSourceSupport>false</EventSourceSupport>
|
|
||||||
<TargetFramework>net9.0-windows</TargetFramework>
|
|
||||||
<OptimizationPreference>Size</OptimizationPreference>
|
|
||||||
<NoDefaultLaunchSettingsFile>true</NoDefaultLaunchSettingsFile>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<Product>AMNet</Product>
|
|
||||||
<CompanyName>ppc</CompanyName>
|
|
||||||
<AssemblyName>amnet</AssemblyName>
|
|
||||||
<AssemblyTitle>AMNet Server</AssemblyTitle>
|
|
||||||
<Copyright>Copyright 2024 (c) ppc</Copyright>
|
|
||||||
<Description>Server component for the AMNet IC Card switcher</Description>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<DebugType>None</DebugType>
|
|
||||||
<DebugSymbols>false</DebugSymbols>
|
|
||||||
<MetricsSupport>false</MetricsSupport>
|
|
||||||
<DebuggerSupport>false</DebuggerSupport>
|
|
||||||
<EventSourceSupport>false</EventSourceSupport>
|
|
||||||
<InvariantGlobalization>true</InvariantGlobalization>
|
|
||||||
<MetadataUpdaterSupport>false</MetadataUpdaterSupport>
|
|
||||||
<EnableUnsafeUTF7Encoding>false</EnableUnsafeUTF7Encoding>
|
|
||||||
<HttpActivityPropagationSupport>false</HttpActivityPropagationSupport>
|
|
||||||
<EnableUnsafeBinaryFormatterSerialization>false</EnableUnsafeBinaryFormatterSerialization>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Microsoft.Windows.CsWin32" Version="0.3.106">
|
|
||||||
<PrivateAssets>all</PrivateAssets>
|
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
|
||||||
</PackageReference>
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Globalization;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading;
|
|
||||||
|
|
||||||
namespace AMNet.Server;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Represents a storage location to load/read cards from
|
|
||||||
/// </summary>
|
|
||||||
internal class CardPresenter
|
|
||||||
{
|
|
||||||
private readonly Lock _cardLock = new();
|
|
||||||
private StoredCard _currentCard;
|
|
||||||
|
|
||||||
public record StoredCard(byte[] Value, string OriginalValue, long ExpiresAt)
|
|
||||||
{
|
|
||||||
public bool Expired => Environment.TickCount64 >= ExpiresAt;
|
|
||||||
|
|
||||||
public override string ToString() => string.Join(" ", Enumerable.Range(0, 5).Select(x => OriginalValue.Substring(x * 4, 4)));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns the currently active card without clearing it
|
|
||||||
/// </summary>
|
|
||||||
public StoredCard PeekActiveCard()
|
|
||||||
{
|
|
||||||
lock (_cardLock)
|
|
||||||
{
|
|
||||||
return _currentCard;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public StoredCard TakeActiveCard()
|
|
||||||
{
|
|
||||||
lock (_cardLock)
|
|
||||||
{
|
|
||||||
var card = _currentCard;
|
|
||||||
_currentCard = null;
|
|
||||||
|
|
||||||
return card;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Sets the card
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cardId">The card id to set</param>
|
|
||||||
/// <param name="validFor">How long the card should be presented to the game for in milliseconds</param>
|
|
||||||
/// <returns>Whether the card was set successfully</returns>
|
|
||||||
public StoredCard SetCard(string cardId, long validFor = 5000)
|
|
||||||
{
|
|
||||||
// ensure the matrix code is 20-digits long, otherwise pad with zeros
|
|
||||||
var matrixCode = cardId.Replace(" ", "").PadLeft(20, '0');
|
|
||||||
var bytes = new byte[10];
|
|
||||||
|
|
||||||
for (var i = 0; i < bytes.Length; i++)
|
|
||||||
{
|
|
||||||
var value = matrixCode.Substring(i * 2, 2);
|
|
||||||
if (byte.TryParse(value, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var b))
|
|
||||||
{
|
|
||||||
bytes[i] = b;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var card = new StoredCard(bytes, matrixCode, Environment.TickCount64 + validFor);
|
|
||||||
|
|
||||||
lock (_cardLock)
|
|
||||||
{
|
|
||||||
_currentCard = card;
|
|
||||||
}
|
|
||||||
|
|
||||||
return card;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
using System;
|
|
||||||
using Windows.Win32;
|
|
||||||
using Windows.Win32.Foundation;
|
|
||||||
|
|
||||||
namespace AMNet.Server;
|
|
||||||
|
|
||||||
internal static class Config
|
|
||||||
{
|
|
||||||
private const string ConfigFileName = @".\segatools.ini";
|
|
||||||
|
|
||||||
internal const string IOSection = "aimeio";
|
|
||||||
|
|
||||||
static Config()
|
|
||||||
{
|
|
||||||
GameId = ReadKey(IOSection, "gameId", 4);
|
|
||||||
ServerName = ReadKey(IOSection, "serverName", 26, Environment.MachineName);
|
|
||||||
|
|
||||||
EnableAimeTxt = PInvoke.GetPrivateProfileInt(IOSection, "enableKeyboardMode", 1, ConfigFileName) > 0;
|
|
||||||
AimeTxtPath = ReadKey("aime", "aimePath", PInvoke.MAX_PATH, @"DEVICE\aime.txt");
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string GameId { get; }
|
|
||||||
public static string ServerName { get; }
|
|
||||||
|
|
||||||
public static bool EnableAimeTxt { get; }
|
|
||||||
public static string AimeTxtPath { get; }
|
|
||||||
|
|
||||||
internal static unsafe string ReadKey(string section, string key, uint maxLength, string @default = null)
|
|
||||||
{
|
|
||||||
// +1 for null terminator
|
|
||||||
var buffer = stackalloc char[(int)maxLength + 1];
|
|
||||||
var bufferStr = new PWSTR(buffer);
|
|
||||||
|
|
||||||
PInvoke.GetPrivateProfileString(section, key, @default ?? string.Empty, bufferStr, maxLength + 1, ConfigFileName);
|
|
||||||
return bufferStr.ToString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.IO;
|
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading;
|
|
||||||
using Windows.Win32;
|
|
||||||
using Microsoft.AspNetCore.Builder;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace AMNet.Server;
|
|
||||||
|
|
||||||
public static class DllMain
|
|
||||||
{
|
|
||||||
private const string WebAddress = "http://card.ppc.moe";
|
|
||||||
|
|
||||||
// instance metrics
|
|
||||||
internal static long LastPollTime = -1;
|
|
||||||
internal static long ServerStartedAt;
|
|
||||||
|
|
||||||
static DllMain()
|
|
||||||
{
|
|
||||||
PInvoke.AllocConsole();
|
|
||||||
|
|
||||||
App = WebServer.BuildServer(Config.ReadKey(Config.IOSection, "serverAddress", 1024, "http://+:6070").Split(';'));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static WebApplication App { get; }
|
|
||||||
|
|
||||||
[UnmanagedCallersOnly(EntryPoint = "aime_io_get_api_version")]
|
|
||||||
public static ushort GetApiVersion() => 0x0100;
|
|
||||||
|
|
||||||
[UnmanagedCallersOnly(EntryPoint = "aime_io_init")]
|
|
||||||
public static int Init()
|
|
||||||
{
|
|
||||||
CancellationTokenRegistration? cancellationRegistration = null;
|
|
||||||
cancellationRegistration = App.Lifetime.ApplicationStarted.Register(() =>
|
|
||||||
{
|
|
||||||
App.Logger.LogInformation("AMNet Server ({gameId}) started successfully.", Config.GameId);
|
|
||||||
App.Logger.LogInformation("Visit {addr} from a mobile device on the same network to get started.", WebAddress);
|
|
||||||
|
|
||||||
// ReSharper disable once AccessToModifiedClosure
|
|
||||||
cancellationRegistration?.Dispose();
|
|
||||||
});
|
|
||||||
|
|
||||||
Volatile.Write(ref ServerStartedAt, Environment.TickCount64);
|
|
||||||
App.RunAsync();
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
[UnmanagedCallersOnly(EntryPoint = "aime_io_nfc_poll")]
|
|
||||||
public static int NfcPoll(byte unitNo)
|
|
||||||
{
|
|
||||||
Volatile.Write(ref LastPollTime, Environment.TickCount64);
|
|
||||||
|
|
||||||
if (!Config.EnableAimeTxt)
|
|
||||||
{
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// check keyboard input for the enter key being held down (0x0D)
|
|
||||||
var enterPressed = (PInvoke.GetAsyncKeyState(0x0D) & 0x8000) != 0;
|
|
||||||
|
|
||||||
if (!enterPressed)
|
|
||||||
{
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var aimeTxtReader = new StreamReader(File.OpenRead(Config.AimeTxtPath), Encoding.UTF8);
|
|
||||||
|
|
||||||
var fileName = Path.GetFileName(Config.AimeTxtPath);
|
|
||||||
var cardResult = App.Services.GetRequiredService<CardPresenter>().SetCard(aimeTxtReader.ReadLine());
|
|
||||||
|
|
||||||
if (cardResult == null)
|
|
||||||
{
|
|
||||||
App.Logger.LogWarning("Failed to read card from {file}: invalid format", fileName);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
App.Logger.LogInformation("Card loaded from {fileName}", fileName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (IOException e)
|
|
||||||
{
|
|
||||||
App.Logger.LogWarning("Failed to read '{path}': {message}", Path.GetFullPath(Config.AimeTxtPath), e.Message);
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
[UnmanagedCallersOnly(EntryPoint = "aime_io_nfc_get_aime_id")]
|
|
||||||
public static int GetAimeId(byte unitNo, IntPtr luid, nint luidSize)
|
|
||||||
{
|
|
||||||
if (unitNo != 0)
|
|
||||||
{
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
var card = App.Services.GetRequiredService<CardPresenter>().TakeActiveCard();
|
|
||||||
if (card?.Expired != false)
|
|
||||||
{
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Marshal.Copy(card.Value, 0, luid, (int)luidSize);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
App.Logger.LogInformation("Card read in: {cardId}", card);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[UnmanagedCallersOnly(EntryPoint = "aime_io_nfc_get_felica_id")]
|
|
||||||
public static unsafe int GetFelicaId(byte unitNo, ulong* idm) => 1; // felica not supported
|
|
||||||
|
|
||||||
[UnmanagedCallersOnly(EntryPoint = "aime_io_led_set_color")]
|
|
||||||
public static void SetLedColour(byte unitNo, byte r, byte g, byte b)
|
|
||||||
{
|
|
||||||
// do nothing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
GetPrivateProfileStringW
|
|
||||||
GetPrivateProfileInt
|
|
||||||
GetAsyncKeyState
|
|
||||||
AllocConsole
|
|
||||||
MAX_PATH
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Globalization;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.AspNetCore.Builder;
|
|
||||||
using Microsoft.AspNetCore.Hosting;
|
|
||||||
using Microsoft.AspNetCore.Http;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace AMNet.Server;
|
|
||||||
|
|
||||||
internal static partial class WebServer
|
|
||||||
{
|
|
||||||
private const int ApiVersion = 1;
|
|
||||||
|
|
||||||
public record SystemState(
|
|
||||||
[property: JsonPropertyName("apiVersion")] int ApiVersion,
|
|
||||||
[property: JsonPropertyName("gameId")] string GameId,
|
|
||||||
[property: JsonPropertyName("serverName")] string ServerName,
|
|
||||||
[property: JsonPropertyName("sessionUptime")] long SessionUptime,
|
|
||||||
[property: JsonPropertyName("timeSinceLastPoll")] long? TimeSinceLastPoll);
|
|
||||||
|
|
||||||
public record CardReadRequest(
|
|
||||||
[property: JsonPropertyName("cardId")] string MatrixCode);
|
|
||||||
|
|
||||||
public static WebApplication BuildServer(params string[] listenAddresses)
|
|
||||||
{
|
|
||||||
var builder = WebApplication.CreateSlimBuilder([]);
|
|
||||||
|
|
||||||
builder.WebHost.UseUrls(listenAddresses);
|
|
||||||
|
|
||||||
builder.Logging.ClearProviders();
|
|
||||||
builder.Logging.AddSimpleConsole(o =>
|
|
||||||
{
|
|
||||||
o.SingleLine = true;
|
|
||||||
o.IncludeScopes = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
builder.Logging.SetMinimumLevel(LogLevel.Information);
|
|
||||||
builder.Logging.AddFilter("Microsoft.AspNetCore.Http.Result", LogLevel.None);
|
|
||||||
builder.Logging.AddFilter("Microsoft.AspNetCore.Routing.EndpointMiddleware", LogLevel.Error);
|
|
||||||
|
|
||||||
builder.Services.AddCors(cors =>
|
|
||||||
{
|
|
||||||
cors.AddDefaultPolicy(policy =>
|
|
||||||
{
|
|
||||||
policy.AllowAnyMethod();
|
|
||||||
policy.AllowAnyOrigin();
|
|
||||||
policy.AllowAnyHeader();
|
|
||||||
|
|
||||||
policy.SetPreflightMaxAge(TimeSpan.FromHours(6));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
builder.Services.ConfigureHttpJsonOptions(options =>
|
|
||||||
{
|
|
||||||
options.SerializerOptions.TypeInfoResolverChain.Insert(0, SerializerContext.Default);
|
|
||||||
});
|
|
||||||
|
|
||||||
builder.Services.AddSingleton<CardPresenter>();
|
|
||||||
|
|
||||||
var app = builder.Build();
|
|
||||||
|
|
||||||
app.UseCors();
|
|
||||||
|
|
||||||
app.MapGet("/amnet/info", ServerInfo);
|
|
||||||
app.MapPost("/amnet/signin", ProcessCard);
|
|
||||||
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static IResult ServerInfo()
|
|
||||||
{
|
|
||||||
var startedAt = Volatile.Read(ref DllMain.ServerStartedAt);
|
|
||||||
var lastPollAt = Volatile.Read(ref DllMain.LastPollTime);
|
|
||||||
|
|
||||||
var state = new SystemState(
|
|
||||||
ApiVersion,
|
|
||||||
Config.GameId,
|
|
||||||
Config.ServerName,
|
|
||||||
Environment.TickCount64 - startedAt,
|
|
||||||
lastPollAt < 0 ? null : Environment.TickCount64 - lastPollAt);
|
|
||||||
|
|
||||||
return Results.Ok(state);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task ProcessCard(HttpContext ctx)
|
|
||||||
{
|
|
||||||
CardReadRequest request;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
request = await ctx.Request.ReadFromJsonAsync<CardReadRequest>();
|
|
||||||
}
|
|
||||||
catch (JsonException e)
|
|
||||||
{
|
|
||||||
ctx.Response.StatusCode = 400;
|
|
||||||
await ctx.Response.WriteAsync(e.Message);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(request?.MatrixCode))
|
|
||||||
{
|
|
||||||
ctx.Response.StatusCode = 400;
|
|
||||||
await ctx.Response.WriteAsync("Card id not provided.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.MatrixCode.Length is 0 or < 20)
|
|
||||||
{
|
|
||||||
ctx.Response.StatusCode = 422;
|
|
||||||
await ctx.Response.WriteAsync("Invalid card id length.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var cardPresenter = ctx.RequestServices.GetRequiredService<CardPresenter>();
|
|
||||||
var currentCard = cardPresenter.PeekActiveCard();
|
|
||||||
|
|
||||||
if (currentCard?.Expired == false)
|
|
||||||
{
|
|
||||||
var timeLeft = Math.Ceiling(TimeSpan.FromMilliseconds(currentCard.ExpiresAt - Environment.TickCount64).TotalSeconds);
|
|
||||||
|
|
||||||
ctx.Response.StatusCode = 429;
|
|
||||||
ctx.Response.Headers.RetryAfter = timeLeft.ToString(CultureInfo.InvariantCulture);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cardPresenter.SetCard(request.MatrixCode) != null)
|
|
||||||
{
|
|
||||||
ctx.Response.StatusCode = 202;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
ctx.Response.StatusCode = 422;
|
|
||||||
await ctx.Response.WriteAsync("Invalid card id format.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[JsonSerializable(typeof(SystemState))]
|
|
||||||
[JsonSerializable(typeof(CardReadRequest))]
|
|
||||||
[JsonSourceGenerationOptions(WriteIndented = true, DefaultIgnoreCondition = JsonIgnoreCondition.Never)]
|
|
||||||
private partial class SerializerContext : JsonSerializerContext;
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
|
|
||||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AMNet.Server", "AMNet.Server\AMNet.Server.csproj", "{C9FA2545-F15C-4517-B3AB-55B8FC23FA04}"
|
|
||||||
EndProject
|
|
||||||
Global
|
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
|
||||||
Debug|Any CPU = Debug|Any CPU
|
|
||||||
Release|Any CPU = Release|Any CPU
|
|
||||||
EndGlobalSection
|
|
||||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
|
||||||
{C9FA2545-F15C-4517-B3AB-55B8FC23FA04}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{C9FA2545-F15C-4517-B3AB-55B8FC23FA04}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{C9FA2545-F15C-4517-B3AB-55B8FC23FA04}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{C9FA2545-F15C-4517-B3AB-55B8FC23FA04}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
EndGlobalSection
|
|
||||||
EndGlobal
|
|
||||||
Generated
+882
@@ -0,0 +1,882 @@
|
|||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "amnet"
|
||||||
|
version = "2.0.0"
|
||||||
|
dependencies = [
|
||||||
|
"axum",
|
||||||
|
"hostname",
|
||||||
|
"rust-ini",
|
||||||
|
"serde",
|
||||||
|
"tokio",
|
||||||
|
"tower",
|
||||||
|
"tower-http",
|
||||||
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
"winresource",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "amnet-server-test"
|
||||||
|
version = "2.0.0"
|
||||||
|
dependencies = [
|
||||||
|
"amnet",
|
||||||
|
"axum",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "atomic-waker"
|
||||||
|
version = "1.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "axum"
|
||||||
|
version = "0.8.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
|
||||||
|
dependencies = [
|
||||||
|
"axum-core",
|
||||||
|
"bytes",
|
||||||
|
"form_urlencoded",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
|
"itoa",
|
||||||
|
"matchit",
|
||||||
|
"memchr",
|
||||||
|
"mime",
|
||||||
|
"percent-encoding",
|
||||||
|
"pin-project-lite",
|
||||||
|
"serde_core",
|
||||||
|
"serde_json",
|
||||||
|
"serde_path_to_error",
|
||||||
|
"serde_urlencoded",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tokio",
|
||||||
|
"tower",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "axum-core"
|
||||||
|
version = "0.5.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"mime",
|
||||||
|
"pin-project-lite",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "2.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bytes"
|
||||||
|
version = "1.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg-if"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "const-random"
|
||||||
|
version = "0.1.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
|
||||||
|
dependencies = [
|
||||||
|
"const-random-macro",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "const-random-macro"
|
||||||
|
version = "0.1.16"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom",
|
||||||
|
"once_cell",
|
||||||
|
"tiny-keccak",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crunchy"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dlv-list"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f"
|
||||||
|
dependencies = [
|
||||||
|
"const-random",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "equivalent"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "form_urlencoded"
|
||||||
|
version = "1.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
|
||||||
|
dependencies = [
|
||||||
|
"percent-encoding",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-channel"
|
||||||
|
version = "0.3.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-core"
|
||||||
|
version = "0.3.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-task"
|
||||||
|
version = "0.3.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-util"
|
||||||
|
version = "0.3.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"futures-task",
|
||||||
|
"pin-project-lite",
|
||||||
|
"pin-utils",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.2.16"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"wasi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.14.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.16.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hostname"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http"
|
||||||
|
version = "1.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"itoa",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http-body"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"http",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http-body-util"
|
||||||
|
version = "0.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httparse"
|
||||||
|
version = "1.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpdate"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper"
|
||||||
|
version = "1.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
|
||||||
|
dependencies = [
|
||||||
|
"atomic-waker",
|
||||||
|
"bytes",
|
||||||
|
"futures-channel",
|
||||||
|
"futures-core",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"httparse",
|
||||||
|
"httpdate",
|
||||||
|
"itoa",
|
||||||
|
"pin-project-lite",
|
||||||
|
"pin-utils",
|
||||||
|
"smallvec",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper-util"
|
||||||
|
version = "0.1.19"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"hyper",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
"tower-service",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "indexmap"
|
||||||
|
version = "2.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
|
||||||
|
dependencies = [
|
||||||
|
"equivalent",
|
||||||
|
"hashbrown 0.16.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itoa"
|
||||||
|
version = "1.0.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lazy_static"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libc"
|
||||||
|
version = "0.2.179"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c5a2d376baa530d1238d133232d15e239abad80d05838b4b59354e5268af431f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "log"
|
||||||
|
version = "0.4.29"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "matchit"
|
||||||
|
version = "0.8.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memchr"
|
||||||
|
version = "2.7.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mime"
|
||||||
|
version = "0.3.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mio"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"wasi",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nu-ansi-term"
|
||||||
|
version = "0.50.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell"
|
||||||
|
version = "1.21.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ordered-multimap"
|
||||||
|
version = "0.7.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79"
|
||||||
|
dependencies = [
|
||||||
|
"dlv-list",
|
||||||
|
"hashbrown 0.14.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "percent-encoding"
|
||||||
|
version = "2.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pin-project-lite"
|
||||||
|
version = "0.2.16"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pin-utils"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.104"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9695f8df41bb4f3d222c95a67532365f569318332d03d5f3f67f37b20e6ebdf0"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.42"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rust-ini"
|
||||||
|
version = "0.21.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"ordered-multimap",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ryu"
|
||||||
|
version = "1.0.22"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_core"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||||
|
dependencies = [
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_derive"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_json"
|
||||||
|
version = "1.0.148"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"memchr",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
"zmij",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_path_to_error"
|
||||||
|
version = "0.1.20"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_spanned"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_urlencoded"
|
||||||
|
version = "0.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||||
|
dependencies = [
|
||||||
|
"form_urlencoded",
|
||||||
|
"itoa",
|
||||||
|
"ryu",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sharded-slab"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
|
||||||
|
dependencies = [
|
||||||
|
"lazy_static",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "smallvec"
|
||||||
|
version = "1.15.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "socket2"
|
||||||
|
version = "0.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys 0.60.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "2.0.113"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "678faa00651c9eb72dd2020cbdf275d92eccb2400d568e419efdd64838145cb4"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sync_wrapper"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thread_local"
|
||||||
|
version = "1.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tiny-keccak"
|
||||||
|
version = "2.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
|
||||||
|
dependencies = [
|
||||||
|
"crunchy",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio"
|
||||||
|
version = "1.49.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"mio",
|
||||||
|
"pin-project-lite",
|
||||||
|
"socket2",
|
||||||
|
"tokio-macros",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-macros"
|
||||||
|
version = "2.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml"
|
||||||
|
version = "0.9.11+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap",
|
||||||
|
"serde_core",
|
||||||
|
"serde_spanned",
|
||||||
|
"toml_datetime",
|
||||||
|
"toml_parser",
|
||||||
|
"toml_writer",
|
||||||
|
"winnow",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_datetime"
|
||||||
|
version = "0.7.5+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_parser"
|
||||||
|
version = "1.0.6+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44"
|
||||||
|
dependencies = [
|
||||||
|
"winnow",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_writer"
|
||||||
|
version = "1.0.6+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
|
"pin-project-lite",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tokio",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower-http"
|
||||||
|
version = "0.6.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"bytes",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower-layer"
|
||||||
|
version = "0.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower-service"
|
||||||
|
version = "0.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing"
|
||||||
|
version = "0.1.44"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tracing-attributes",
|
||||||
|
"tracing-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-attributes"
|
||||||
|
version = "0.1.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-core"
|
||||||
|
version = "0.1.36"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||||
|
dependencies = [
|
||||||
|
"once_cell",
|
||||||
|
"valuable",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-log"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"once_cell",
|
||||||
|
"tracing-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-subscriber"
|
||||||
|
version = "0.3.22"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
|
||||||
|
dependencies = [
|
||||||
|
"nu-ansi-term",
|
||||||
|
"sharded-slab",
|
||||||
|
"smallvec",
|
||||||
|
"thread_local",
|
||||||
|
"tracing-core",
|
||||||
|
"tracing-log",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.22"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "valuable"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "version_check"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasi"
|
||||||
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-link"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.60.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.61.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-targets"
|
||||||
|
version = "0.53.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
"windows_aarch64_gnullvm",
|
||||||
|
"windows_aarch64_msvc",
|
||||||
|
"windows_i686_gnu",
|
||||||
|
"windows_i686_gnullvm",
|
||||||
|
"windows_i686_msvc",
|
||||||
|
"windows_x86_64_gnu",
|
||||||
|
"windows_x86_64_gnullvm",
|
||||||
|
"windows_x86_64_msvc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_gnullvm"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_msvc"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnu"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnullvm"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_msvc"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnu"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnullvm"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_msvc"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winnow"
|
||||||
|
version = "0.7.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winresource"
|
||||||
|
version = "0.1.28"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6b021990998587d4438bb672b5c5f034cbc927f51b45e3807ab7323645ef4899"
|
||||||
|
dependencies = [
|
||||||
|
"toml",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zmij"
|
||||||
|
version = "1.0.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dcb2c125bd7365735bebeb420ccb880265ed2d2bddcbcd49f597fdfe6bd5e577"
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
[workspace]
|
||||||
|
members = ["amnet-server", "amnet-server-test"]
|
||||||
|
resolver = "3"
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
version = "2.0.0"
|
||||||
|
authors = ["ppc"]
|
||||||
|
description = "AMNet IC Card Switcher"
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
FROM node:lts-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# amnet-shared packages
|
||||||
|
COPY amnet-shared/package.json amnet-shared/package.json
|
||||||
|
COPY amnet-shared/package-lock.json amnet-shared/package-lock.json
|
||||||
|
RUN cd amnet-shared && npm ci
|
||||||
|
|
||||||
|
# amnet-web packages
|
||||||
|
COPY amnet-web/package.json amnet-web/package.json
|
||||||
|
COPY amnet-web/package-lock.json amnet-web/package-lock.json
|
||||||
|
RUN cd amnet-web && npm ci
|
||||||
|
|
||||||
|
# main contents
|
||||||
|
COPY amnet-shared amnet-shared
|
||||||
|
COPY amnet-web amnet-web
|
||||||
|
|
||||||
|
RUN cd amnet-shared && npm run build
|
||||||
|
RUN cd amnet-web && npm run build
|
||||||
|
|
||||||
|
# ---
|
||||||
|
|
||||||
|
FROM joseluisq/static-web-server:2 AS final
|
||||||
|
|
||||||
|
ENV SERVER_PORT=8080
|
||||||
|
ENV SERVER_ROOT=/static
|
||||||
|
ENV SERVER_LOG_LEVEL=info
|
||||||
|
ENV SERVER_FALLBACK_PAGE=/static/index.html
|
||||||
|
|
||||||
|
COPY --from=builder /build/amnet-web/dist /static
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# ENTRYPOINT/CMD doesn't need modifying
|
||||||
+25
-19
@@ -1,14 +1,14 @@
|
|||||||
import React, {useEffect} from "react";
|
import React from "react";
|
||||||
import {StatusBar} from "expo-status-bar";
|
import {StatusBar} from "expo-status-bar";
|
||||||
import {useColorScheme, View} from "react-native";
|
|
||||||
import {Server, WalletCards} from "lucide-react-native";
|
import {Server, WalletCards} from "lucide-react-native";
|
||||||
|
import {Platform, useColorScheme, View} from "react-native";
|
||||||
import {NavigationContainer} from "@react-navigation/native";
|
import {NavigationContainer} from "@react-navigation/native";
|
||||||
import {SafeAreaProvider} from "react-native-safe-area-context";
|
import {SafeAreaProvider} from "react-native-safe-area-context";
|
||||||
import {MD3Colors} from "react-native-paper/lib/typescript/types";
|
import {MD3Colors} from "react-native-paper/lib/typescript/types";
|
||||||
import {createBottomTabNavigator} from "@react-navigation/bottom-tabs";
|
import {createBottomTabNavigator} from "@react-navigation/bottom-tabs";
|
||||||
import {createNativeStackNavigator} from "@react-navigation/native-stack";
|
import {createNativeStackNavigator} from "@react-navigation/native-stack";
|
||||||
import {DefaultTheme as NavigationLightTheme, DarkTheme as NavigationDarkTheme} from '@react-navigation/native';
|
|
||||||
import {JetBrainsMono_400Regular, useFonts} from "@expo-google-fonts/jetbrains-mono";
|
import {JetBrainsMono_400Regular, useFonts} from "@expo-google-fonts/jetbrains-mono";
|
||||||
|
import {DefaultTheme as NavigationLightTheme, DarkTheme as NavigationDarkTheme} from '@react-navigation/native';
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
adaptNavigationTheme,
|
adaptNavigationTheme,
|
||||||
@@ -16,15 +16,18 @@ import {
|
|||||||
MD3LightTheme,
|
MD3LightTheme,
|
||||||
MD3Theme,
|
MD3Theme,
|
||||||
PaperProvider,
|
PaperProvider,
|
||||||
Paragraph
|
Text
|
||||||
} from 'react-native-paper';
|
} from 'react-native-paper';
|
||||||
|
|
||||||
import RealmContext from "./models/RealmContext";
|
|
||||||
import linking from "./Linking";
|
import linking from "./Linking";
|
||||||
|
import RealmContext from "./models/RealmContext";
|
||||||
|
|
||||||
import {AMUITabBar} from "./components/AMUITabBar";
|
import {AMUITabBar} from "./components/AMUITabBar";
|
||||||
import CardAddModal from "./app/CardAddModal";
|
|
||||||
|
import CardEditorModal from "./app/card/CardEditorModal";
|
||||||
|
import CardAddModal from "./app/card/CardAddModal";
|
||||||
import ServerAddModal from "./app/ServerAddModal";
|
import ServerAddModal from "./app/ServerAddModal";
|
||||||
|
|
||||||
import CardManagerStack from "./app/CardManagerStack";
|
import CardManagerStack from "./app/CardManagerStack";
|
||||||
import ServerManagerStack from "./app/ServerManagerStack";
|
import ServerManagerStack from "./app/ServerManagerStack";
|
||||||
|
|
||||||
@@ -69,25 +72,22 @@ const {LightTheme, DarkTheme} = adaptNavigationTheme({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const {RealmProvider} = RealmContext;
|
const {RealmProvider} = RealmContext;
|
||||||
|
|
||||||
const Tab = createBottomTabNavigator();
|
const Tab = createBottomTabNavigator();
|
||||||
const Stack = createNativeStackNavigator();
|
const Stack = createNativeStackNavigator();
|
||||||
|
|
||||||
function AppContent() {
|
function AppContent() {
|
||||||
return (<Tab.Navigator tabBar={p => <AMUITabBar {...p}/>} screenOptions={{headerShown: false}}>
|
return (
|
||||||
|
<Tab.Navigator tabBar={p => <AMUITabBar {...p}/>} screenOptions={{headerShown: false}}>
|
||||||
<Tab.Screen name="servers"
|
<Tab.Screen name="servers"
|
||||||
component={ServerManagerStack}
|
component={ServerManagerStack}
|
||||||
options={{
|
options={{tabBarIcon: ({color, size}) => (<Server color={color} size={size}/>)}}/>
|
||||||
title: 'Servers',
|
|
||||||
tabBarIcon: ({color, size}) => (<Server color={color} size={size}/>)
|
|
||||||
}}/>
|
|
||||||
|
|
||||||
<Tab.Screen name="cards"
|
<Tab.Screen name="cards"
|
||||||
component={CardManagerStack}
|
component={CardManagerStack}
|
||||||
options={{
|
options={{tabBarIcon: ({color, size}) => (<WalletCards color={color} size={size}/>)}}/>
|
||||||
title: "Cards",
|
</Tab.Navigator>
|
||||||
tabBarIcon: ({color, size}) => (<WalletCards color={color} size={size}/>)
|
)
|
||||||
}}/>
|
|
||||||
</Tab.Navigator>)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AppRoot() {
|
export default function AppRoot() {
|
||||||
@@ -100,7 +100,7 @@ export default function AppRoot() {
|
|||||||
return (
|
return (
|
||||||
<View style={{flex: 1, justifyContent: "center", alignItems: "center", gap: 10}}>
|
<View style={{flex: 1, justifyContent: "center", alignItems: "center", gap: 10}}>
|
||||||
<ActivityIndicator animating/>
|
<ActivityIndicator animating/>
|
||||||
<Paragraph>Starting...</Paragraph>
|
<Text variant="bodyMedium">Starting...</Text>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -116,9 +116,15 @@ export default function AppRoot() {
|
|||||||
{/* main app */}
|
{/* main app */}
|
||||||
<Stack.Screen name="app" component={AppContent} options={{headerShown: false}}/>
|
<Stack.Screen name="app" component={AppContent} options={{headerShown: false}}/>
|
||||||
|
|
||||||
<Stack.Group screenOptions={{presentation: "modal"}}>
|
<Stack.Group screenOptions={{
|
||||||
|
presentation: "modal",
|
||||||
|
headerTitleStyle: {
|
||||||
|
fontWeight: "bold"
|
||||||
|
}
|
||||||
|
}}>
|
||||||
<Stack.Screen name="add-server" component={ServerAddModal} options={{title: "Add Server"}}/>
|
<Stack.Screen name="add-server" component={ServerAddModal} options={{title: "Add Server"}}/>
|
||||||
<Stack.Screen name="add-card" component={CardAddModal} options={{title: "Add Card"}}/>
|
<Stack.Screen name="add-card" component={CardAddModal} options={{title: "Add Card", headerShown: Platform.OS !== 'ios'}}/>
|
||||||
|
<Stack.Screen name="edit-card" component={CardEditorModal} options={{title: "Edit Card"}}/>
|
||||||
</Stack.Group>
|
</Stack.Group>
|
||||||
</Stack.Navigator>
|
</Stack.Navigator>
|
||||||
</NavigationContainer>
|
</NavigationContainer>
|
||||||
|
|||||||
+13
-8
@@ -1,18 +1,21 @@
|
|||||||
{
|
{
|
||||||
|
"expo": {
|
||||||
"name": "AMNet",
|
"name": "AMNet",
|
||||||
"scheme": "amnet",
|
"scheme": "amnet",
|
||||||
"version": "1.2.0",
|
"version": "1.3.2",
|
||||||
"slug": "amnet-native",
|
"slug": "amnet-native",
|
||||||
"icon": "./assets/icon.png",
|
"icon": "./assets/icon.png",
|
||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "automatic",
|
||||||
|
"newArchEnabled": true,
|
||||||
"ios": {
|
"ios": {
|
||||||
"bundleIdentifier": "moe.ppc.amnet",
|
"bundleIdentifier": "moe.ppc.amnet",
|
||||||
"supportsTablet": true,
|
"supportsTablet": true,
|
||||||
"associatedDomains": [
|
|
||||||
"applinks:card.ppc.moe"
|
|
||||||
],
|
|
||||||
"infoPlist": {
|
"infoPlist": {
|
||||||
"UIViewControllerBasedStatusBarAppearance": "NO"
|
"NSAppTransportSecurity": {
|
||||||
|
"NSAllowsArbitraryLoads": true
|
||||||
|
},
|
||||||
|
"UIDesignRequiresCompatibility": true,
|
||||||
|
"UIViewControllerBasedStatusBarAppearance": false
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"usesNonExemptEncryption": false
|
"usesNonExemptEncryption": false
|
||||||
@@ -20,13 +23,14 @@
|
|||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"package": "moe.ppc.amnet",
|
"package": "moe.ppc.amnet",
|
||||||
"versionCode": 3,
|
"versionCode": 9,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/icon-android-alt.png",
|
"foregroundImage": "./assets/icon-android.png",
|
||||||
"backgroundColor": "#DED8FD"
|
"backgroundColor": "#DED8FD"
|
||||||
},
|
},
|
||||||
"splash": {
|
"splash": {
|
||||||
"image": "./assets/icon-android-alt.png",
|
"resizeMode": "cover",
|
||||||
|
"image": "./assets/icon-android.png",
|
||||||
"backgroundColor": "#DED8FD",
|
"backgroundColor": "#DED8FD",
|
||||||
"dark": {
|
"dark": {
|
||||||
"backgroundColor": "#3B5ACC"
|
"backgroundColor": "#3B5ACC"
|
||||||
@@ -75,4 +79,5 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
import {generateCardNumber} from "amnet-shared";
|
|
||||||
import {useModalStyles} from "../utils/ModalStyles";
|
|
||||||
import {useCallback, useLayoutEffect, useState} from "react";
|
|
||||||
import {Platform, TextInput, TouchableOpacity, View} from "react-native";
|
|
||||||
import {Button, Card, Divider, Paragraph, useTheme} from "react-native-paper";
|
|
||||||
|
|
||||||
import {AMCard} from "../models/AMCard";
|
|
||||||
import RealmContext from "../models/RealmContext";
|
|
||||||
|
|
||||||
const {useRealm} = RealmContext;
|
|
||||||
|
|
||||||
export default function CardAddModal({navigation, route}) {
|
|
||||||
const realm = useRealm();
|
|
||||||
const theme = useTheme();
|
|
||||||
const modalStyles = useModalStyles();
|
|
||||||
|
|
||||||
// state values
|
|
||||||
const [cardName, setCardName] = useState('');
|
|
||||||
const [cardNumber, setCardNumber] = useState('');
|
|
||||||
const [cardNumberReadOnly, setCardNumberReadOnly] = useState(false);
|
|
||||||
|
|
||||||
const cardAddCallback = useCallback((cardId: string, cardName: string) => {
|
|
||||||
const duplicateCard = realm.objects<AMCard>("card").find(x => x.code === cardId);
|
|
||||||
|
|
||||||
if (duplicateCard) {
|
|
||||||
realm.write(() => {
|
|
||||||
duplicateCard.name = cardName;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
realm.write(() => {
|
|
||||||
realm.create("card", AMCard.generate(cardId, cardName));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
navigation.goBack();
|
|
||||||
}, [realm]);
|
|
||||||
|
|
||||||
// header effects
|
|
||||||
if (Platform.OS === 'ios') {
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
navigation.setOptions({
|
|
||||||
headerLeft: () => <TouchableOpacity onPress={() => navigation.goBack()}>
|
|
||||||
<Paragraph style={modalStyles.headerText}>Cancel</Paragraph>
|
|
||||||
</TouchableOpacity>,
|
|
||||||
});
|
|
||||||
}, [navigation, modalStyles]);
|
|
||||||
}
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const canSave = cardName.length && cardNumber.length === 20;
|
|
||||||
const themeOptions = canSave ? modalStyles.headerText : {color: theme.colors.outline};
|
|
||||||
|
|
||||||
navigation.setOptions({
|
|
||||||
headerRight: () => <TouchableOpacity disabled={!canSave}
|
|
||||||
onPress={() => cardAddCallback(cardNumber, cardName)}>
|
|
||||||
<Paragraph style={{...themeOptions, fontWeight: "600"}}>Save</Paragraph>
|
|
||||||
</TouchableOpacity>
|
|
||||||
})
|
|
||||||
}, [navigation, modalStyles, cardName, cardNumber]);
|
|
||||||
|
|
||||||
// enable renaming by passing cardId to modal
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (!route.params?.cardId) {
|
|
||||||
setCardNumberReadOnly(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCardNumber(route.params.cardId);
|
|
||||||
setCardNumberReadOnly(true);
|
|
||||||
|
|
||||||
if (route.params.cardName) {
|
|
||||||
setCardName(route.params.cardName);
|
|
||||||
}
|
|
||||||
|
|
||||||
navigation.setOptions({
|
|
||||||
title: "Edit Card"
|
|
||||||
});
|
|
||||||
}, [route]);
|
|
||||||
|
|
||||||
return (<View style={{display: "flex", gap: 10, justifyContent: "center", paddingHorizontal: 15, paddingTop: 25}}>
|
|
||||||
<Card mode="contained">
|
|
||||||
<Card.Content>
|
|
||||||
<View style={modalStyles.formItemRow}>
|
|
||||||
<Paragraph style={modalStyles.formItemName}>
|
|
||||||
Card Name
|
|
||||||
</Paragraph>
|
|
||||||
<TextInput style={modalStyles.formItemInput}
|
|
||||||
numberOfLines={1} autoFocus
|
|
||||||
value={cardName}
|
|
||||||
onChangeText={setCardName}
|
|
||||||
placeholder="Primary Card"
|
|
||||||
placeholderTextColor={theme.colors.outline}/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Divider style={{marginVertical: 15}}/>
|
|
||||||
|
|
||||||
<View style={modalStyles.formItemRow}>
|
|
||||||
<Paragraph style={modalStyles.formItemName}>
|
|
||||||
Matrix Code
|
|
||||||
</Paragraph>
|
|
||||||
<TextInput numberOfLines={1}
|
|
||||||
keyboardType="numeric"
|
|
||||||
readOnly={cardNumberReadOnly}
|
|
||||||
placeholder="0000 0000 0000 0000 0000"
|
|
||||||
placeholderTextColor={theme.colors.outline}
|
|
||||||
value={cardNumber.match(/.{1,4}/g)?.join(" ")}
|
|
||||||
onChangeText={val => setCardNumber(val?.replaceAll(/\D/g, '').substring(0, 20) ?? '')}
|
|
||||||
style={{...modalStyles.formItemInput, color: cardNumberReadOnly ? theme.colors.onSurfaceDisabled : theme.colors.onSurface}}/>
|
|
||||||
</View>
|
|
||||||
</Card.Content>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/*only show card entry info when in create mode*/}
|
|
||||||
{!cardNumberReadOnly && (
|
|
||||||
<>
|
|
||||||
<Paragraph style={modalStyles.formFootnote}>
|
|
||||||
If you have a physical card, you can enter the 20-digit code on the back.
|
|
||||||
</Paragraph>
|
|
||||||
|
|
||||||
<Button mode="text" onPress={() => setCardNumber(generateCardNumber())}>Generate Matrix Code</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</View>)
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,7 @@ import {Button, useTheme} from "react-native-paper";
|
|||||||
import {CreditCard, Plus} from "lucide-react-native";
|
import {CreditCard, Plus} from "lucide-react-native";
|
||||||
import ContextMenu from "react-native-context-menu-view";
|
import ContextMenu from "react-native-context-menu-view";
|
||||||
|
|
||||||
import {AimeCard} from "../components/AimeCard";
|
import {ICCard} from "../components/ICCard";
|
||||||
import {EmptyStatePlaceholder} from "../components/EmptyStatePlaceholder";
|
import {EmptyStatePlaceholder} from "../components/EmptyStatePlaceholder";
|
||||||
|
|
||||||
import {AMCard} from "../models/AMCard";
|
import {AMCard} from "../models/AMCard";
|
||||||
@@ -21,19 +21,20 @@ function CardEntry(props: { card: AMCard, navigation: any }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch (indexPath.toString()) {
|
switch (indexPath.toString()) {
|
||||||
// copy card
|
// copy card access code
|
||||||
case "0":
|
case "0":
|
||||||
|
case "0,0":
|
||||||
await Clipboard.setStringAsync(props.card.code);
|
await Clipboard.setStringAsync(props.card.code);
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// edit card
|
// edit card
|
||||||
case "1,0":
|
case "1,0":
|
||||||
props.navigation.navigate('add-card', {cardId: props.card.code, cardName: props.card.name});
|
props.navigation.navigate('edit-card', {cardId: props.card._id.toHexString()});
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// remove card
|
// remove card
|
||||||
case "1,1":
|
case "1,1":
|
||||||
realm.write(() => realm.delete(props.card));
|
AMCard.delete(realm, props.card);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}, [realm, props.card, props.navigation]);
|
}, [realm, props.card, props.navigation]);
|
||||||
@@ -50,7 +51,7 @@ function CardEntry(props: { card: AMCard, navigation: any }) {
|
|||||||
{title: "Edit Card", systemIcon: "square.and.pencil"},
|
{title: "Edit Card", systemIcon: "square.and.pencil"},
|
||||||
{title: "Delete Card", destructive: true, systemIcon: "trash"}]
|
{title: "Delete Card", destructive: true, systemIcon: "trash"}]
|
||||||
}]}>
|
}]}>
|
||||||
<AimeCard card={props.card} showFullCardNumber/>
|
<ICCard card={props.card} showFullCardNumber/>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {Plus} from "lucide-react-native";
|
import {Plus} from "lucide-react-native";
|
||||||
import {TouchableOpacity} from "react-native";
|
|
||||||
import {useTheme} from "@react-navigation/native";
|
import {useTheme} from "@react-navigation/native";
|
||||||
|
import {Platform, TouchableOpacity} from "react-native";
|
||||||
import {createNativeStackNavigator} from "@react-navigation/native-stack";
|
import {createNativeStackNavigator} from "@react-navigation/native-stack";
|
||||||
|
|
||||||
import CardManager from "./CardManager";
|
import CardManager from "./CardManager";
|
||||||
@@ -15,14 +15,20 @@ export default function CardManagerStack({navigation}) {
|
|||||||
headerLargeTitle: true,
|
headerLargeTitle: true,
|
||||||
headerBlurEffect: "regular",
|
headerBlurEffect: "regular",
|
||||||
headerStyle: {
|
headerStyle: {
|
||||||
backgroundColor: theme.colors.background,
|
backgroundColor: theme.colors.background
|
||||||
|
},
|
||||||
|
headerTitleStyle: {
|
||||||
|
fontWeight: "bold"
|
||||||
|
},
|
||||||
|
headerLargeTitleStyle: {
|
||||||
|
fontWeight: "bold"
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
<Stack.Screen name="card-listing"
|
<Stack.Screen name="card-listing"
|
||||||
component={CardManager}
|
component={CardManager}
|
||||||
options={{
|
options={{
|
||||||
title: "Cards",
|
title: "Cards",
|
||||||
headerRight: () => <TouchableOpacity onPress={() => navigation.navigate('add-card')}><Plus/></TouchableOpacity>
|
headerRight: () => Platform.OS === 'android' ? null : <TouchableOpacity onPress={() => navigation.navigate('add-card')}><Plus color={theme.colors.primary}/></TouchableOpacity>
|
||||||
}}/>
|
}}/>
|
||||||
</Stack.Navigator>
|
</Stack.Navigator>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import {useModalStyles} from "../utils/ModalStyles";
|
import {StatusBar} from "expo-status-bar";
|
||||||
import {Card, Paragraph, useTheme} from "react-native-paper";
|
import {Card, Text, useTheme} from "react-native-paper";
|
||||||
import {useCallback, useEffect, useLayoutEffect, useState} from "react";
|
import {useCallback, useEffect, useLayoutEffect, useState} from "react";
|
||||||
import {Platform, TextInput, TouchableOpacity, View} from "react-native";
|
import {Alert, Platform, TextInput, TouchableOpacity, View} from "react-native";
|
||||||
|
|
||||||
|
import {useModalStyles} from "../utils/ModalStyles";
|
||||||
import RealmContext from "../models/RealmContext";
|
import RealmContext from "../models/RealmContext";
|
||||||
import {AMServer} from "../models/AMServer";
|
import {AMServer} from "../models/AMServer";
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ export default function ServerAddModal({route, navigation}) {
|
|||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerLeft: () => <TouchableOpacity onPress={() => navigation.goBack()}>
|
headerLeft: () => <TouchableOpacity onPress={() => navigation.goBack()}>
|
||||||
<Paragraph style={modalStyles.headerText}>Cancel</Paragraph>
|
<Text variant="bodyMedium" style={modalStyles.headerText}>Cancel</Text>
|
||||||
</TouchableOpacity>,
|
</TouchableOpacity>,
|
||||||
});
|
});
|
||||||
}, [navigation, modalStyles]);
|
}, [navigation, modalStyles]);
|
||||||
@@ -34,41 +35,74 @@ export default function ServerAddModal({route, navigation}) {
|
|||||||
let urlValid = false;
|
let urlValid = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
new URL(serverAddress);
|
const protocol = new URL(serverAddress).protocol;
|
||||||
urlValid = true;
|
urlValid = protocol.localeCompare("http:", undefined, {sensitivity: "case"}) === 0 || protocol.localeCompare("https:", undefined, {sensitivity: "case"}) === 0;
|
||||||
} catch {
|
} catch {
|
||||||
// do nothing
|
// do nothing
|
||||||
}
|
}
|
||||||
|
|
||||||
const themeOptions = urlValid ? modalStyles.headerText : {color: theme.colors.outline};
|
const themeOptions = urlValid ? modalStyles.headerText : {color: theme.colors.outline};
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () => <TouchableOpacity disabled={!urlValid} onPress={() => saveServer()}>
|
headerRight: () => <TouchableOpacity disabled={!urlValid} onPress={() => saveServer(serverAddress)}>
|
||||||
<Paragraph style={{...themeOptions, fontWeight: "600"}}>Save</Paragraph>
|
<Text variant="bodyMedium" style={{...themeOptions, fontWeight: "600"}}>Save</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
})
|
})
|
||||||
}, [navigation, modalStyles, serverAddress]);
|
}, [navigation, modalStyles, serverAddress]);
|
||||||
|
|
||||||
const saveServer = useCallback(() => {
|
const saveServer = useCallback(async (address: string) => {
|
||||||
let serverObject = realm.objects<AMServer>("server").find(x => x.serverAddress == serverAddress);
|
if (address.startsWith("https:")) {
|
||||||
|
await new Promise<void>(done => {
|
||||||
|
Alert.alert(
|
||||||
|
"HTTPS Warning",
|
||||||
|
"By default, AMNet doesn't support HTTPS without advanced configuration. It's highly likely you won't be able to connect.",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: "Keep HTTPS",
|
||||||
|
style: "destructive",
|
||||||
|
onPress: () => done()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "Change to HTTP",
|
||||||
|
isPreferred: true,
|
||||||
|
onPress: () => {
|
||||||
|
address = address.replace(/^https:/i, "http:");
|
||||||
|
done();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let serverObject = realm.objects<AMServer>(AMServer.schema.name).find(x => x.serverAddress == address);
|
||||||
if (!serverObject) {
|
if (!serverObject) {
|
||||||
realm.write(() => {
|
realm.write(() => {
|
||||||
serverObject = realm.create<AMServer>("server", AMServer.generate(serverAddress));
|
serverObject = realm.create<AMServer>(AMServer.schema.name, AMServer.generate(address));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
navigation.goBack();
|
navigation.goBack();
|
||||||
setTimeout(() => navigation.navigate('server', {
|
setTimeout(() => {
|
||||||
|
navigation.popTo('app', {
|
||||||
|
screen: "servers",
|
||||||
|
params: {
|
||||||
|
screen: "server",
|
||||||
|
params: {
|
||||||
serverId: serverObject._id.toHexString()
|
serverId: serverObject._id.toHexString()
|
||||||
}), 500);
|
}
|
||||||
}, [serverAddress, realm]);
|
}
|
||||||
|
});
|
||||||
|
}, 500);
|
||||||
|
}, [realm]);
|
||||||
|
|
||||||
return (<View style={{display: "flex", gap: 10, justifyContent: "center", paddingHorizontal: 15, paddingTop: 25}}>
|
return (<>
|
||||||
|
{Platform.OS == 'ios' && <StatusBar style="light"/>}
|
||||||
|
<View style={{display: "flex", gap: 10, justifyContent: "center", paddingHorizontal: 15, paddingTop: 25}}>
|
||||||
<Card mode="contained">
|
<Card mode="contained">
|
||||||
<Card.Content>
|
<Card.Content>
|
||||||
<View style={{...modalStyles.formItemRow, gap: 10}}>
|
<View style={{...modalStyles.formItemRow, gap: 10}}>
|
||||||
<Paragraph style={{...modalStyles.formItemName, minWidth: 0}}>
|
<Text variant="bodyMedium" style={{...modalStyles.formItemName, minWidth: 0}}>
|
||||||
Address
|
Address
|
||||||
</Paragraph>
|
</Text>
|
||||||
<TextInput style={modalStyles.formItemInput}
|
<TextInput style={modalStyles.formItemInput}
|
||||||
autoFocus
|
autoFocus
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
@@ -84,10 +118,11 @@ export default function ServerAddModal({route, navigation}) {
|
|||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Paragraph style={modalStyles.formFootnote}>
|
<Text variant="bodyMedium" style={modalStyles.formFootnote}>
|
||||||
If you're using HTTPS, ensure a trusted certificate has been configured via HTTP.SYS
|
AMNet uses HTTP by default. HTTPS will not work unless specifically configured using HTTP.SYS
|
||||||
</Paragraph>
|
</Text>
|
||||||
</View>)
|
</View>
|
||||||
|
</>)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDeepLink(realm, navigation, route, setAddress: (address: string) => void) {
|
function handleDeepLink(realm, navigation, route, setAddress: (address: string) => void) {
|
||||||
@@ -104,7 +139,7 @@ function handleDeepLink(realm, navigation, route, setAddress: (address: string)
|
|||||||
navigation.goBack();
|
navigation.goBack();
|
||||||
|
|
||||||
// allow the homepage to render before moving on
|
// allow the homepage to render before moving on
|
||||||
setTimeout(() => navigation.navigate('server', {
|
setTimeout(() => navigation.popTo('server', {
|
||||||
serverId: item[0]._id.toHexString()
|
serverId: item[0]._id.toHexString()
|
||||||
}), 1000);
|
}), 1000);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,40 +1,31 @@
|
|||||||
import {BSON} from "realm";
|
import {BSON} from "realm";
|
||||||
|
import {ActivityIndicator, Button, Text, useTheme} from "react-native-paper";
|
||||||
import React, {useCallback, useEffect, useLayoutEffect, useMemo, useState} from "react";
|
import React, {useCallback, useEffect, useLayoutEffect, useMemo, useState} from "react";
|
||||||
import {ActivityIndicator, Button, Paragraph, Title, useTheme} from "react-native-paper";
|
import {Alert, Platform, RefreshControl, ScrollView, Share, StyleSheet, View} from "react-native";
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
Platform,
|
|
||||||
RefreshControl,
|
|
||||||
ScrollView,
|
|
||||||
Share,
|
|
||||||
StyleSheet,
|
|
||||||
Text,
|
|
||||||
TouchableOpacity,
|
|
||||||
View
|
|
||||||
} from "react-native";
|
|
||||||
import {
|
import {
|
||||||
CircleCheck,
|
CircleCheck,
|
||||||
CircleDot,
|
CircleDot,
|
||||||
|
CircleEllipsis,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
Nfc,
|
|
||||||
Plus,
|
Plus,
|
||||||
SmartphoneNfcIcon,
|
SmartphoneNfcIcon,
|
||||||
Share as ShareIcon,
|
|
||||||
WifiOff,
|
WifiOff,
|
||||||
TvMinimalPlay,
|
TvMinimalPlay,
|
||||||
Star
|
Star,
|
||||||
|
SquareArrowOutUpRight
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
|
|
||||||
import {EmptyStatePlaceholder} from "../components/EmptyStatePlaceholder";
|
import {EmptyStatePlaceholder} from "../components/EmptyStatePlaceholder";
|
||||||
import {AMUIStatusAlert} from "../components/AMUIStatusAlert";
|
import {AMUIStatusAlert} from "../components/AMUIStatusAlert";
|
||||||
import {AMUICardDetail} from "../components/AMUICard";
|
import {AMUICardDetail} from "../components/AMUICard";
|
||||||
import {AimeCard} from "../components/AimeCard";
|
import {ICCard} from "../components/ICCard";
|
||||||
|
|
||||||
import RealmContext from "../models/RealmContext";
|
import RealmContext from "../models/RealmContext";
|
||||||
import {AMServer} from "../models/AMServer";
|
import {AMServer} from "../models/AMServer";
|
||||||
import {AMCard} from "../models/AMCard";
|
import {AMCard} from "../models/AMCard";
|
||||||
import ObjectId = BSON.ObjectId;
|
|
||||||
import {
|
import {
|
||||||
|
AMCardInRequest,
|
||||||
AMServerConnectionInfo,
|
AMServerConnectionInfo,
|
||||||
AMServerInstanceMetrics,
|
AMServerInstanceMetrics,
|
||||||
fetchServerInfo,
|
fetchServerInfo,
|
||||||
@@ -42,26 +33,59 @@ import {
|
|||||||
performCardIn
|
performCardIn
|
||||||
} from "amnet-shared";
|
} from "amnet-shared";
|
||||||
|
|
||||||
|
import ObjectId = BSON.ObjectId;
|
||||||
|
import {AMCardRecentUse} from "../models/AMCardRecentUse";
|
||||||
|
import {MenuAction, MenuView, NativeActionEvent} from "@react-native-menu/menu";
|
||||||
|
|
||||||
const {useRealm, useQuery, useObject} = RealmContext;
|
const {useRealm, useQuery, useObject} = RealmContext;
|
||||||
|
|
||||||
export default function ServerCardIn({route, navigation}) {
|
export default function ServerCardIn({route, navigation}) {
|
||||||
const realm = useRealm();
|
const realm = useRealm();
|
||||||
const theme = useTheme();
|
const theme = useTheme()
|
||||||
|
|
||||||
let {serverId} = route.params;
|
const {serverId} = route.params;
|
||||||
const server = useObject(AMServer, ObjectId.createFromHexString(serverId));
|
const server = useObject(AMServer, ObjectId.createFromHexString(serverId));
|
||||||
const cards = useQuery({
|
const cards = useQuery({
|
||||||
type: AMCard,
|
type: AMCard,
|
||||||
query: q => q.sorted("lastUsed", true)
|
query: q => q.sorted("lastUsed", true)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// header actions
|
||||||
|
const menuCallback = useCallback(async (e: NativeActionEvent) => {
|
||||||
|
switch (e.nativeEvent.event) {
|
||||||
|
case "share":
|
||||||
|
Share.share({url: server.shareUrl});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "delete":
|
||||||
|
Alert.alert("Delete Server", "Are you sure you want to delete this server?", [
|
||||||
|
{
|
||||||
|
text: "Cancel",
|
||||||
|
style: "cancel"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "Delete",
|
||||||
|
style: "destructive",
|
||||||
|
onPress: () => {
|
||||||
|
realm.write(() => {
|
||||||
|
realm.delete(server);
|
||||||
|
navigation.goBack();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}, [realm, server, navigation]);
|
||||||
|
|
||||||
// server state
|
// server state
|
||||||
const [serverRefreshing, setServerRefreshing] = useState<boolean>(false);
|
const [serverRefreshing, setServerRefreshing] = useState<boolean>(false);
|
||||||
const [serverReloadCounter, setServerReloadCounter] = useState<number>(0);
|
const [serverReloadCounter, setServerReloadCounter] = useState<number>(0);
|
||||||
const [serverConnectionSuccess, setServerConnectionSuccess] = useState<boolean | null>(null);
|
const [serverConnectionSuccess, setServerConnectionSuccess] = useState<boolean | null>(null);
|
||||||
|
|
||||||
const [userSelectedCard, setUserSelectedCard] = useState<AMCard>(null);
|
const [userSelectedCard, setUserSelectedCard] = useState<AMCard | null>(null);
|
||||||
const [cardInCompleted, setCardInCompleted] = useState<boolean>(null);
|
const [cardInCompleted, setCardInCompleted] = useState<boolean | null>(null);
|
||||||
|
|
||||||
const disableCardIn = useMemo(() => cardInCompleted !== null || !serverConnectionSuccess, [cardInCompleted, serverConnectionSuccess]);
|
const disableCardIn = useMemo(() => cardInCompleted !== null || !serverConnectionSuccess, [cardInCompleted, serverConnectionSuccess]);
|
||||||
const serverDefaultCard = useMemo(() => {
|
const serverDefaultCard = useMemo(() => {
|
||||||
@@ -69,8 +93,8 @@ export default function ServerCardIn({route, navigation}) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return cards.find(x => x.lastUsedGameId === server.gameId);
|
return realm.objects<AMCardRecentUse>(AMCardRecentUse.schema.name).find(x => x.gameId === server.gameId)?.card;
|
||||||
}, [cards, server?.gameId]);
|
}, [realm, cards, server?.gameId]);
|
||||||
|
|
||||||
const selectedCard = useMemo(() => [userSelectedCard, serverDefaultCard, cards.at(0)].find(x => x?.isValid() == true) ?? null, [userSelectedCard, serverDefaultCard, cards]);
|
const selectedCard = useMemo(() => [userSelectedCard, serverDefaultCard, cards.at(0)].find(x => x?.isValid() == true) ?? null, [userSelectedCard, serverDefaultCard, cards]);
|
||||||
const unselectedCards = useMemo(() => cards.filter(x => x.isValid() === true && x._id.toHexString() !== selectedCard?._id.toHexString()), [cards, selectedCard]);
|
const unselectedCards = useMemo(() => cards.filter(x => x.isValid() === true && x._id.toHexString() !== selectedCard?._id.toHexString()), [cards, selectedCard]);
|
||||||
@@ -83,30 +107,20 @@ export default function ServerCardIn({route, navigation}) {
|
|||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
title: server.serverName,
|
title: server.serverName,
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<TouchableOpacity onPress={() => Share.share({url: server.shareUrl})}>
|
<MenuView actions={menuActions} onPressAction={menuCallback}>
|
||||||
<ShareIcon color={theme.colors.primary}/>
|
<CircleEllipsis color={theme.colors.primary}/>
|
||||||
</TouchableOpacity>
|
</MenuView>
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
}, [navigation, server]);
|
}, [navigation, server]);
|
||||||
|
|
||||||
// invalid card/server selection
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (server?.isValid() !== true) {
|
if (server?.isValid() !== true) {
|
||||||
navigation.goBack();
|
navigation.goBack();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}, [server]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
if (!serverConnectionSuccess) {
|
||||||
if (userSelectedCard?.isValid() === false) {
|
|
||||||
setUserSelectedCard(null);
|
|
||||||
}
|
|
||||||
}, [userSelectedCard]);
|
|
||||||
|
|
||||||
// handle auto-refresh
|
|
||||||
useEffect(() => {
|
|
||||||
if (!serverConnectionSuccess || server?.isValid() !== true) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,6 +131,13 @@ export default function ServerCardIn({route, navigation}) {
|
|||||||
}
|
}
|
||||||
}, [serverConnectionSuccess, server]);
|
}, [serverConnectionSuccess, server]);
|
||||||
|
|
||||||
|
// invalid card/server selection
|
||||||
|
useEffect(() => {
|
||||||
|
if (userSelectedCard?.isValid() === false) {
|
||||||
|
setUserSelectedCard(null);
|
||||||
|
}
|
||||||
|
}, [userSelectedCard]);
|
||||||
|
|
||||||
// server info update
|
// server info update
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
@@ -145,16 +166,24 @@ export default function ServerCardIn({route, navigation}) {
|
|||||||
}
|
}
|
||||||
}, [serverId, serverReloadCounter, realm]);
|
}, [serverId, serverReloadCounter, realm]);
|
||||||
|
|
||||||
const cardInCallback = useCallback(() => {
|
const cardInCallback = useCallback((card: AMCard) => {
|
||||||
setCardInCompleted(false);
|
|
||||||
|
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
performCardIn(server, {cardId: selectedCard.code}, abortController.signal).then(async r => {
|
const request: AMCardInRequest = {
|
||||||
|
cardId: card.code,
|
||||||
|
physicalCardIDm: card.physicalCardData?.cardIDm ?? undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
performCardIn(server, request, abortController.signal).then(r => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
realm.write(() => {
|
realm.write(() => {
|
||||||
server.lastUsed = now;
|
server.lastUsed = now;
|
||||||
selectedCard.lastUsed = now;
|
card.lastUsed = now;
|
||||||
selectedCard.lastUsedGameId = server.gameId;
|
|
||||||
|
if (server.gameId) {
|
||||||
|
// replace the "most recent card" record for the current game
|
||||||
|
realm.delete(realm.objects(AMCardRecentUse.schema.name).filtered("gameId = $0", server.gameId));
|
||||||
|
realm.create(AMCardRecentUse.schema.name, AMCardRecentUse.generate(server.gameId, card));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
setCardInCompleted(r);
|
setCardInCompleted(r);
|
||||||
@@ -166,7 +195,7 @@ export default function ServerCardIn({route, navigation}) {
|
|||||||
|
|
||||||
setTimeout(() => abortController.abort(), 5000);
|
setTimeout(() => abortController.abort(), 5000);
|
||||||
return () => abortController.abort();
|
return () => abortController.abort();
|
||||||
}, [server, selectedCard]);
|
}, [server]);
|
||||||
|
|
||||||
const performRefresh = () => {
|
const performRefresh = () => {
|
||||||
setServerRefreshing(true);
|
setServerRefreshing(true);
|
||||||
@@ -181,7 +210,7 @@ export default function ServerCardIn({route, navigation}) {
|
|||||||
return (
|
return (
|
||||||
<View style={{flex: 1, justifyContent: "center", alignItems: "center", gap: 10}}>
|
<View style={{flex: 1, justifyContent: "center", alignItems: "center", gap: 10}}>
|
||||||
<ActivityIndicator animating/>
|
<ActivityIndicator animating/>
|
||||||
<Paragraph>Connecting to server...</Paragraph>
|
<Text variant="bodyMedium">Connecting to server...</Text>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -191,20 +220,21 @@ export default function ServerCardIn({route, navigation}) {
|
|||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<StatusAlert server={server} connectionEstablished={serverConnectionSuccess}/>
|
<StatusAlert server={server} connectionEstablished={serverConnectionSuccess}/>
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
<Title style={styles.sectionTitle}>{server.gameId ? `Play ${getGameName(server.gameId)}` : "Selected Card"}</Title>
|
<Text variant="titleLarge" style={styles.sectionTitle}>{server.gameId ? `Play ${getGameName(server.gameId)}` : "Selected Card"}</Text>
|
||||||
{selectedCard ? <>
|
{selectedCard ? <>
|
||||||
<AimeCard actionDisabled
|
<ICCard actionDisabled
|
||||||
card={selectedCard}
|
card={selectedCard}
|
||||||
actionIcon={<CircleCheck color="green" size={24}/>}>
|
actionIcon={<CircleCheck color="green" size={24}/>}>
|
||||||
{serverDefaultCard?.code == selectedCard.code && <DefaultCardDetail/>}
|
{serverDefaultCard?.code == selectedCard.code && <DefaultCardDetail/>}
|
||||||
</AimeCard>
|
</ICCard>
|
||||||
<Button textColor="white"
|
<Button style={{flex: 1}}
|
||||||
onPress={cardInCallback}
|
textColor="white"
|
||||||
disabled={disableCardIn}
|
disabled={disableCardIn}
|
||||||
contentStyle={{flexDirection: 'row-reverse'}}
|
contentStyle={{flexDirection: 'row-reverse'}}
|
||||||
mode={serverConnectionSuccess ? "contained" : "outlined"}
|
mode={serverConnectionSuccess ? "contained" : "outlined"}
|
||||||
buttonColor={cardInCompleted === false ? "red" : "green"}
|
buttonColor={cardInCompleted === false ? "red" : "green"}
|
||||||
icon={() => <Nfc color={disableCardIn ? "gray" : "white"} size={18}/>}>
|
onPress={() => cardInCallback(selectedCard)}
|
||||||
|
icon={() => <SquareArrowOutUpRight color={disableCardIn ? "gray" : "white"} size={18}/>}>
|
||||||
{cardInCompleted === null ? "Card In" : cardInCompleted ? "Card In Success" : "Failed"}
|
{cardInCompleted === null ? "Card In" : cardInCompleted ? "Card In Success" : "Failed"}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
@@ -213,34 +243,34 @@ export default function ServerCardIn({route, navigation}) {
|
|||||||
<Button mode="text"
|
<Button mode="text"
|
||||||
icon={() => <Plus color={theme.colors.primary} size={18}/>}
|
icon={() => <Plus color={theme.colors.primary} size={18}/>}
|
||||||
onPress={() => navigation.navigate('add-card')}>
|
onPress={() => navigation.navigate('add-card')}>
|
||||||
Add card
|
Add Card
|
||||||
</Button>
|
</Button>
|
||||||
</View>}
|
</View>
|
||||||
|
}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{cards.length > 0 && (
|
{cards.length > 0 && (
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
<Title style={styles.sectionTitle}>Additional Cards</Title>
|
<Text variant="titleLarge" style={styles.sectionTitle}>Additional Cards</Text>
|
||||||
<Button mode="contained"
|
<Button mode="contained"
|
||||||
textColor="white"
|
textColor="white"
|
||||||
buttonColor={theme.colors.tertiary}
|
buttonColor={theme.colors.tertiary}
|
||||||
icon={() => <Plus color="white" size={18}/>}
|
|
||||||
contentStyle={{flexDirection: 'row-reverse'}}
|
contentStyle={{flexDirection: 'row-reverse'}}
|
||||||
|
icon={() => <Plus color="white" size={18}/>}
|
||||||
onPress={() => navigation.navigate('add-card')}>
|
onPress={() => navigation.navigate('add-card')}>
|
||||||
Add card
|
Add Card
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{unselectedCards.length
|
{unselectedCards.length
|
||||||
? unselectedCards.map((card: AMCard) => (
|
? unselectedCards.map((card: AMCard) => (
|
||||||
<AimeCard card={card}
|
<ICCard card={card}
|
||||||
key={card._id.toHexString()}
|
key={card._id.toHexString()}
|
||||||
actionIcon={<CircleDot color={theme.colors.tertiary} size={24}/>}
|
actionIcon={<CircleDot color={theme.colors.tertiary} size={24}/>}
|
||||||
onPress={() => setUserSelectedCard(card)}>
|
onPress={() => setUserSelectedCard(card)}>
|
||||||
{serverDefaultCard?.code == card.code && <DefaultCardDetail/>}
|
{serverDefaultCard?.code == card.code && <DefaultCardDetail/>}
|
||||||
</AimeCard>))
|
</ICCard>))
|
||||||
: <View style={styles.emptyPlaceholderView}>
|
: <View style={styles.emptyPlaceholderView}>
|
||||||
<EmptyStatePlaceholder message="No additional cards"
|
<EmptyStatePlaceholder message="No additional cards" icon={<SmartphoneNfcIcon color="gray"/>}/>
|
||||||
icon={<SmartphoneNfcIcon color="gray"/>}/>
|
|
||||||
</View>}
|
</View>}
|
||||||
</View>)}
|
</View>)}
|
||||||
</View>
|
</View>
|
||||||
@@ -255,11 +285,11 @@ function StatusAlert(props: {
|
|||||||
if (!props.connectionEstablished) {
|
if (!props.connectionEstablished) {
|
||||||
return (
|
return (
|
||||||
<AMUIStatusAlert color="red" icon={WifiOff} title="Connection Failed">
|
<AMUIStatusAlert color="red" icon={WifiOff} title="Connection Failed">
|
||||||
<Paragraph>
|
<Text variant="bodyMedium">
|
||||||
There was an issue connecting to <Paragraph
|
There was an issue connecting to <Text variant="bodyMedium"
|
||||||
style={{fontFamily: "JetBrainsMono_400Regular"}}>{props.server.serverAddress}</Paragraph>.
|
style={{fontFamily: "JetBrainsMono_400Regular"}}>{props.server.serverAddress}</Text>.
|
||||||
Check the network connection and try again.
|
Check the network connection and try again.
|
||||||
</Paragraph>
|
</Text>
|
||||||
</AMUIStatusAlert>
|
</AMUIStatusAlert>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -268,9 +298,9 @@ function StatusAlert(props: {
|
|||||||
if (props.server.timeSinceLastPoll === null) {
|
if (props.server.timeSinceLastPoll === null) {
|
||||||
return (
|
return (
|
||||||
<AMUIStatusAlert color="#822eff" icon={TvMinimalPlay} title="Server Startup">
|
<AMUIStatusAlert color="#822eff" icon={TvMinimalPlay} title="Server Startup">
|
||||||
<Paragraph>
|
<Text variant="bodyMedium">
|
||||||
Scanning a card below may not have any effect until the service has fully started up.
|
Scanning a card below may not have any effect until the service has fully started up.
|
||||||
</Paragraph>
|
</Text>
|
||||||
</AMUIStatusAlert>
|
</AMUIStatusAlert>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -292,6 +322,28 @@ function DefaultCardDetail() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const menuActions: MenuAction[] = [
|
||||||
|
{
|
||||||
|
id: "share",
|
||||||
|
title: "Share",
|
||||||
|
image: Platform.select({
|
||||||
|
ios: "square.and.arrow.up",
|
||||||
|
android: "share"
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "delete",
|
||||||
|
title: "Delete Server",
|
||||||
|
attributes: {
|
||||||
|
destructive: true,
|
||||||
|
},
|
||||||
|
image: Platform.select({
|
||||||
|
ios: "trash",
|
||||||
|
android: "delete"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
section: {
|
section: {
|
||||||
gap: 10
|
gap: 10
|
||||||
@@ -304,7 +356,7 @@ const styles = StyleSheet.create({
|
|||||||
gap: 15
|
gap: 15
|
||||||
},
|
},
|
||||||
container: {
|
container: {
|
||||||
paddingTop: Platform.OS === 'android' ? 120 : 0,
|
paddingTop: Platform.select({android: 120, default: 0}),
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
paddingBottom: 80,
|
paddingBottom: 80,
|
||||||
gap: 20
|
gap: 20
|
||||||
|
|||||||
@@ -1,77 +1,47 @@
|
|||||||
import {Button, Paragraph, useTheme} from "react-native-paper";
|
import {getGameName} from "amnet-shared";
|
||||||
|
import {FlatList, Platform, View} from "react-native";
|
||||||
|
import {Button, Text, useTheme} from "react-native-paper";
|
||||||
import React, {useCallback, useLayoutEffect, useMemo, useState} from "react";
|
import React, {useCallback, useLayoutEffect, useMemo, useState} from "react";
|
||||||
import {Globe, History, Joystick, Plus, ServerOff} from "lucide-react-native";
|
import {Globe, History, Joystick, Plus, ServerOff} from "lucide-react-native";
|
||||||
import {FlatList, NativeSyntheticEvent, Platform, ScrollView, Share, View} from "react-native";
|
|
||||||
import ContextMenu, {ContextMenuOnPressNativeEvent} from "react-native-context-menu-view";
|
|
||||||
import {EmptyStatePlaceholder} from "../components/EmptyStatePlaceholder";
|
|
||||||
import {AMUICard, AMUICardDetail} from "../components/AMUICard";
|
|
||||||
import TimeAgo from "../components/TimeAgo";
|
import TimeAgo from "../components/TimeAgo";
|
||||||
|
import {AMUICard, AMUICardDetail} from "../components/AMUICard";
|
||||||
|
import {EmptyStatePlaceholder} from "../components/EmptyStatePlaceholder";
|
||||||
|
|
||||||
import RealmContext from "../models/RealmContext";
|
|
||||||
import {AMServer} from "../models/AMServer";
|
import {AMServer} from "../models/AMServer";
|
||||||
import {getGameName} from "amnet-shared";
|
import RealmContext from "../models/RealmContext";
|
||||||
|
|
||||||
const {useRealm, useQuery} = RealmContext;
|
const {useQuery} = RealmContext;
|
||||||
|
|
||||||
function ServerCard(props: { server: AMServer, navigation: any }) {
|
function ServerCard(props: { server: AMServer, navigation: any }) {
|
||||||
const realm = useRealm();
|
const gameName = useMemo(() => getGameName(props.server.gameId ?? ''), [props.server.gameId]);
|
||||||
const gameName = useMemo(() => getGameName(props.server.gameId), [props.server.gameId]);
|
|
||||||
|
|
||||||
const pageCallback = useCallback(() => {
|
const pageCallback = useCallback(() => {
|
||||||
props.navigation.navigate('server', {
|
props.navigation.navigate('server', {
|
||||||
serverId: props.server._id.toHexString()
|
serverId: props.server._id.toHexString()
|
||||||
});
|
});
|
||||||
}, [props.navigation, props.server]);
|
}, [props.navigation, props.server]);
|
||||||
|
|
||||||
const pageContextMenuCallback = useCallback((action: NativeSyntheticEvent<ContextMenuOnPressNativeEvent>) => {
|
|
||||||
switch (action.nativeEvent.indexPath.toString()) {
|
|
||||||
case "0":
|
|
||||||
pageCallback();
|
|
||||||
return;
|
|
||||||
|
|
||||||
case "1,0":
|
|
||||||
Share.share({url: props.server.shareUrl});
|
|
||||||
return;
|
|
||||||
|
|
||||||
case "1,1":
|
|
||||||
realm.write(() => realm.delete(props.server));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}, [realm, props.server]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContextMenu onPress={pageContextMenuCallback} actions={[
|
|
||||||
{title: `Select ${props.server.serverName}`, systemIcon: "chevron.right"},
|
|
||||||
{
|
|
||||||
title: Platform.OS === "ios" ? "" : "Additional Actions",
|
|
||||||
inlineChildren: true,
|
|
||||||
actions: [
|
|
||||||
{title: "Share Server", systemIcon: "square.and.arrow.up"},
|
|
||||||
{title: "Delete Server", systemIcon: "trash", destructive: true}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]}>
|
|
||||||
<AMUICard title={props.server.serverName} onPress={pageCallback}>
|
<AMUICard title={props.server.serverName} onPress={pageCallback}>
|
||||||
<AMUICardDetail>
|
<AMUICardDetail>
|
||||||
<Globe size={18} color="gray"/>
|
<Globe size={18} color="gray"/>
|
||||||
<Paragraph style={{color: "gray", fontFamily: "JetBrainsMono_400Regular"}}>
|
<Text variant="bodyMedium" style={{color: "gray", fontFamily: "JetBrainsMono_400Regular"}}>
|
||||||
{props.server.serverAddress}
|
{props.server.serverAddress}
|
||||||
</Paragraph>
|
</Text>
|
||||||
</AMUICardDetail>
|
</AMUICardDetail>
|
||||||
{gameName && (
|
{gameName && (
|
||||||
<AMUICardDetail>
|
<AMUICardDetail>
|
||||||
<Joystick size={18} color="gray"/>
|
<Joystick size={18} color="gray"/>
|
||||||
<Paragraph style={{color: "gray"}}>{gameName}</Paragraph>
|
<Text variant="bodyMedium" style={{color: "gray"}}>{gameName}</Text>
|
||||||
</AMUICardDetail>
|
</AMUICardDetail>
|
||||||
)}
|
)}
|
||||||
<AMUICardDetail>
|
<AMUICardDetail>
|
||||||
<History size={18} color="gray"/>
|
<History size={18} color="gray"/>
|
||||||
{props.server.lastUsed
|
{props.server.lastUsed
|
||||||
? (<TimeAgo style={{color: "gray"}} dateTo={props.server.lastUsed}/>)
|
? (<TimeAgo style={{color: "gray"}} dateTo={props.server.lastUsed}/>)
|
||||||
: (<Paragraph style={{color: "gray"}}>never</Paragraph>)}
|
: (<Text variant="bodyMedium" style={{color: "gray"}}>never</Text>)}
|
||||||
</AMUICardDetail>
|
</AMUICardDetail>
|
||||||
</AMUICard>
|
</AMUICard>
|
||||||
</ContextMenu>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +57,7 @@ export default function ServerManager({navigation}) {
|
|||||||
q = q.filtered("serverName CONTAINS[c] $0 OR serverAddress CONTAINS[c] $0", searchQuery);
|
q = q.filtered("serverName CONTAINS[c] $0 OR serverAddress CONTAINS[c] $0", searchQuery);
|
||||||
}
|
}
|
||||||
|
|
||||||
return q.sorted("lastUsed");
|
return q.sorted("lastUsed", true);
|
||||||
}, [query, searchQuery]);
|
}, [query, searchQuery]);
|
||||||
|
|
||||||
// enable searchbar
|
// enable searchbar
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {Plus} from "lucide-react-native";
|
import {Plus} from "lucide-react-native";
|
||||||
import {TouchableOpacity} from "react-native";
|
|
||||||
import {useTheme} from "@react-navigation/native";
|
import {useTheme} from "@react-navigation/native";
|
||||||
|
import {Platform, TouchableOpacity} from "react-native";
|
||||||
import {createNativeStackNavigator} from "@react-navigation/native-stack";
|
import {createNativeStackNavigator} from "@react-navigation/native-stack";
|
||||||
|
|
||||||
import ServerCardIn from "./ServerCardIn";
|
import ServerCardIn from "./ServerCardIn";
|
||||||
@@ -18,16 +18,19 @@ export default function ServerManagerStack({navigation}) {
|
|||||||
headerBlurEffect: "regular",
|
headerBlurEffect: "regular",
|
||||||
headerStyle: {
|
headerStyle: {
|
||||||
backgroundColor: theme.colors.background
|
backgroundColor: theme.colors.background
|
||||||
|
},
|
||||||
|
headerTitleStyle: {
|
||||||
|
fontWeight: "bold"
|
||||||
|
},
|
||||||
|
headerLargeTitleStyle: {
|
||||||
|
fontWeight: "bold"
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
<Stack.Screen name="server-listing"
|
<Stack.Screen name="server-listing"
|
||||||
component={ServerManager}
|
component={ServerManager}
|
||||||
options={{
|
options={{
|
||||||
title: "Servers",
|
title: "Servers",
|
||||||
headerRight: () => <TouchableOpacity
|
headerRight: () => Platform.OS === 'android' ? null : <TouchableOpacity onPress={() => navigation.navigate('add-server')}><Plus color={theme.colors.primary}/></TouchableOpacity>
|
||||||
onPress={() => navigation.navigate('add-server')}>
|
|
||||||
<Plus/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
}}/>
|
}}/>
|
||||||
|
|
||||||
<Stack.Screen name="server" options={{title: "Server", headerTransparent: true}} component={ServerCardIn}/>
|
<Stack.Screen name="server" options={{title: "Server", headerTransparent: true}} component={ServerCardIn}/>
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {StatusBar} from "expo-status-bar";
|
||||||
|
import {Platform, TouchableOpacity, View} from "react-native";
|
||||||
|
import {createNativeStackNavigator} from "@react-navigation/native-stack";
|
||||||
|
|
||||||
|
import {Card, Text, useTheme} from "react-native-paper";
|
||||||
|
import {SquareAsterisk, SquarePlus} from "lucide-react-native";
|
||||||
|
|
||||||
|
import ManualCardEntry from "./ManualCardEntry";
|
||||||
|
|
||||||
|
const Stack = createNativeStackNavigator();
|
||||||
|
|
||||||
|
function CardAddModal() {
|
||||||
|
return (
|
||||||
|
<Stack.Navigator initialRouteName="Select" screenOptions={{headerTitleStyle: {fontWeight: "bold"}}}>
|
||||||
|
<Stack.Screen name="Select" component={CardAddMethodSelector} options={{headerShown: false}}/>
|
||||||
|
|
||||||
|
<Stack.Screen name="Manual" component={ManualCardEntry} options={{title: "Add Card Manually"}}/>
|
||||||
|
</Stack.Navigator>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAddMethodSelector({navigation}) {
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{Platform.OS === 'ios' && <StatusBar style="light"/>}
|
||||||
|
<View style={{paddingHorizontal: 15, paddingTop: 90, gap: 8}}>
|
||||||
|
<View style={{paddingBottom: 25, gap: 5}}>
|
||||||
|
<Text variant="titleLarge" style={{textAlign: "center", fontSize: 32, lineHeight: 32, fontWeight: "bold"}}>
|
||||||
|
Add new Card
|
||||||
|
</Text>
|
||||||
|
<Text variant="bodyMedium" style={{textAlign: "center"}}>
|
||||||
|
AMNet supports scanning physical cards, entering details manually or generating new ones.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<MethodCard color={theme.colors.secondary}
|
||||||
|
icon={<SquareAsterisk/>}
|
||||||
|
title="Enter Details Manually"
|
||||||
|
onPress={() => navigation.navigate('Manual')}/>
|
||||||
|
|
||||||
|
<MethodCard color="#4caf50"
|
||||||
|
icon={<SquarePlus/>}
|
||||||
|
title="Generate New Card"
|
||||||
|
onPress={() => navigation.navigate('Manual', {autoGenerate: true})}/>
|
||||||
|
</View>
|
||||||
|
</>)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MethodCard(props: {
|
||||||
|
title: string,
|
||||||
|
description?: string,
|
||||||
|
color: string,
|
||||||
|
disabled?: boolean,
|
||||||
|
icon: React.ReactElement,
|
||||||
|
onPress: () => void
|
||||||
|
}) {
|
||||||
|
const theme = useTheme();
|
||||||
|
const large = props.description?.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity onPress={() => !props.disabled && props.onPress()} disabled={props.disabled}>
|
||||||
|
<Card mode={theme.dark ? "elevated" : "contained"} disabled={props.disabled}>
|
||||||
|
<Card.Content style={{display: "flex", flexDirection: "row", alignItems: "center", gap: 15}}>
|
||||||
|
<View style={{
|
||||||
|
borderRadius: large ? 10 : 5,
|
||||||
|
height: large ? 50 : 30,
|
||||||
|
width: large ? 50 : 30,
|
||||||
|
backgroundColor: props.color,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center"
|
||||||
|
}}>
|
||||||
|
{React.cloneElement(props.icon, {size: large ? 24 : 16, color: theme.colors.surfaceVariant})}
|
||||||
|
</View>
|
||||||
|
<View style={{flexShrink: 1}}>
|
||||||
|
<Text style={{fontSize: large ? 16 : 14}}>{props.title}</Text>
|
||||||
|
{props.description && (<Text variant="bodyMedium" style={{fontSize: 12}}>{props.description}</Text>)}
|
||||||
|
</View>
|
||||||
|
</Card.Content>
|
||||||
|
</Card>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CardAddModal;
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import {BSON} from "realm";
|
||||||
|
import {StatusBar} from "expo-status-bar";
|
||||||
|
import {Card, Divider, Text, useTheme} from "react-native-paper";
|
||||||
|
import {useCallback, useEffect, useLayoutEffect, useState} from "react";
|
||||||
|
import {Platform, TextInput, TouchableOpacity, View} from "react-native";
|
||||||
|
|
||||||
|
import {useModalStyles} from "../../utils/ModalStyles";
|
||||||
|
|
||||||
|
import {AMCard} from "../../models/AMCard";
|
||||||
|
import RealmContext from "../../models/RealmContext";
|
||||||
|
|
||||||
|
const {useObject, useRealm} = RealmContext;
|
||||||
|
|
||||||
|
export default function CardEditorModal({navigation, route}) {
|
||||||
|
if (!route.params || !route.params.cardId) {
|
||||||
|
navigation.goBack();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const theme = useTheme();
|
||||||
|
const modalStyles = useModalStyles();
|
||||||
|
|
||||||
|
const realm = useRealm();
|
||||||
|
const card = useObject(AMCard, BSON.ObjectId.createFromHexString(route.params.cardId));
|
||||||
|
|
||||||
|
const [cardName, setCardName] = useState<string>('');
|
||||||
|
|
||||||
|
const updateCardCallback = useCallback((newName: string) => {
|
||||||
|
realm.write(() => {
|
||||||
|
card.name = newName;
|
||||||
|
});
|
||||||
|
|
||||||
|
navigation.goBack();
|
||||||
|
}, [realm, navigation]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (card?.isValid() !== true) {
|
||||||
|
navigation.goBack();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cardName.length) {
|
||||||
|
setCardName(card.name);
|
||||||
|
}
|
||||||
|
}, [card]);
|
||||||
|
|
||||||
|
// header effects
|
||||||
|
if (Platform.OS === 'ios') {
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerLeft: () => <TouchableOpacity onPress={() => navigation.goBack()}>
|
||||||
|
<Text variant="bodyMedium" style={modalStyles.headerText}>Cancel</Text>
|
||||||
|
</TouchableOpacity>,
|
||||||
|
});
|
||||||
|
}, [navigation, modalStyles]);
|
||||||
|
}
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerRight: () => <TouchableOpacity onPress={() => updateCardCallback(cardName)} disabled={!cardName.length}>
|
||||||
|
<Text variant="bodyMedium" style={modalStyles.headerText}>Save</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
});
|
||||||
|
}, [cardName]);
|
||||||
|
|
||||||
|
return (<>
|
||||||
|
{Platform.OS === 'ios' && <StatusBar style="light"/>}
|
||||||
|
<View style={modalStyles.rootLayout}>
|
||||||
|
<Card mode="contained">
|
||||||
|
<Card.Content>
|
||||||
|
<View style={modalStyles.formItemRow}>
|
||||||
|
<Text variant="bodyMedium" style={modalStyles.formItemName}>
|
||||||
|
Card Name
|
||||||
|
</Text>
|
||||||
|
<TextInput style={modalStyles.formItemInput}
|
||||||
|
autoFocus
|
||||||
|
numberOfLines={1}
|
||||||
|
value={cardName}
|
||||||
|
onChangeText={setCardName}
|
||||||
|
placeholder="Primary Card"
|
||||||
|
placeholderTextColor={theme.colors.outline}/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Divider style={{marginVertical: 15}}/>
|
||||||
|
|
||||||
|
<View style={modalStyles.formItemRow}>
|
||||||
|
<Text variant="bodyMedium" style={modalStyles.formItemName}>
|
||||||
|
Access Code
|
||||||
|
</Text>
|
||||||
|
<TextInput readOnly
|
||||||
|
numberOfLines={1}
|
||||||
|
value={card?.code.match(/.{1,4}/g)?.join(" ")}
|
||||||
|
style={{...modalStyles.formItemInput, color: theme.colors.onSurfaceDisabled}}/>
|
||||||
|
</View>
|
||||||
|
</Card.Content>
|
||||||
|
</Card>
|
||||||
|
</View>
|
||||||
|
</>)
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import {StatusBar} from "expo-status-bar";
|
||||||
|
import {generateCardNumber, DecodeAMAccessCode} from "amnet-shared";
|
||||||
|
import {useCallback, useEffect, useLayoutEffect, useState} from "react";
|
||||||
|
import {Button, Card, Divider, Text, useTheme} from "react-native-paper";
|
||||||
|
import {Alert, Platform, TextInput, TouchableOpacity, View} from "react-native";
|
||||||
|
|
||||||
|
import {AMCard} from "../../models/AMCard";
|
||||||
|
import RealmContext from "../../models/RealmContext";
|
||||||
|
import {useModalStyles} from "../../utils/ModalStyles";
|
||||||
|
|
||||||
|
const {useRealm} = RealmContext;
|
||||||
|
|
||||||
|
export default function CardAddModal({navigation, route}) {
|
||||||
|
const realm = useRealm();
|
||||||
|
const theme = useTheme();
|
||||||
|
const modalStyles = useModalStyles();
|
||||||
|
|
||||||
|
// state values
|
||||||
|
const [cardName, setCardName] = useState('');
|
||||||
|
const [cardNumber, setCardNumber] = useState('');
|
||||||
|
|
||||||
|
const cardAddCallback = useCallback(async (cardId: string, cardName: string) => {
|
||||||
|
try {
|
||||||
|
// validate AM card numbers
|
||||||
|
cardId.startsWith('5') && DecodeAMAccessCode(cardId);
|
||||||
|
} catch {
|
||||||
|
const shouldContinue = await new Promise<boolean>(done => {
|
||||||
|
Alert.alert(
|
||||||
|
"Invalid Access Code",
|
||||||
|
"It looks like the access code isn't from an Amusement IC card. Please double-check the code, and if it's correct, note that some networks might still reject it.",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
style: "destructive",
|
||||||
|
text: "Continue Anyway",
|
||||||
|
onPress: () => done(true)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
style: "cancel",
|
||||||
|
text: "Cancel",
|
||||||
|
onPress: () => done(false)
|
||||||
|
}
|
||||||
|
], {
|
||||||
|
cancelable: true,
|
||||||
|
onDismiss: () => done(false)
|
||||||
|
});
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!shouldContinue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const duplicateCard = realm.objects<AMCard>(AMCard.schema.name).find(x => x.code === cardId);
|
||||||
|
|
||||||
|
if (duplicateCard) {
|
||||||
|
realm.write(() => {
|
||||||
|
duplicateCard.name = cardName;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
realm.write(() => {
|
||||||
|
realm.create<AMCard>(AMCard.schema.name, AMCard.generate(cardId, cardName));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Platform.OS === 'ios') {
|
||||||
|
navigation.getParent()?.goBack();
|
||||||
|
} else {
|
||||||
|
navigation.goBack();
|
||||||
|
}
|
||||||
|
}, [realm]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const canSave = cardName.length && cardNumber.length === 20;
|
||||||
|
const themeOptions = canSave ? modalStyles.headerText : {color: theme.colors.outline};
|
||||||
|
|
||||||
|
navigation.setOptions({
|
||||||
|
headerRight: () => <TouchableOpacity disabled={!canSave}
|
||||||
|
onPress={() => cardAddCallback(cardNumber, cardName)}>
|
||||||
|
<Text variant="bodyMedium" style={{...themeOptions}}>Save</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
})
|
||||||
|
}, [navigation, modalStyles, cardName, cardNumber]);
|
||||||
|
|
||||||
|
// check params for autoGenerate request
|
||||||
|
useEffect(() => {
|
||||||
|
if (route.params?.autoGenerate) {
|
||||||
|
setCardNumber(generateCardNumber());
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (<>
|
||||||
|
{Platform.OS == 'ios' && <StatusBar style="light"/>}
|
||||||
|
<View style={modalStyles.rootLayout}>
|
||||||
|
<Card mode="contained">
|
||||||
|
<Card.Content>
|
||||||
|
<View style={modalStyles.formItemRow}>
|
||||||
|
<Text variant="bodyMedium" style={modalStyles.formItemName}>
|
||||||
|
Card Name
|
||||||
|
</Text>
|
||||||
|
<TextInput style={modalStyles.formItemInput}
|
||||||
|
autoFocus
|
||||||
|
numberOfLines={1}
|
||||||
|
value={cardName}
|
||||||
|
onChangeText={setCardName}
|
||||||
|
placeholder="Primary Card"
|
||||||
|
placeholderTextColor={theme.colors.outline}/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Divider style={{marginVertical: 15}}/>
|
||||||
|
|
||||||
|
<View style={modalStyles.formItemRow}>
|
||||||
|
<Text variant="bodyMedium" style={modalStyles.formItemName}>
|
||||||
|
Access Code
|
||||||
|
</Text>
|
||||||
|
<TextInput numberOfLines={1}
|
||||||
|
keyboardType="numeric"
|
||||||
|
placeholder="0000 0000 0000 0000 0000"
|
||||||
|
placeholderTextColor={theme.colors.outline}
|
||||||
|
value={cardNumber.match(/.{1,4}/g)?.join(" ")}
|
||||||
|
onChangeText={val => setCardNumber(val?.replaceAll(/\D/g, '').substring(0, 20) ?? '')}
|
||||||
|
style={{...modalStyles.formItemInput, color: theme.colors.onSurface}}/>
|
||||||
|
</View>
|
||||||
|
</Card.Content>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Button mode="text" onPress={() => setCardNumber(generateCardNumber())}>Generate Access Code</Button>
|
||||||
|
</View>
|
||||||
|
</>)
|
||||||
|
}
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 18 KiB |
@@ -1,6 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import {TouchableOpacity, View} from "react-native";
|
import {TouchableOpacity, View} from "react-native";
|
||||||
import {Card, Title, useTheme} from "react-native-paper";
|
import {Card, Text, useTheme} from "react-native-paper";
|
||||||
|
|
||||||
export interface AMUICardActionProps {
|
export interface AMUICardActionProps {
|
||||||
actionIcon?: Readonly<React.ReactNode>,
|
actionIcon?: Readonly<React.ReactNode>,
|
||||||
@@ -21,7 +21,7 @@ export function AMUICard(props: AMUICardActionProps & AMUICardProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity disabled={props.actionDisabled} onPress={props.onPress} onLongPress={props.onLongPress}>
|
<TouchableOpacity disabled={props.actionDisabled} onPress={props.onPress} onLongPress={props.onLongPress} delayLongPress={150}>
|
||||||
<CardComponent {...props}/>
|
<CardComponent {...props}/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
@@ -48,7 +48,7 @@ function CardComponent(props: AMUICardProps & AMUICardActionProps) {
|
|||||||
gap: 5
|
gap: 5
|
||||||
}}>
|
}}>
|
||||||
<View style={{flexGrow: 1}}>
|
<View style={{flexGrow: 1}}>
|
||||||
<Title style={{fontWeight: "bold"}}>{props.title}</Title>
|
<Text variant="titleLarge" style={{fontWeight: "bold"}}>{props.title}</Text>
|
||||||
<View style={{display: "flex", gap: 8, flexWrap: "wrap", marginTop: 10}}>
|
<View style={{display: "flex", gap: 8, flexWrap: "wrap", marginTop: 10}}>
|
||||||
{props.children}
|
{props.children}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {View} from "react-native";
|
import {View} from "react-native";
|
||||||
import React, {ReactNode} from "react";
|
import React, {ReactNode} from "react";
|
||||||
import {Card, Title} from "react-native-paper";
|
import {Card, Text} from "react-native-paper";
|
||||||
import {LucideIcon, LucideProps} from "lucide-react-native";
|
import {LucideIcon, LucideProps} from "lucide-react-native";
|
||||||
|
|
||||||
export function AMUIStatusAlert(props: { title: string, color: string, icon: LucideIcon, children?: ReactNode }) {
|
export function AMUIStatusAlert(props: { title: string, color: string, icon: LucideIcon, children?: ReactNode }) {
|
||||||
@@ -9,7 +9,7 @@ export function AMUIStatusAlert(props: { title: string, color: string, icon: Luc
|
|||||||
<Card.Content>
|
<Card.Content>
|
||||||
<View style={{alignItems: "center", flexDirection: "row", gap: 10}}>
|
<View style={{alignItems: "center", flexDirection: "row", gap: 10}}>
|
||||||
{renderIconWithProps(props.icon, {size: 22, color: props.color})}
|
{renderIconWithProps(props.icon, {size: 22, color: props.color})}
|
||||||
<Title style={{color: props.color}}>{props.title}</Title>
|
<Text variant="titleLarge" style={{color: props.color}}>{props.title}</Text>
|
||||||
</View>
|
</View>
|
||||||
{props.children}
|
{props.children}
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
import {ReactNode, useMemo} from "react";
|
|
||||||
import {Text} from "react-native";
|
|
||||||
import {CreditCard, History} from "lucide-react-native";
|
|
||||||
|
|
||||||
import TimeAgo from "./TimeAgo";
|
|
||||||
import {AMCardData} from "../models/AMCard";
|
|
||||||
import {AMUICard, AMUICardActionProps, AMUICardDetail} from "./AMUICard";
|
|
||||||
|
|
||||||
export interface AimeCardProps extends AMUICardActionProps {
|
|
||||||
card: AMCardData,
|
|
||||||
children?: ReactNode,
|
|
||||||
showFullCardNumber?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AimeCard(props: AimeCardProps) {
|
|
||||||
const displayedCardNumber = useMemo(() => props.showFullCardNumber
|
|
||||||
? props.card.code.match(/.{1,4}/g)?.join(" ")
|
|
||||||
: `•••• ${props.card.code.substring(props.card.code.length - 4)}`,
|
|
||||||
[props.showFullCardNumber, props.card.code]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AMUICard title={props.card.name} {...props}>
|
|
||||||
<AMUICardDetail>
|
|
||||||
<CreditCard size={18} color="gray"/>
|
|
||||||
<Text style={{color: "gray", fontFamily: "JetBrainsMono_400Regular"}}>{displayedCardNumber}</Text>
|
|
||||||
</AMUICardDetail>
|
|
||||||
{props.card.lastUsed && (
|
|
||||||
<AMUICardDetail>
|
|
||||||
<History size={18} color="gray"/>
|
|
||||||
<TimeAgo style={{color: "gray"}} dateTo={props.card.lastUsed}/>
|
|
||||||
</AMUICardDetail>
|
|
||||||
)}
|
|
||||||
{props.children}
|
|
||||||
</AMUICard>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
import {View} from "react-native";
|
import {View} from "react-native";
|
||||||
import {Paragraph} from "react-native-paper";
|
import {Text} from "react-native-paper";
|
||||||
|
|
||||||
export function EmptyStatePlaceholder(props: { message: string, icon: React.ReactNode }) {
|
export function EmptyStatePlaceholder(props: { message: string, icon: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<View style={{display: "flex", gap: 5, alignItems: "center"}}>
|
<View style={{display: "flex", gap: 5, alignItems: "center"}}>
|
||||||
{props.icon}
|
{props.icon}
|
||||||
<Paragraph style={{color: "gray", fontSize: 14}}>
|
<Text variant="bodyMedium" style={{color: "gray", fontSize: 14}}>
|
||||||
{props.message}
|
{props.message}
|
||||||
</Paragraph>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import {Text} from "react-native";
|
||||||
|
import React, {ReactNode, useMemo} from "react";
|
||||||
|
import {CreditCard, Gem, History, Joystick, LucideIcon, RailSymbol} from "lucide-react-native";
|
||||||
|
|
||||||
|
import TimeAgo from "./TimeAgo";
|
||||||
|
import {AMUICard, AMUICardActionProps, AMUICardDetail} from "./AMUICard";
|
||||||
|
|
||||||
|
import {AmusementICVendor, ICType} from "../lib/iccard";
|
||||||
|
import {AMCardData} from "../models/AMCardData";
|
||||||
|
|
||||||
|
export interface ICCardProps extends AMUICardActionProps {
|
||||||
|
card: AMCardData,
|
||||||
|
children?: ReactNode,
|
||||||
|
showFullCardNumber?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ICCard(props: ICCardProps) {
|
||||||
|
const displayedCardNumber = useMemo(() => props.showFullCardNumber
|
||||||
|
? props.card.code.match(/.{1,4}/g)?.join(" ")
|
||||||
|
: `•••• ${props.card.code.substring(props.card.code.length - 4)}`,
|
||||||
|
[props.showFullCardNumber, props.card.code]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AMUICard title={props.card.name} {...props}>
|
||||||
|
<PhysicalICInfo card={props.card}/>
|
||||||
|
|
||||||
|
<AMUICardDetail>
|
||||||
|
<CreditCard size={18} color="gray"/>
|
||||||
|
<Text style={{color: "gray", fontFamily: "JetBrainsMono_400Regular"}}>{displayedCardNumber}</Text>
|
||||||
|
</AMUICardDetail>
|
||||||
|
|
||||||
|
{props.card.lastUsed && (
|
||||||
|
<AMUICardDetail>
|
||||||
|
<History size={18} color="gray"/>
|
||||||
|
<TimeAgo style={{color: "gray"}} dateTo={props.card.lastUsed}/>
|
||||||
|
</AMUICardDetail>
|
||||||
|
)}
|
||||||
|
{props.children}
|
||||||
|
</AMUICard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PhysicalICInfo(props: { card: AMCardData | null }) {
|
||||||
|
if (props.card.physicalCardData?.cardType === ICType.Transport) {
|
||||||
|
return (
|
||||||
|
<AMUICardDetail>
|
||||||
|
<RailSymbol size={18} color="gray"/>
|
||||||
|
<Text style={{color: "gray"}}>Transport IC Card</Text>
|
||||||
|
</AMUICardDetail>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.card.physicalCardData?.cardVendor) {
|
||||||
|
const [icon, color]: [LucideIcon, string] = props.card.isLimitedAimeCard ? [Gem, "crimson"] : [Joystick, "gray"];
|
||||||
|
return (
|
||||||
|
<AMUICardDetail>
|
||||||
|
{React.createElement(icon, {size: 18, color})}
|
||||||
|
<Text style={{color}}>
|
||||||
|
{props.card.physicalCardData.isBootlegAmusementCard ? "Bootleg " : null}{ICVendorName(props.card.physicalCardData.cardVendor)}
|
||||||
|
</Text>
|
||||||
|
</AMUICardDetail>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ICVendorName(vendor: AmusementICVendor): string {
|
||||||
|
switch (vendor) {
|
||||||
|
case AmusementICVendor.Sega:
|
||||||
|
return "Aime Card";
|
||||||
|
|
||||||
|
case AmusementICVendor.Konami:
|
||||||
|
return "e-Amusement Pass";
|
||||||
|
|
||||||
|
case AmusementICVendor.BandaiNamcoGames:
|
||||||
|
case AmusementICVendor.BandaiNamcoEntertainment:
|
||||||
|
return "Banapass";
|
||||||
|
|
||||||
|
case AmusementICVendor.Nesica:
|
||||||
|
return "NESiCA Card";
|
||||||
|
|
||||||
|
default:
|
||||||
|
return "Amusement IC Card";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
|
import {Text} from "react-native-paper";
|
||||||
import React, {useEffect, useState} from 'react';
|
import React, {useEffect, useState} from 'react';
|
||||||
import {StyleProp, TextStyle} from 'react-native';
|
import {StyleProp, TextStyle} from 'react-native';
|
||||||
import {formatDistance} from 'date-fns/formatDistance';
|
import {formatDistance} from 'date-fns/formatDistance';
|
||||||
import {Paragraph} from "react-native-paper";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
dateTo: Date;
|
dateTo: Date;
|
||||||
@@ -28,11 +28,11 @@ const TimeAgo: React.FC<Props> = ({
|
|||||||
}, [dateFrom, updateInterval]);
|
}, [dateFrom, updateInterval]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paragraph {...{style}}>
|
<Text variant="bodyMedium" {...{style}}>
|
||||||
{formatDistance(dateTo, dateFrom || currentDate, {
|
{formatDistance(dateTo, dateFrom || currentDate, {
|
||||||
addSuffix: !hideAgo,
|
addSuffix: !hideAgo,
|
||||||
})}
|
})}
|
||||||
</Paragraph>
|
</Text>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
export enum ICType {
|
||||||
|
Amusement = 1,
|
||||||
|
Transport = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum AmusementICVendor {
|
||||||
|
Sega = 0x0078,
|
||||||
|
Konami = 0x0068,
|
||||||
|
BandaiNamcoEntertainment = 0x002A,
|
||||||
|
BandaiNamcoGames = 0x003A,
|
||||||
|
Nesica = 0x0079,
|
||||||
|
Unknown = 0x0000
|
||||||
|
}
|
||||||
|
|
||||||
|
type AmusementICCard = {
|
||||||
|
type: ICType.Amusement,
|
||||||
|
|
||||||
|
idm: string,
|
||||||
|
accessCode: string,
|
||||||
|
vendor: AmusementICVendor
|
||||||
|
}
|
||||||
|
|
||||||
|
type TransportICCard = {
|
||||||
|
type: ICType.Transport,
|
||||||
|
|
||||||
|
idm: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ICCard = AmusementICCard | TransportICCard;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts an IC card IDm to the 20-digit "0008" number used by some networks
|
||||||
|
* @param idm The IDm to convert
|
||||||
|
*/
|
||||||
|
export function icCard0008Number(idm: string): string {
|
||||||
|
// 0008 is the numerical representation of the idm padded with zeros until it is 20 digits
|
||||||
|
return BigInt(`0x${idm}`).toString().padStart(20, '0');
|
||||||
|
}
|
||||||
@@ -6,7 +6,8 @@ module.exports = (async () => {
|
|||||||
const config = await getDefaultConfig(__dirname);
|
const config = await getDefaultConfig(__dirname);
|
||||||
|
|
||||||
config.resolver.extraNodeModules = {
|
config.resolver.extraNodeModules = {
|
||||||
'amnet-shared': resolve(__dirname, '../amnet-shared')
|
'amnet-shared': resolve(__dirname, '../amnet-shared'),
|
||||||
|
'@babel/runtime': resolve(__dirname, 'node_modules/@babel/runtime')
|
||||||
};
|
};
|
||||||
|
|
||||||
config.watchFolders = [
|
config.watchFolders = [
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import {Realm} from "realm";
|
import {Realm} from "realm";
|
||||||
|
|
||||||
export interface AMCardData {
|
import {ICType} from "../lib/iccard";
|
||||||
code: string;
|
|
||||||
name: string;
|
|
||||||
|
|
||||||
added: Date;
|
import {AMCardData} from "./AMCardData";
|
||||||
lastUsed?: Date | null;
|
import {AMCardRecentUse} from "./AMCardRecentUse";
|
||||||
}
|
import {AMPhysicalCardData} from "./AMPhysicalCardData";
|
||||||
|
|
||||||
export class AMCard extends Realm.Object<AMCard> implements AMCardData {
|
export class AMCard extends Realm.Object<AMCard> implements AMCardData {
|
||||||
_id!: Realm.BSON.ObjectId;
|
_id!: Realm.BSON.ObjectId;
|
||||||
@@ -15,8 +13,15 @@ export class AMCard extends Realm.Object<AMCard> implements AMCardData {
|
|||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
added: Date;
|
added: Date;
|
||||||
lastUsed?: Date;
|
lastUsed: Date;
|
||||||
lastUsedGameId?: string | null;
|
|
||||||
|
recentUses: Realm.Results<AMCardRecentUse>;
|
||||||
|
physicalCardData?: AMPhysicalCardData | null = null;
|
||||||
|
|
||||||
|
get isLimitedAimeCard() {
|
||||||
|
// limited cards use the company code 500
|
||||||
|
return this.physicalCardData?.cardType === ICType.Amusement && this.code.startsWith('500');
|
||||||
|
}
|
||||||
|
|
||||||
static generate(code: string, name: string) {
|
static generate(code: string, name: string) {
|
||||||
return {
|
return {
|
||||||
@@ -27,6 +32,18 @@ export class AMCard extends Realm.Object<AMCard> implements AMCardData {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static delete(realm: Realm, card: AMCard) {
|
||||||
|
realm.write(() => {
|
||||||
|
if (card.physicalCardData) {
|
||||||
|
realm.delete(card.physicalCardData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove any usage history tied to the card
|
||||||
|
realm.delete(card.recentUses);
|
||||||
|
realm.delete(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
static schema: Realm.ObjectSchema = {
|
static schema: Realm.ObjectSchema = {
|
||||||
name: "card",
|
name: "card",
|
||||||
primaryKey: "_id",
|
primaryKey: "_id",
|
||||||
@@ -34,10 +51,12 @@ export class AMCard extends Realm.Object<AMCard> implements AMCardData {
|
|||||||
properties: {
|
properties: {
|
||||||
_id: "objectId",
|
_id: "objectId",
|
||||||
code: "string",
|
code: "string",
|
||||||
name: {type: "string", default: "New Card"},
|
name: {type: "string", default: "New Card", indexed: true},
|
||||||
added: {type: "date", default: new Date()},
|
added: {type: "date", default: new Date(), indexed: true},
|
||||||
lastUsed: {type: "date", optional: true},
|
lastUsed: {type: "date", optional: true, indexed: true},
|
||||||
lastUsedGameId: {type: "string", optional: true}
|
recentUses: {type: "linkingObjects", objectType: AMCardRecentUse.schema.name, property: "card"},
|
||||||
|
physicalCardData: {type: "object", objectType: AMPhysicalCardData.schema.name, optional: true}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import {AMPhysicalCardData} from "./AMPhysicalCardData";
|
||||||
|
|
||||||
|
export interface AMCardData {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
added: Date;
|
||||||
|
lastUsed?: Date | null;
|
||||||
|
isLimitedAimeCard: boolean;
|
||||||
|
physicalCardData?: AMPhysicalCardData | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import {Realm} from "realm";
|
||||||
|
import {AMCard} from "./AMCard";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a usage of a card for determining which card was last used on a specific game.
|
||||||
|
*/
|
||||||
|
export class AMCardRecentUse extends Realm.Object<AMCardRecentUse> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The game id that is being tracked
|
||||||
|
*/
|
||||||
|
gameId: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The card that was last used on the game.
|
||||||
|
* If the card is removed, this record will also be removed.
|
||||||
|
*/
|
||||||
|
card: AMCard;
|
||||||
|
|
||||||
|
static generate(gameId: string, card: AMCard) {
|
||||||
|
return {
|
||||||
|
gameId: gameId,
|
||||||
|
card: card
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static schema: Realm.ObjectSchema = {
|
||||||
|
name: "cardUsageHistory",
|
||||||
|
primaryKey: "gameId",
|
||||||
|
|
||||||
|
properties: {
|
||||||
|
gameId: "string",
|
||||||
|
card: {type: "object", objectType: "card"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import {Realm} from "realm";
|
||||||
|
import {AmusementICVendor, ICType} from "../lib/iccard";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds physical card information when a card is imported via NFC/FeliCa
|
||||||
|
*/
|
||||||
|
export class AMPhysicalCardData extends Realm.Object<AMPhysicalCardData> {
|
||||||
|
|
||||||
|
cardIDm: string;
|
||||||
|
cardType: ICType;
|
||||||
|
cardVendor: AmusementICVendor | null;
|
||||||
|
|
||||||
|
get isBootlegAmusementCard() {
|
||||||
|
// real cards have an IDm manufacturer code of 012E (first two bytes of the IDm or in this case, the first 4 characters)
|
||||||
|
return this.cardType === ICType.Amusement && !this.cardIDm.startsWith("012E");
|
||||||
|
}
|
||||||
|
|
||||||
|
static schema: Realm.ObjectSchema = {
|
||||||
|
name: "physicalCard",
|
||||||
|
primaryKey: "cardIDm",
|
||||||
|
|
||||||
|
properties: {
|
||||||
|
cardIDm: "string",
|
||||||
|
cardType: "int",
|
||||||
|
cardVendor: {type: "int", optional: true}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ export class AMServer extends Realm.Object<AMServer> implements AMServerConnecti
|
|||||||
lastUsed: Date;
|
lastUsed: Date;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
|
preferredCardIdFormat?: "idm" | "access" | null;
|
||||||
|
|
||||||
get shareUrl() {
|
get shareUrl() {
|
||||||
return `http://card.ppc.moe/#/servers/add?address=${encodeURIComponent(this.serverAddress)}`;
|
return `http://card.ppc.moe/#/servers/add?address=${encodeURIComponent(this.serverAddress)}`;
|
||||||
}
|
}
|
||||||
@@ -40,9 +42,9 @@ export class AMServer extends Realm.Object<AMServer> implements AMServerConnecti
|
|||||||
apiVersion: {type: "int", default: 0},
|
apiVersion: {type: "int", default: 0},
|
||||||
gameId: {type: "string", optional: true},
|
gameId: {type: "string", optional: true},
|
||||||
serverAddress: "string",
|
serverAddress: "string",
|
||||||
serverName: {type: "string", default: "New Server"},
|
serverName: {type: "string", default: "New Server", indexed: "full-text"},
|
||||||
createdAt: {type: "date", default: new Date()},
|
createdAt: {type: "date", default: new Date()},
|
||||||
lastUsed: {type: "date", optional: true}
|
lastUsed: {type: "date", optional: true, indexed: true}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,42 @@
|
|||||||
|
import {Realm} from "realm";
|
||||||
import {createRealmContext} from "@realm/react";
|
import {createRealmContext} from "@realm/react";
|
||||||
|
|
||||||
import {AMCard} from "./AMCard";
|
import {AMCard} from "./AMCard";
|
||||||
import {AMServer} from "./AMServer";
|
import {AMServer} from "./AMServer";
|
||||||
|
import {AMCardRecentUse} from "./AMCardRecentUse";
|
||||||
|
import {AMPhysicalCardData} from "./AMPhysicalCardData";
|
||||||
|
|
||||||
|
const migrationFunction = (oldRealm: Realm, newRealm: Realm) => {
|
||||||
|
if (oldRealm.schemaVersion < 5) {
|
||||||
|
const oldCards = oldRealm.objects("card");
|
||||||
|
const gameIdMap: Map<string, { card: any, lastUsed: Date }> = new Map();
|
||||||
|
|
||||||
|
// get last used card for each game
|
||||||
|
oldCards.forEach((card: any) => {
|
||||||
|
if (card.lastUsedGameId && card.lastUsed) {
|
||||||
|
const existing = gameIdMap.get(card.lastUsedGameId);
|
||||||
|
if (!existing || card.lastUsed > existing.lastUsed) {
|
||||||
|
gameIdMap.set(card.lastUsedGameId, {card, lastUsed: card.lastUsed});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// write new history records
|
||||||
|
const newCards = newRealm.objects("card");
|
||||||
|
gameIdMap.forEach((value, gameId) => {
|
||||||
|
const newCard = newCards.filtered(`_id == $0`, value.card._id)[0];
|
||||||
|
newRealm.create("cardUsageHistory", {
|
||||||
|
gameId: gameId,
|
||||||
|
card: newCard
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
export default createRealmContext({
|
export default createRealmContext({
|
||||||
schemaVersion: 3,
|
schemaVersion: 5,
|
||||||
schema: [AMCard, AMServer],
|
schema: [AMPhysicalCardData, AMCardRecentUse, AMCard, AMServer],
|
||||||
deleteRealmIfMigrationNeeded: false
|
onMigration: migrationFunction,
|
||||||
|
deleteRealmIfMigrationNeeded: false,
|
||||||
});
|
});
|
||||||
Generated
+3641
-7336
File diff suppressed because it is too large
Load Diff
+36
-33
@@ -1,45 +1,48 @@
|
|||||||
{
|
{
|
||||||
"name": "amnet-native",
|
"name": "amnet-native",
|
||||||
"version": "1.1.0",
|
"version": "1.3.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"android": "expo run:android",
|
"android": "expo run:android",
|
||||||
"ios": "expo run:ios",
|
"ios": "expo run:ios",
|
||||||
"ios-prebuild": "expo prebuild --platform ios",
|
"ios-prebuild": "expo prebuild --platform ios",
|
||||||
"postinstall": "patch-package"
|
"android-prebuild": "expo prebuild --platform android"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo-google-fonts/jetbrains-mono": "^0.2.3",
|
"@babel/runtime": "^7.28.4",
|
||||||
"@react-navigation/bottom-tabs": "^6.6.1",
|
"@expo-google-fonts/jetbrains-mono": "^0.4.1",
|
||||||
"@react-navigation/native": "^6.1.18",
|
"@react-native-menu/menu": "^2.0.0",
|
||||||
"@react-navigation/native-stack": "^6.11.0",
|
"@react-navigation/bottom-tabs": "^7.9.0",
|
||||||
"@realm/react": "^0.9.0",
|
"@react-navigation/native": "^7.1.26",
|
||||||
"@types/react": "~18.2.79",
|
"@react-navigation/native-stack": "^7.9.0",
|
||||||
|
"@realm/react": "^0.20.0",
|
||||||
"amnet-shared": "file:../amnet-shared",
|
"amnet-shared": "file:../amnet-shared",
|
||||||
"date-fns": "^3.6.0",
|
"babel-preset-expo": "^54.0.9",
|
||||||
"expo": "^51.0.38",
|
"date-fns": "^4.1.0",
|
||||||
"expo-blur": "~13.0.2",
|
"expo": "^54.0.30",
|
||||||
"expo-build-properties": "~0.12.5",
|
"expo-build-properties": "~1.0.10",
|
||||||
"expo-clipboard": "~6.0.3",
|
"expo-clipboard": "~8.0.8",
|
||||||
"expo-constants": "~16.0.2",
|
"expo-dev-client": "~6.0.20",
|
||||||
"expo-dev-client": "~4.0.29",
|
"expo-linking": "~8.0.11",
|
||||||
"expo-linking": "~6.3.1",
|
"expo-splash-screen": "~31.0.13",
|
||||||
"expo-status-bar": "~1.12.1",
|
"expo-status-bar": "~3.0.9",
|
||||||
"expo-system-ui": "~3.0.7",
|
"lucide-react-native": "^0.562.0",
|
||||||
"lucide-react-native": "^0.427.0",
|
"pusher-js": "^8.4.0",
|
||||||
"patch-package": "^8.0.0",
|
"react": "19.1.0",
|
||||||
"react": "18.2.0",
|
"react-native": "0.81.5",
|
||||||
"react-native": "0.74.5",
|
"react-native-context-menu-view": "^1.21.0",
|
||||||
"react-native-context-menu-view": "^1.16.0",
|
"react-native-gesture-handler": "^2.30.0",
|
||||||
"react-native-gesture-handler": "~2.16.1",
|
"react-native-get-random-values": "^2.0.0",
|
||||||
"react-native-get-random-values": "^1.11.0",
|
"react-native-paper": "^5.14.5",
|
||||||
"react-native-paper": "^5.12.5",
|
"react-native-reanimated": "~4.2.1",
|
||||||
"react-native-reanimated": "~3.10.1",
|
"react-native-safe-area-context": "5.6.2",
|
||||||
"react-native-safe-area-context": "4.10.5",
|
"react-native-screens": "~4.19.0",
|
||||||
"react-native-screens": "3.31.1",
|
"react-native-svg": "15.15.1",
|
||||||
"react-native-svg": "15.2.0",
|
"react-native-url-polyfill": "^3.0.0",
|
||||||
"react-native-url-polyfill": "^2.0.0",
|
"realm": "^20.1.0"
|
||||||
"realm": "^12.12.1",
|
},
|
||||||
"typescript": "~5.3.3"
|
"devDependencies": {
|
||||||
|
"@types/react": "~19.1.10",
|
||||||
|
"typescript": "~5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
diff --git a/node_modules/expo-modules-core/ios/AppDelegates/ExpoAppDelegate.swift b/node_modules/expo-modules-core/ios/AppDelegates/ExpoAppDelegate.swift
|
|
||||||
index 8ba09a7..8d7d2b0 100644
|
|
||||||
--- a/node_modules/expo-modules-core/ios/AppDelegates/ExpoAppDelegate.swift
|
|
||||||
+++ b/node_modules/expo-modules-core/ios/AppDelegates/ExpoAppDelegate.swift
|
|
||||||
@@ -101,59 +101,6 @@ open class ExpoAppDelegate: UIResponder, UIApplicationDelegate {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
- // MARK: - Handling Remote Notification Registration
|
|
||||||
-
|
|
||||||
- open func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
|
||||||
- subscribers.forEach { $0.application?(application, didRegisterForRemoteNotificationsWithDeviceToken: deviceToken) }
|
|
||||||
- }
|
|
||||||
-
|
|
||||||
- open func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
|
|
||||||
- subscribers.forEach { $0.application?(application, didFailToRegisterForRemoteNotificationsWithError: error) }
|
|
||||||
- }
|
|
||||||
-
|
|
||||||
- open func application(
|
|
||||||
- _ application: UIApplication,
|
|
||||||
- didReceiveRemoteNotification userInfo: [AnyHashable: Any],
|
|
||||||
- fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
|
|
||||||
- ) {
|
|
||||||
- let selector = #selector(application(_:didReceiveRemoteNotification:fetchCompletionHandler:))
|
|
||||||
- let subs = subscribers.filter { $0.responds(to: selector) }
|
|
||||||
- var subscribersLeft = subs.count
|
|
||||||
- let dispatchQueue = DispatchQueue(label: "expo.application.remoteNotification", qos: .userInteractive)
|
|
||||||
- var failedCount = 0
|
|
||||||
- var newDataCount = 0
|
|
||||||
-
|
|
||||||
- let handler = { (result: UIBackgroundFetchResult) in
|
|
||||||
- dispatchQueue.sync {
|
|
||||||
- if result == .failed {
|
|
||||||
- failedCount += 1
|
|
||||||
- } else if result == .newData {
|
|
||||||
- newDataCount += 1
|
|
||||||
- }
|
|
||||||
-
|
|
||||||
- subscribersLeft -= 1
|
|
||||||
-
|
|
||||||
- if subscribersLeft == 0 {
|
|
||||||
- if newDataCount > 0 {
|
|
||||||
- completionHandler(.newData)
|
|
||||||
- } else if failedCount > 0 {
|
|
||||||
- completionHandler(.failed)
|
|
||||||
- } else {
|
|
||||||
- completionHandler(.noData)
|
|
||||||
- }
|
|
||||||
- }
|
|
||||||
- }
|
|
||||||
- }
|
|
||||||
-
|
|
||||||
- if subs.isEmpty {
|
|
||||||
- completionHandler(.noData)
|
|
||||||
- } else {
|
|
||||||
- subs.forEach { subscriber in
|
|
||||||
- subscriber.application?(application, didReceiveRemoteNotification: userInfo, fetchCompletionHandler: handler)
|
|
||||||
- }
|
|
||||||
- }
|
|
||||||
- }
|
|
||||||
-
|
|
||||||
// MARK: - Continuing User Activity and Handling Quick Actions
|
|
||||||
|
|
||||||
open func application(_ application: UIApplication, willContinueUserActivityWithType userActivityType: String) -> Bool {
|
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {},
|
"compilerOptions": {
|
||||||
|
"strictNullChecks": false,
|
||||||
|
"moduleResolution": "bundler"
|
||||||
|
},
|
||||||
"extends": "expo/tsconfig.base"
|
"extends": "expo/tsconfig.base"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,14 @@ const modalStyles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
formItemDivider: {
|
formItemDivider: {
|
||||||
marginVertical: 15
|
marginVertical: 15
|
||||||
}
|
},
|
||||||
|
rootLayout: {
|
||||||
|
display: "flex",
|
||||||
|
gap: 10,
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingHorizontal: 15,
|
||||||
|
paddingTop: 25
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export function useModalStyles() {
|
export function useModalStyles() {
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[package]
|
||||||
|
name = "amnet-server-test"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
version.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
description = "AMNet Server Test Application"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "amnet-server-test"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
amnet = { path = "../amnet-server" }
|
||||||
|
axum = "0.8.8"
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = "0.3"
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use amnet::{build_server, CardSlot, ServerConfig, Metrics, format_hex_bytes};
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_max_level(tracing::Level::DEBUG)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let config = ServerConfig::instance();
|
||||||
|
|
||||||
|
info!("AMNet Server (Test Application)");
|
||||||
|
|
||||||
|
info!("Configuration:");
|
||||||
|
info!(" Game ID: {}", config.game_id.as_deref().unwrap_or("(not set)"));
|
||||||
|
info!(" Server Name: {}", config.server_name);
|
||||||
|
info!(" Listen Address: {}", config.server_listen_address);
|
||||||
|
info!(" Physical Card Use IDm: {}", config.physical_card_use_idm);
|
||||||
|
info!("");
|
||||||
|
|
||||||
|
// Start the webserver in the background
|
||||||
|
let app = build_server();
|
||||||
|
let listen_addr = config.server_listen_address.as_str();
|
||||||
|
|
||||||
|
let listener = match TcpListener::bind(listen_addr).await {
|
||||||
|
Ok(l) => l,
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to bind to {}: {}", listen_addr, e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Metrics::instance().set_start_time();
|
||||||
|
|
||||||
|
info!("Server started on {}", listen_addr);
|
||||||
|
info!("Submit a card via the web API and it will be detected here.");
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = axum::serve(listener, app).await {
|
||||||
|
warn!("Server error: {}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if let Err(_) = Metrics::instance().set_last_poll() {
|
||||||
|
warn!("Failed to update poll metrics");
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(card) = CardSlot::instance().request_card(None) {
|
||||||
|
info!("=== CARD DETECTED ===");
|
||||||
|
info!(" Access Code: {}", format_hex_bytes(&card.access_code));
|
||||||
|
|
||||||
|
if let Some(idm) = card.card_idm {
|
||||||
|
info!(" Card IDm: {}", format_hex_bytes(&idm));
|
||||||
|
} else {
|
||||||
|
info!(" Card IDm: (not provided)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
[package]
|
||||||
|
name = "amnet"
|
||||||
|
edition = "2024"
|
||||||
|
build = "build.rs"
|
||||||
|
|
||||||
|
version.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
description.workspace = true
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "amnet"
|
||||||
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
lto = true
|
||||||
|
strip = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
axum = "0.8.8"
|
||||||
|
hostname = "0.4"
|
||||||
|
tracing = "0.1"
|
||||||
|
rust-ini = "0.21"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread", "net"] }
|
||||||
|
tower = "0.5.2"
|
||||||
|
tower-http = { version = "0.6.8", features = ["cors", "trace"] }
|
||||||
|
windows-sys = { version = "0.61.2", features = ["Win32_System_Console", "Win32_UI_Input_KeyboardAndMouse"] }
|
||||||
|
tracing-subscriber = "0.3.22"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
winresource = "0.1"
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
fn main() {
|
||||||
|
if std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default() == "windows" {
|
||||||
|
set_windows_resource_version();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_windows_resource_version() {
|
||||||
|
let version_str = env!("CARGO_PKG_VERSION");
|
||||||
|
let version_parts: Vec<&str> = version_str.split('.').collect();
|
||||||
|
let mut normalized_parts = vec!["0", "0", "0", "0"];
|
||||||
|
|
||||||
|
for (i, part) in version_parts.iter().enumerate().take(4) {
|
||||||
|
normalized_parts[i] = part;
|
||||||
|
}
|
||||||
|
|
||||||
|
let version_display = normalized_parts.join(".");
|
||||||
|
|
||||||
|
let mut res = winresource::WindowsResource::new();
|
||||||
|
|
||||||
|
res.set("FileVersion", &version_display);
|
||||||
|
res.set("ProductVersion", "2.0.0.0");
|
||||||
|
res.set("ProductName", "AMNet");
|
||||||
|
res.set("FileDescription", "AMNet Server");
|
||||||
|
res.set("CompanyName", "ppc");
|
||||||
|
res.set("InternalName", "amnet-server");
|
||||||
|
res.set("OriginalFilename", "amnet.dll");
|
||||||
|
res.set("LegalCopyright", "Copyright 2026 (c) ppc");
|
||||||
|
|
||||||
|
if let Err(e) = res.compile() {
|
||||||
|
println!("cargo:warning=Failed to set Windows resource information: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
use std::{sync::{Mutex, OnceLock}, time::{Duration, Instant}};
|
||||||
|
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct StoredCard {
|
||||||
|
pub access_code: [u8; 10],
|
||||||
|
pub card_idm: Option<[u8; 8]>,
|
||||||
|
|
||||||
|
pub expiry_time: Instant
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StoredCard {
|
||||||
|
pub fn is_expired(&self) -> bool {
|
||||||
|
Instant::now() > self.expiry_time
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum CardPresentResult {
|
||||||
|
Accepted,
|
||||||
|
SlotUnavailable(Instant /* release time */),
|
||||||
|
}
|
||||||
|
|
||||||
|
static PRESENTER: OnceLock<CardPresenter> = OnceLock::new();
|
||||||
|
|
||||||
|
pub struct CardPresenter {
|
||||||
|
current_card: Mutex<Option<StoredCard>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CardPresenter {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
current_card: Mutex::new(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CardPresenter {
|
||||||
|
pub fn instance() -> &'static CardPresenter {
|
||||||
|
PRESENTER.get_or_init(|| CardPresenter::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn request_card(&self, require_idm: Option<bool>) -> Option<StoredCard> {
|
||||||
|
let mut guard = self.current_card.lock().unwrap();
|
||||||
|
let card = guard.as_ref()?;
|
||||||
|
|
||||||
|
if let Some(require) = require_idm && card.card_idm.is_some() != require {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let card = guard.take();
|
||||||
|
|
||||||
|
if let Some(ref stored_card) = card && stored_card.is_expired() {
|
||||||
|
debug!("Stored card has expired and will not be provided");
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
|
||||||
|
card
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn present_card(&self, access_code: [u8; 10], idm_hex: Option<[u8; 8]>, validity: Duration) -> CardPresentResult {
|
||||||
|
let mut guard = self.current_card.lock().unwrap();
|
||||||
|
|
||||||
|
if let Some(card) = guard.as_ref() && !card.is_expired() {
|
||||||
|
return CardPresentResult::SlotUnavailable(card.expiry_time);
|
||||||
|
}
|
||||||
|
|
||||||
|
let stored_card = StoredCard {
|
||||||
|
access_code,
|
||||||
|
card_idm: idm_hex,
|
||||||
|
expiry_time: Instant::now() + validity,
|
||||||
|
};
|
||||||
|
|
||||||
|
*guard = Some(stored_card);
|
||||||
|
|
||||||
|
CardPresentResult::Accepted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::utils::{parse_access_code, parse_idm_hex};
|
||||||
|
|
||||||
|
mod stored_card_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_expired_returns_false_for_future_expiry() {
|
||||||
|
let card = StoredCard {
|
||||||
|
access_code: [0; 10],
|
||||||
|
card_idm: None,
|
||||||
|
expiry_time: Instant::now() + Duration::from_secs(60),
|
||||||
|
};
|
||||||
|
assert!(!card.is_expired());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_expired_returns_true_for_past_expiry() {
|
||||||
|
let card = StoredCard {
|
||||||
|
access_code: [0; 10],
|
||||||
|
card_idm: None,
|
||||||
|
expiry_time: Instant::now() - Duration::from_millis(1),
|
||||||
|
};
|
||||||
|
assert!(card.is_expired());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mod card_presenter_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn create_presenter() -> CardPresenter {
|
||||||
|
CardPresenter::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_card_returns_none_when_empty() {
|
||||||
|
let presenter = create_presenter();
|
||||||
|
assert!(presenter.request_card(None).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn present_card_accepts_valid_access_code() {
|
||||||
|
let presenter = create_presenter();
|
||||||
|
let access_code = parse_access_code(&"12345678901234567890".to_string()).unwrap();
|
||||||
|
|
||||||
|
match presenter.present_card(access_code, None, Duration::from_secs(5)) {
|
||||||
|
CardPresentResult::Accepted => {}
|
||||||
|
_ => panic!("Expected Accepted"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn present_card_rejects_invalid_access_code() {
|
||||||
|
let access_code = parse_access_code(&"INVALID".to_string());
|
||||||
|
assert!(access_code.is_none(), "Expected parsing to fail for invalid access code");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn present_card_accepts_valid_idm() {
|
||||||
|
let presenter = create_presenter();
|
||||||
|
let access_code = parse_access_code(&"12345678901234567890".to_string()).unwrap();
|
||||||
|
let idm = parse_idm_hex(&"0102030405060708".to_string()).unwrap();
|
||||||
|
|
||||||
|
match presenter.present_card(access_code, Some(idm), Duration::from_secs(5)) {
|
||||||
|
CardPresentResult::Accepted => {}
|
||||||
|
_ => panic!("Expected Accepted"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn present_card_rejects_invalid_idm() {
|
||||||
|
let idm = parse_idm_hex(&"INVALID_IDM".to_string());
|
||||||
|
assert!(idm.is_none(), "Expected parsing to fail for invalid IDm");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn present_card_returns_slot_unavailable_when_card_present() {
|
||||||
|
let presenter = create_presenter();
|
||||||
|
let access_code = parse_access_code(&"12345678901234567890".to_string()).unwrap();
|
||||||
|
|
||||||
|
// Present first card
|
||||||
|
presenter.present_card(access_code, None, Duration::from_secs(5));
|
||||||
|
|
||||||
|
// Try to present second card
|
||||||
|
match presenter.present_card(access_code, None, Duration::from_secs(5)) {
|
||||||
|
CardPresentResult::SlotUnavailable(_) => {}
|
||||||
|
_ => panic!("Expected SlotUnavailable"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_card_returns_card_after_present() {
|
||||||
|
let presenter = create_presenter();
|
||||||
|
let access_code = parse_access_code(&"00101234567890123456".to_string()).unwrap();
|
||||||
|
|
||||||
|
presenter.present_card(access_code, None, Duration::from_secs(5));
|
||||||
|
|
||||||
|
let card = presenter.request_card(None);
|
||||||
|
assert!(card.is_some());
|
||||||
|
|
||||||
|
let card = card.unwrap();
|
||||||
|
assert_eq!(card.access_code, [0x00, 0x10, 0x12, 0x34, 0x56, 0x78, 0x90, 0x12, 0x34, 0x56]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_card_consumes_the_card() {
|
||||||
|
let presenter = create_presenter();
|
||||||
|
let access_code = parse_access_code(&"12345678901234567890".to_string()).unwrap();
|
||||||
|
|
||||||
|
presenter.present_card(access_code, None, Duration::from_secs(5));
|
||||||
|
|
||||||
|
// First request should succeed
|
||||||
|
assert!(presenter.request_card(None).is_some());
|
||||||
|
|
||||||
|
// Second request should return None
|
||||||
|
assert!(presenter.request_card(None).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_card_filters_by_idm_requirement_true() {
|
||||||
|
let presenter = create_presenter();
|
||||||
|
let access_code = parse_access_code(&"12345678901234567890".to_string()).unwrap();
|
||||||
|
|
||||||
|
// Present card without IDm
|
||||||
|
presenter.present_card(access_code, None, Duration::from_secs(5));
|
||||||
|
|
||||||
|
// Request card that requires IDm should return None
|
||||||
|
assert!(presenter.request_card(Some(true)).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_card_filters_by_idm_requirement_false() {
|
||||||
|
let presenter = create_presenter();
|
||||||
|
let access_code = parse_access_code(&"12345678901234567890".to_string()).unwrap();
|
||||||
|
let idm = parse_idm_hex(&"0102030405060708".to_string()).unwrap();
|
||||||
|
|
||||||
|
// Present card with IDm
|
||||||
|
presenter.present_card(access_code, Some(idm), Duration::from_secs(5));
|
||||||
|
|
||||||
|
// Request card that requires no IDm should return None
|
||||||
|
assert!(presenter.request_card(Some(false)).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_card_returns_card_with_idm_when_required() {
|
||||||
|
let presenter = create_presenter();
|
||||||
|
let access_code = parse_access_code(&"12345678901234567890".to_string()).unwrap();
|
||||||
|
let idm = parse_idm_hex(&"0102030405060708".to_string()).unwrap();
|
||||||
|
|
||||||
|
presenter.present_card(access_code, Some(idm), Duration::from_secs(5));
|
||||||
|
|
||||||
|
let card = presenter.request_card(Some(true));
|
||||||
|
assert!(card.is_some());
|
||||||
|
assert!(card.unwrap().card_idm.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expired_card_cannot_be_requested() {
|
||||||
|
let presenter = create_presenter();
|
||||||
|
let access_code = parse_access_code(&"12345678901234567890".to_string()).unwrap();
|
||||||
|
|
||||||
|
// Present card with very short validity
|
||||||
|
presenter.present_card(access_code, None, Duration::from_millis(1));
|
||||||
|
|
||||||
|
// Wait for expiry
|
||||||
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
|
|
||||||
|
// Card should not be available
|
||||||
|
assert!(presenter.request_card(None).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn can_present_card_after_previous_expires() {
|
||||||
|
let presenter = create_presenter();
|
||||||
|
let access_code = parse_access_code(&"12345678901234567890".to_string()).unwrap();
|
||||||
|
|
||||||
|
// Present first card with very short validity
|
||||||
|
presenter.present_card(access_code, None, Duration::from_millis(1));
|
||||||
|
|
||||||
|
// Wait for expiry
|
||||||
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
|
|
||||||
|
// Should be able to present another card
|
||||||
|
match presenter.present_card(access_code, None, Duration::from_secs(5)) {
|
||||||
|
CardPresentResult::Accepted => {}
|
||||||
|
_ => panic!("Expected Accepted after previous card expired"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
use std::env;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
use ini::Ini;
|
||||||
|
use tracing::{error, warn};
|
||||||
|
|
||||||
|
mod defaults {
|
||||||
|
pub const CONFIG_FILE: &str = "./segatools.ini";
|
||||||
|
pub const CONFIG_FILE_ENV: &str = "SEGATOOLS_CONFIG_PATH";
|
||||||
|
|
||||||
|
pub const SERVER_NAME: &str = "AMNET-SERVER";
|
||||||
|
pub const SERVER_ADDRESS: &str = "0.0.0.0:6070";
|
||||||
|
|
||||||
|
pub const AIME_FILE: &str = r"DEVICE\aime.txt";
|
||||||
|
pub const AIME_KEYBOARD_ENABLED: bool = true;
|
||||||
|
pub const INCLUDE_IDM_FOR_PHYSICAL_CARDS: bool = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
mod limits {
|
||||||
|
pub const GAME_ID_LEN: usize = 4;
|
||||||
|
pub const SERVER_NAME_MAX_LEN: usize = 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
static CONFIG: OnceLock<Config> = OnceLock::new();
|
||||||
|
|
||||||
|
pub struct Config {
|
||||||
|
/// Game ID (4 characters or none)
|
||||||
|
pub game_id: Option<String>,
|
||||||
|
|
||||||
|
/// Server name shown on devices (max 16 chars). Defaults to the machine hostname.
|
||||||
|
pub server_name: String,
|
||||||
|
|
||||||
|
/// Whether physically scanned cards should have their IDm forwarded to the game
|
||||||
|
/// instead of the access code.
|
||||||
|
pub physical_card_use_idm: bool,
|
||||||
|
|
||||||
|
/// Whether holding the enter key should trigger a card scan using the code
|
||||||
|
/// from the aime.txt file
|
||||||
|
pub enable_aime_txt: bool,
|
||||||
|
|
||||||
|
/// Path to the aime.txt file
|
||||||
|
pub aime_txt_path: PathBuf,
|
||||||
|
|
||||||
|
/// Server address that should be listened on
|
||||||
|
pub server_listen_address: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn instance() -> &'static Config {
|
||||||
|
CONFIG.get_or_init(|| {
|
||||||
|
Config::load().unwrap_or_else(|e| {
|
||||||
|
warn!("Failed to load config: {}. Using defaults.", e);
|
||||||
|
Config::default()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load() -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
|
let config_path = env::var(defaults::CONFIG_FILE_ENV).unwrap_or_else(|_| defaults::CONFIG_FILE.to_string());
|
||||||
|
let ini = Ini::load_from_file(&config_path)?;
|
||||||
|
|
||||||
|
let io_section = ini.section(Some("aimeio")).ok_or("Missing [aimeio] section")?;
|
||||||
|
let aime_section = ini.section(Some("aime"));
|
||||||
|
|
||||||
|
// gameId must be exactly 4 ascii letters or empty
|
||||||
|
let game_id = io_section.get("gameId").unwrap_or("");
|
||||||
|
let game_id = if game_id.is_empty() {
|
||||||
|
None
|
||||||
|
} else if game_id.chars().count() == limits::GAME_ID_LEN && game_id.chars().all(|c| c.is_ascii_alphabetic()) {
|
||||||
|
Some(game_id.to_ascii_uppercase().to_string())
|
||||||
|
} else {
|
||||||
|
error!("Invalid gameId '{game_id}': must be exactly {} characters or empty", limits::GAME_ID_LEN);
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let default_hostname = hostname::get()
|
||||||
|
.ok()
|
||||||
|
.and_then(|h| h.into_string().ok())
|
||||||
|
.unwrap_or_else(|| defaults::SERVER_NAME.to_string());
|
||||||
|
|
||||||
|
let server_name = io_section.get("serverName")
|
||||||
|
.unwrap_or(&default_hostname)
|
||||||
|
.chars()
|
||||||
|
.take(limits::SERVER_NAME_MAX_LEN)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Read boolean flags
|
||||||
|
let physical_card_use_idm = io_section.get("useAimeDBForPhysicalCards")
|
||||||
|
.and_then(|v| v.parse::<i32>().ok())
|
||||||
|
.map(|v| v > 0)
|
||||||
|
.unwrap_or(defaults::INCLUDE_IDM_FOR_PHYSICAL_CARDS);
|
||||||
|
|
||||||
|
let enable_aime_txt = io_section.get("enableKeyboardMode")
|
||||||
|
.and_then(|v| v.parse::<i32>().ok())
|
||||||
|
.map(|v| v > 0)
|
||||||
|
.unwrap_or(defaults::AIME_KEYBOARD_ENABLED);
|
||||||
|
|
||||||
|
let aime_txt_path_raw = aime_section
|
||||||
|
.and_then(|s| s.get("aimePath"))
|
||||||
|
.unwrap_or(defaults::AIME_FILE);
|
||||||
|
|
||||||
|
// Normalize backslashes to forward slashes to avoid escape issues
|
||||||
|
let aime_txt_path_str = aime_txt_path_raw.replace("\\", "/");
|
||||||
|
let aime_txt_path = PathBuf::from(aime_txt_path_str);
|
||||||
|
|
||||||
|
let server_listen_address = io_section.get("listenAddress")
|
||||||
|
.unwrap_or(defaults::SERVER_ADDRESS)
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
Ok(Config {
|
||||||
|
game_id,
|
||||||
|
server_name,
|
||||||
|
physical_card_use_idm,
|
||||||
|
enable_aime_txt,
|
||||||
|
aime_txt_path,
|
||||||
|
server_listen_address,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Config {
|
||||||
|
fn default() -> Self {
|
||||||
|
let default_hostname = hostname::get()
|
||||||
|
.ok()
|
||||||
|
.and_then(|h| h.into_string().ok())
|
||||||
|
.unwrap_or_else(|| defaults::SERVER_NAME.to_string());
|
||||||
|
|
||||||
|
Self {
|
||||||
|
game_id: None,
|
||||||
|
server_name: default_hostname.chars().take(limits::SERVER_NAME_MAX_LEN).collect(),
|
||||||
|
physical_card_use_idm: defaults::INCLUDE_IDM_FOR_PHYSICAL_CARDS,
|
||||||
|
enable_aime_txt: defaults::AIME_KEYBOARD_ENABLED,
|
||||||
|
aime_txt_path: PathBuf::from(defaults::AIME_FILE),
|
||||||
|
server_listen_address: defaults::SERVER_ADDRESS.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
use tokio::runtime::Runtime;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
mod config;
|
||||||
|
mod card;
|
||||||
|
mod webapi;
|
||||||
|
mod metrics;
|
||||||
|
mod utils;
|
||||||
|
mod webserver;
|
||||||
|
|
||||||
|
use card::CardPresenter;
|
||||||
|
use config::Config;
|
||||||
|
use metrics::SystemMetrics;
|
||||||
|
|
||||||
|
// Re-export public API for library users
|
||||||
|
pub use config::Config as ServerConfig;
|
||||||
|
pub use card::{CardPresenter as CardSlot, StoredCard, CardPresentResult};
|
||||||
|
pub use metrics::SystemMetrics as Metrics;
|
||||||
|
pub use webserver::build_server;
|
||||||
|
pub use utils::{format_hex_bytes, parse_access_code, parse_idm_hex};
|
||||||
|
|
||||||
|
static WEBSERVER_INIT: OnceLock<()> = OnceLock::<()>::new();
|
||||||
|
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn aime_io_get_api_version() -> u16 {
|
||||||
|
0x0100
|
||||||
|
}
|
||||||
|
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn aime_io_init() -> i32 {
|
||||||
|
WEBSERVER_INIT.get_or_init(|| {
|
||||||
|
std::thread::spawn(|| {
|
||||||
|
let rt = Runtime::new().expect("Failed to create tokio runtime");
|
||||||
|
rt.block_on(start_server_async());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn aime_io_nfc_poll(unit_no: u8) -> i32 {
|
||||||
|
if unit_no != 0 {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(_) = SystemMetrics::instance().set_last_poll() {
|
||||||
|
warn!("Failed to update last poll time");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
use windows_sys::Win32::UI::Input::KeyboardAndMouse::GetAsyncKeyState;
|
||||||
|
|
||||||
|
let config = Config::instance();
|
||||||
|
if !config.enable_aime_txt {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let enter_pressed = unsafe { (GetAsyncKeyState(0x0D) & (0x8000u16 as i16)) != 0 };
|
||||||
|
if !enter_pressed {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let aime_code = match std::fs::read_to_string(&config.aime_txt_path) {
|
||||||
|
Ok(content) => content.trim().to_string(),
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to read text file at {}: {}", config.aime_txt_path.display(), e);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if aime_code.is_empty() {
|
||||||
|
warn!("Access code file is empty");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(access_code) = utils::parse_access_code(&aime_code) else {
|
||||||
|
warn!("Invalid access code read from file");
|
||||||
|
return 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
match CardPresenter::instance().present_card(access_code, None, std::time::Duration::from_secs(5)) {
|
||||||
|
card::CardPresentResult::Accepted => {
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
card::CardPresentResult::SlotUnavailable(release_time) => {
|
||||||
|
warn!("Access code was not accepted as there is already a card in the active slot until {:?}", release_time);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn aime_io_nfc_get_aime_id(unit_no: u8, luid: *mut u8, luid_size: usize) -> i32 {
|
||||||
|
if unit_no != 0 {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let require_idm = if Config::instance().physical_card_use_idm {
|
||||||
|
Some(false)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(card) = CardPresenter::instance().request_card(require_idm) else {
|
||||||
|
return 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
let copy_size = std::cmp::min(card.access_code.len(), luid_size);
|
||||||
|
std::ptr::copy_nonoverlapping(card.access_code.as_ptr(), luid, copy_size);
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Provided access code: {}", utils::format_hex_bytes(&card.access_code));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn aime_io_nfc_get_felica_id(unit_no: u8, idm: *mut u64) -> i32 {
|
||||||
|
if unit_no != 0 || Config::instance().physical_card_use_idm == false {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(card) = CardPresenter::instance().request_card(Some(true)) else {
|
||||||
|
return 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(card_idm) = card.card_idm else {
|
||||||
|
return 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut idm_value: u64 = 0;
|
||||||
|
for byte in card_idm {
|
||||||
|
idm_value = (idm_value << 8) | (byte as u64);
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
*idm = idm_value;
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Provided IDm: {}", utils::format_hex_bytes(&card_idm));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn aime_io_led_set_color(_unit_no: u8, _r: u8, _g: u8, _b: u8) {
|
||||||
|
// do nothing
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
#[allow(non_snake_case)]
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
pub extern "system" fn DllMain(_hinst_dll: *mut std::ffi::c_void, fdw_reason: u32, _lpv_reserved: *mut std::ffi::c_void) -> i32 {
|
||||||
|
if fdw_reason != 1 /* DLL_PROCESS_ATTACH */ {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
windows_sys::Win32::System::Console::AllocConsole();
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_max_level(tracing::Level::DEBUG)
|
||||||
|
.with_ansi(false) // some targets that are injected into don't play nicely with ansi codes
|
||||||
|
.init();
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start_server_async() {
|
||||||
|
let app = webserver::build_server();
|
||||||
|
let config = Config::instance();
|
||||||
|
|
||||||
|
let listen_addr = config.server_listen_address.as_str();
|
||||||
|
let listener = TcpListener::bind(listen_addr)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| panic!("Failed to bind to {}: {}", listen_addr, e));
|
||||||
|
|
||||||
|
SystemMetrics::instance().set_start_time();
|
||||||
|
|
||||||
|
let game_id = config.game_id.clone().unwrap_or_else(|| "SXXX".to_string());
|
||||||
|
|
||||||
|
info!("AMNet Server - Version {} ({}) loaded", env!("CARGO_PKG_VERSION"), game_id);
|
||||||
|
info!("Listening on {} - visit http://card.ppc.moe from a mobile device or use the AMNet App to get started.", listen_addr);
|
||||||
|
|
||||||
|
axum::serve(listener, app)
|
||||||
|
.await
|
||||||
|
.expect("Failed to start server");
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
use std::{sync::{Mutex, OnceLock}, time::{Duration, Instant}};
|
||||||
|
|
||||||
|
static METRICS: OnceLock<SystemMetrics> = OnceLock::new();
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct SystemMetrics {
|
||||||
|
start_time: OnceLock<Instant>,
|
||||||
|
last_poll_time: Mutex<Option<Instant>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SystemMetrics {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
start_time: OnceLock::new(),
|
||||||
|
last_poll_time: Mutex::new(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SystemMetrics {
|
||||||
|
pub fn instance() -> &'static SystemMetrics {
|
||||||
|
METRICS.get_or_init(|| SystemMetrics::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// get the start_time of the server.
|
||||||
|
pub fn get_server_uptime(&self) -> Option<Duration> {
|
||||||
|
let Some(time) = self.start_time.get() else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(time.elapsed())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// sets the start time of the server.
|
||||||
|
/// this does nothing if the time was already set.
|
||||||
|
pub fn set_start_time(&self) {
|
||||||
|
self.start_time.get_or_init(|| Instant::now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// get the last time the card was polled by the game.
|
||||||
|
/// if the data is not available, an empty err will be returned.
|
||||||
|
pub fn get_last_poll(&self) -> Option<Duration> {
|
||||||
|
let Ok(time_guard) = self.last_poll_time.lock() else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(time_guard.as_ref()?.elapsed())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the last poll time to now, returning the previously stored value
|
||||||
|
pub fn set_last_poll(&self) -> Result<(), ()> {
|
||||||
|
let Ok(mut time_guard) = self.last_poll_time.lock() else {
|
||||||
|
return Err(());
|
||||||
|
};
|
||||||
|
|
||||||
|
time_guard.replace(Instant::now());
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
pub fn format_hex_bytes(bytes: &[u8]) -> String {
|
||||||
|
bytes.chunks(2)
|
||||||
|
.map(|chunk| chunk.iter().map(|b| format!("{:02X}", b)).collect::<String>())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_access_code(code: &String) -> Option<[u8; 10]> {
|
||||||
|
let matrix_code = code.replace(" ", "");
|
||||||
|
|
||||||
|
if code.chars().any(|c| !c.is_ascii_hexdigit() && !c.is_whitespace()) && matrix_code.len() != 20 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut bytes: [u8; 10] = [0; 10];
|
||||||
|
|
||||||
|
for i in 0..bytes.len() {
|
||||||
|
let slice_str = &matrix_code[i*2..i*2+2];
|
||||||
|
let slice_val = u8::from_str_radix(slice_str, 16).ok()?;
|
||||||
|
|
||||||
|
bytes[i] = slice_val;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_idm_hex(idm_hex: &String) -> Option<[u8; 8]> {
|
||||||
|
if idm_hex.len() != 16 || idm_hex.chars().any(|c| !c.is_ascii_hexdigit()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut idm_bytes: [u8; 8] = [0; 8];
|
||||||
|
|
||||||
|
for i in 0..idm_bytes.len() {
|
||||||
|
let slice_str = &idm_hex[i*2..i*2+2];
|
||||||
|
let slice_val = u8::from_str_radix(slice_str, 16).ok()?;
|
||||||
|
|
||||||
|
idm_bytes[i] = slice_val;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(idm_bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
mod format_hex_bytes_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn formats_empty_bytes() {
|
||||||
|
assert_eq!(format_hex_bytes(&[]), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn formats_access_code_bytes() {
|
||||||
|
let bytes = [0x00, 0x10, 0x12, 0x34, 0x56, 0x78, 0x90, 0x12, 0x34, 0x56];
|
||||||
|
assert_eq!(format_hex_bytes(&bytes), "0010 1234 5678 9012 3456");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mod parse_access_code_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_full_20_digit_code() {
|
||||||
|
let code = "00101234567890123456".to_string();
|
||||||
|
let result = parse_access_code(&code);
|
||||||
|
|
||||||
|
assert!(result.is_some());
|
||||||
|
assert_eq!(result.unwrap(), [0x00, 0x10, 0x12, 0x34, 0x56, 0x78, 0x90, 0x12, 0x34, 0x56]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_code_with_spaces() {
|
||||||
|
let code = "0010 1234 5678 9012 3456".to_string();
|
||||||
|
let result = parse_access_code(&code);
|
||||||
|
|
||||||
|
assert!(result.is_some());
|
||||||
|
assert_eq!(result.unwrap(), [0x00, 0x10, 0x12, 0x34, 0x56, 0x78, 0x90, 0x12, 0x34, 0x56]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_code_with_leading_zeros() {
|
||||||
|
let code = "00000000000000001234".to_string(); // Valid 20-digit code with leading zeros
|
||||||
|
let result = parse_access_code(&code);
|
||||||
|
|
||||||
|
assert!(result.is_some());
|
||||||
|
assert_eq!(result.unwrap(), [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x12, 0x34]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn returns_none_for_invalid_hex() {
|
||||||
|
let code = "GHIJKLMNOPQRSTUVWXYZ".to_string();
|
||||||
|
let result = parse_access_code(&code);
|
||||||
|
|
||||||
|
assert!(result.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_code_with_all_zeros() {
|
||||||
|
let code = "00000000000000000000".to_string();
|
||||||
|
let result = parse_access_code(&code);
|
||||||
|
|
||||||
|
assert!(result.is_some());
|
||||||
|
assert_eq!(result.unwrap(), [0x00; 10]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mod parse_idm_hex_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_full_16_char_idm() {
|
||||||
|
let idm = "0102030405060708".to_string();
|
||||||
|
let result = parse_idm_hex(&idm);
|
||||||
|
|
||||||
|
assert!(result.is_some());
|
||||||
|
assert_eq!(result.unwrap(), [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_short_idm_returns_none() {
|
||||||
|
let idm = "1234".to_string();
|
||||||
|
let result = parse_idm_hex(&idm);
|
||||||
|
|
||||||
|
// IDm is too short - should fail
|
||||||
|
assert!(result.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn returns_none_for_too_long_idm() {
|
||||||
|
let idm = "01020304050607080910".to_string(); // 20 chars
|
||||||
|
let result = parse_idm_hex(&idm);
|
||||||
|
|
||||||
|
assert!(result.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn returns_none_for_non_hex_characters() {
|
||||||
|
let idm = "0102GHIJ".to_string();
|
||||||
|
let result = parse_idm_hex(&idm);
|
||||||
|
|
||||||
|
assert!(result.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_empty_idm_returns_none() {
|
||||||
|
let idm = "".to_string();
|
||||||
|
let result = parse_idm_hex(&idm);
|
||||||
|
|
||||||
|
// Empty IDm should fail
|
||||||
|
assert!(result.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub const API_VERSION: u8 = 1;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct ServerError {
|
||||||
|
pub error: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct ServerState {
|
||||||
|
#[serde(rename = "apiVersion")]
|
||||||
|
pub api_version: u8,
|
||||||
|
|
||||||
|
#[serde(rename = "gameId")]
|
||||||
|
pub game_id: Option<String>,
|
||||||
|
|
||||||
|
#[serde(rename = "serverName")]
|
||||||
|
pub server_name: String,
|
||||||
|
|
||||||
|
#[serde(rename = "sessionUptime")]
|
||||||
|
pub session_uptime: u64,
|
||||||
|
|
||||||
|
#[serde(rename = "timeSinceLastPoll")]
|
||||||
|
pub time_since_last_poll: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CardSubmissionRequest {
|
||||||
|
#[serde(rename = "cardId")]
|
||||||
|
pub access_code: String,
|
||||||
|
|
||||||
|
#[serde(rename = "physicalCardIDm")]
|
||||||
|
pub card_idm_hex: Option<String>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use axum::http::{Method, StatusCode, header};
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use axum::routing::{get, post};
|
||||||
|
use axum::{Json, Router};
|
||||||
|
use tower::ServiceBuilder;
|
||||||
|
use tower_http::{cors::{Any, CorsLayer}, trace::TraceLayer};
|
||||||
|
|
||||||
|
use crate::card::{CardPresentResult, CardPresenter};
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::metrics::SystemMetrics;
|
||||||
|
use crate::utils;
|
||||||
|
use crate::webapi::API_VERSION;
|
||||||
|
|
||||||
|
pub fn build_server() -> Router {
|
||||||
|
let services = ServiceBuilder::new()
|
||||||
|
.layer(TraceLayer::new_for_http())
|
||||||
|
.layer(CorsLayer::new().allow_origin(Any).allow_headers(Any).allow_methods([Method::GET, Method::POST]).max_age(Duration::from_secs(600)));
|
||||||
|
|
||||||
|
Router::<()>::new()
|
||||||
|
.route("/amnet/info", get(server_state_handler))
|
||||||
|
.route("/amnet/signin", post(card_submission_handler))
|
||||||
|
.layer(services)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_error_response(status: StatusCode, message: &str) -> Response {
|
||||||
|
let error = crate::webapi::ServerError {
|
||||||
|
error: message.to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
(status, Json(error)).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn server_state_handler() -> Json<crate::webapi::ServerState> {
|
||||||
|
let config = Config::instance();
|
||||||
|
let metrics = SystemMetrics::instance();
|
||||||
|
|
||||||
|
let session_uptime = metrics.get_server_uptime().map(|d| d.as_secs()).unwrap_or(0);
|
||||||
|
let time_since_last_poll = metrics.get_last_poll().map(|d| d.as_secs());
|
||||||
|
|
||||||
|
let state = crate::webapi::ServerState {
|
||||||
|
api_version: API_VERSION,
|
||||||
|
game_id: config.game_id.clone(),
|
||||||
|
server_name: config.server_name.clone(),
|
||||||
|
session_uptime,
|
||||||
|
time_since_last_poll,
|
||||||
|
};
|
||||||
|
|
||||||
|
Json(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn card_submission_handler(
|
||||||
|
Json(payload): Json<crate::webapi::CardSubmissionRequest>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
if payload.access_code.len() > 20 || payload.access_code.len() == 0 {
|
||||||
|
return build_error_response(StatusCode::BAD_REQUEST, "Access code is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(access_code) = utils::parse_access_code(&payload.access_code) else {
|
||||||
|
return build_error_response(StatusCode::UNPROCESSABLE_ENTITY, "Invalid access code provided");
|
||||||
|
};
|
||||||
|
|
||||||
|
let card_idm_bytes = if let Some(idm_hex) = payload.card_idm_hex.as_ref() {
|
||||||
|
if idm_hex.len() != 16 {
|
||||||
|
return build_error_response(StatusCode::BAD_REQUEST, "IDm must be a 16-char hex string");
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(idm_bytes) = utils::parse_idm_hex(idm_hex) else {
|
||||||
|
return build_error_response(StatusCode::UNPROCESSABLE_ENTITY, "Invalid IDm provided");
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(idm_bytes)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
match CardPresenter::instance().present_card(access_code, card_idm_bytes, Duration::from_secs(5)) {
|
||||||
|
CardPresentResult::Accepted => StatusCode::ACCEPTED.into_response(),
|
||||||
|
CardPresentResult::SlotUnavailable(release_time) => {
|
||||||
|
let now = Instant::now();
|
||||||
|
let wait_secs = if release_time > now {
|
||||||
|
(release_time - now).as_secs()
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut response = build_error_response(
|
||||||
|
StatusCode::TOO_MANY_REQUESTS,
|
||||||
|
"A card has already been submitted. Please wait before submitting another.",
|
||||||
|
);
|
||||||
|
|
||||||
|
let headers = response.headers_mut();
|
||||||
|
headers.insert(header::RETRY_AFTER, wait_secs.to_string().parse().unwrap());
|
||||||
|
|
||||||
|
response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+10
-1
@@ -8,6 +8,7 @@ declare const AMNET_API_VERSION = 1;
|
|||||||
declare const MIN_AMNET_API_VERSION = 1;
|
declare const MIN_AMNET_API_VERSION = 1;
|
||||||
interface AMCardInRequest {
|
interface AMCardInRequest {
|
||||||
cardId: string;
|
cardId: string;
|
||||||
|
physicalCardIDm?: string;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Server connection info (api version, address, etc.)
|
* Server connection info (api version, address, etc.)
|
||||||
@@ -49,6 +50,7 @@ declare function performCardIn(server: AMServerConnectionInfo, request: AMCardIn
|
|||||||
declare function fetchServerInfo(serverAddress: string, abort: AbortSignal): Promise<AMServerInfo | null>;
|
declare function fetchServerInfo(serverAddress: string, abort: AbortSignal): Promise<AMServerInfo | null>;
|
||||||
|
|
||||||
declare const CARD_ID_REGEX: RegExp;
|
declare const CARD_ID_REGEX: RegExp;
|
||||||
|
declare const AMNET_COMPANY_ID = 597;
|
||||||
declare function generateCardNumber(): string;
|
declare function generateCardNumber(): string;
|
||||||
/**
|
/**
|
||||||
* Validates the provided card id by known parameters
|
* Validates the provided card id by known parameters
|
||||||
@@ -62,4 +64,11 @@ declare function validateCardId(id: string): boolean;
|
|||||||
*/
|
*/
|
||||||
declare function getGameName(id: string): string | null;
|
declare function getGameName(id: string): string | null;
|
||||||
|
|
||||||
export { type AMCardInRequest, AMNET_API_VERSION, type AMServerConnectionInfo, type AMServerInfo, type AMServerInstanceMetrics, type AMServerMetadata, CARD_ID_REGEX, MIN_AMNET_API_VERSION, fetchServerInfo, generateCardNumber, getGameName, performCardIn, validateCardId };
|
type DecodedAccessCode = {
|
||||||
|
revision: number;
|
||||||
|
serial: number;
|
||||||
|
};
|
||||||
|
declare function decodeAccessCode(code: string): DecodedAccessCode | null;
|
||||||
|
declare function encodeAccessCode(company: number, serial: number, rev?: number): string;
|
||||||
|
|
||||||
|
export { type AMCardInRequest, AMNET_API_VERSION, AMNET_COMPANY_ID, type AMServerConnectionInfo, type AMServerInfo, type AMServerInstanceMetrics, type AMServerMetadata, CARD_ID_REGEX, decodeAccessCode as DecodeAMAccessCode, encodeAccessCode as EncodeAMAccessCode, MIN_AMNET_API_VERSION, fetchServerInfo, generateCardNumber, getGameName, performCardIn, validateCardId };
|
||||||
|
|||||||
Vendored
+10
-1
@@ -8,6 +8,7 @@ declare const AMNET_API_VERSION = 1;
|
|||||||
declare const MIN_AMNET_API_VERSION = 1;
|
declare const MIN_AMNET_API_VERSION = 1;
|
||||||
interface AMCardInRequest {
|
interface AMCardInRequest {
|
||||||
cardId: string;
|
cardId: string;
|
||||||
|
physicalCardIDm?: string;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Server connection info (api version, address, etc.)
|
* Server connection info (api version, address, etc.)
|
||||||
@@ -49,6 +50,7 @@ declare function performCardIn(server: AMServerConnectionInfo, request: AMCardIn
|
|||||||
declare function fetchServerInfo(serverAddress: string, abort: AbortSignal): Promise<AMServerInfo | null>;
|
declare function fetchServerInfo(serverAddress: string, abort: AbortSignal): Promise<AMServerInfo | null>;
|
||||||
|
|
||||||
declare const CARD_ID_REGEX: RegExp;
|
declare const CARD_ID_REGEX: RegExp;
|
||||||
|
declare const AMNET_COMPANY_ID = 597;
|
||||||
declare function generateCardNumber(): string;
|
declare function generateCardNumber(): string;
|
||||||
/**
|
/**
|
||||||
* Validates the provided card id by known parameters
|
* Validates the provided card id by known parameters
|
||||||
@@ -62,4 +64,11 @@ declare function validateCardId(id: string): boolean;
|
|||||||
*/
|
*/
|
||||||
declare function getGameName(id: string): string | null;
|
declare function getGameName(id: string): string | null;
|
||||||
|
|
||||||
export { type AMCardInRequest, AMNET_API_VERSION, type AMServerConnectionInfo, type AMServerInfo, type AMServerInstanceMetrics, type AMServerMetadata, CARD_ID_REGEX, MIN_AMNET_API_VERSION, fetchServerInfo, generateCardNumber, getGameName, performCardIn, validateCardId };
|
type DecodedAccessCode = {
|
||||||
|
revision: number;
|
||||||
|
serial: number;
|
||||||
|
};
|
||||||
|
declare function decodeAccessCode(code: string): DecodedAccessCode | null;
|
||||||
|
declare function encodeAccessCode(company: number, serial: number, rev?: number): string;
|
||||||
|
|
||||||
|
export { type AMCardInRequest, AMNET_API_VERSION, AMNET_COMPANY_ID, type AMServerConnectionInfo, type AMServerInfo, type AMServerInstanceMetrics, type AMServerMetadata, CARD_ID_REGEX, decodeAccessCode as DecodeAMAccessCode, encodeAccessCode as EncodeAMAccessCode, MIN_AMNET_API_VERSION, fetchServerInfo, generateCardNumber, getGameName, performCardIn, validateCardId };
|
||||||
|
|||||||
Vendored
+121
-46
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+105
-43
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Generated
+574
-750
File diff suppressed because it is too large
Load Diff
@@ -16,7 +16,12 @@
|
|||||||
".": "./dist/index.mjs"
|
".": "./dist/index.mjs"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"tsup": "^8.2.4",
|
"@types/lodash": "^4.17.21",
|
||||||
"typescript": "^5.5.4"
|
"tsup": "^8.5.1",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"big-integer": "^1.6.52",
|
||||||
|
"lodash": "^4.17.21"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export const MIN_AMNET_API_VERSION = 1;
|
|||||||
|
|
||||||
export interface AMCardInRequest {
|
export interface AMCardInRequest {
|
||||||
cardId: string;
|
cardId: string;
|
||||||
|
physicalCardIDm?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,23 +1,12 @@
|
|||||||
|
import {encodeAccessCode} from "./card-encoding";
|
||||||
|
|
||||||
export const CARD_ID_REGEX = /^(?:[0-9]{4} ?){5}$/i;
|
export const CARD_ID_REGEX = /^(?:[0-9]{4} ?){5}$/i;
|
||||||
|
export const AMNET_COMPANY_ID = 597;
|
||||||
|
|
||||||
export function generateCardNumber(): string {
|
export function generateCardNumber(): string {
|
||||||
let cardId = '';
|
// serial between 5000 and 99999999999
|
||||||
|
const serial = Math.floor(Math.random() * 99999994999) + 5000;
|
||||||
while (true) {
|
return encodeAccessCode(AMNET_COMPANY_ID, serial, 0);
|
||||||
// don't start with a 3
|
|
||||||
const firstDigit = Math.floor(Math.random() * 10);
|
|
||||||
|
|
||||||
if (firstDigit !== 3) {
|
|
||||||
cardId = firstDigit.toString();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < 19; i++) {
|
|
||||||
cardId += Math.floor(Math.random() * 10).toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
return cardId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,18 +4,38 @@
|
|||||||
*/
|
*/
|
||||||
export function getGameName(id: string): string | null {
|
export function getGameName(id: string): string | null {
|
||||||
switch (id?.toUpperCase()) {
|
switch (id?.toUpperCase()) {
|
||||||
case "SDGT":
|
case "SDGT": // IDAC JPN
|
||||||
|
case "SDJJ": // IDAC CHN
|
||||||
return "Initial D The Arcade";
|
return "Initial D The Arcade";
|
||||||
case "SDHD":
|
|
||||||
case "SDBT":
|
case "SDGS": // CHUNITHM INTL
|
||||||
|
case "SDHD": // CHUNITHM NEW
|
||||||
|
case "SDHJ": // CHUNITHM CHN
|
||||||
|
case "SDBT": // CHUNITHM
|
||||||
return "CHUNITHM";
|
return "CHUNITHM";
|
||||||
|
|
||||||
|
case "SDEZ": // maimai DX
|
||||||
|
case "SDGA": // maimai DX INTL
|
||||||
|
case "SDGB": // maimai DX CHN
|
||||||
|
return "maimai DX";
|
||||||
|
|
||||||
case "SDDT":
|
case "SDDT":
|
||||||
return "O.N.G.E.K.I.";
|
return "O.N.G.E.K.I.";
|
||||||
case "SDEZ":
|
|
||||||
return "maimai DX";
|
case "SDED":
|
||||||
|
return "Card Maker";
|
||||||
|
|
||||||
case "SBZV":
|
case "SBZV":
|
||||||
return "Project DIVA Future Tone";
|
return "Project DIVA Future Tone";
|
||||||
|
|
||||||
|
case "SDDS":
|
||||||
|
return "SEGA World Driver Championship";
|
||||||
|
|
||||||
|
case "SDFE": // Wacca JPN
|
||||||
|
case "SDHN": // Wacca Ver.2/2R CHN
|
||||||
|
case "SDJE": // Wacca Ver.3 CHN
|
||||||
|
return "Wacca";
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
export * from './api';
|
export * from './api';
|
||||||
export * from './card';
|
export * from './card';
|
||||||
export * from './gameIds';
|
export * from './gameIds';
|
||||||
|
|
||||||
|
export {decodeAccessCode as DecodeAMAccessCode, encodeAccessCode as EncodeAMAccessCode} from './card-encoding';
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2016",
|
"baseUrl": ".",
|
||||||
|
"target": "ES2017",
|
||||||
"module": "CommonJS",
|
"module": "CommonJS",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"strict": true,
|
|
||||||
"noUncheckedIndexedAccess": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"baseUrl": "."
|
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"]
|
"include": [
|
||||||
|
"src/**/*.ts"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2520
-2255
File diff suppressed because it is too large
Load Diff
+39
-39
@@ -10,49 +10,49 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^3.9.0",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.1",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-context-menu": "^2.2.1",
|
"@radix-ui/react-context-menu": "^2.2.16",
|
||||||
"@radix-ui/react-dialog": "^1.1.1",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.0",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-separator": "^1.1.0",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
"@radix-ui/react-slot": "^1.1.4",
|
||||||
"@radix-ui/react-toast": "^1.2.1",
|
"@radix-ui/react-toast": "^1.2.15",
|
||||||
"@radix-ui/react-tooltip": "^1.1.2",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"amnet-shared": "file:../amnet-shared",
|
"amnet-shared": "file:../amnet-shared",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"core-js": "^3.39.0",
|
"core-js": "^3.47.0",
|
||||||
"detectincognitojs": "^1.3.5",
|
"detectincognitojs": "^1.6.2",
|
||||||
"dexie": "^4.0.8",
|
"dexie": "^4.2.1",
|
||||||
"dexie-react-hooks": "^1.1.7",
|
"dexie-react-hooks": "^4.2.0",
|
||||||
"lucide-react": "^0.427.0",
|
"lucide-react": "^0.562.0",
|
||||||
"react": "^18.3.1",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^19.2.3",
|
||||||
"react-hook-form": "^7.52.2",
|
"react-hook-form": "^7.69.0",
|
||||||
"react-router-dom": "^6.26.0",
|
"react-router-dom": "^7.11.0",
|
||||||
"react-timeago": "^7.2.0",
|
"react-timeago": "^8.3.0",
|
||||||
"tailwind-merge": "^2.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tw-animate-css": "^1.4.0",
|
||||||
"vaul": "^0.9.1",
|
"vaul": "^1.1.2",
|
||||||
"zod": "^3.23.8"
|
"zod": "^4.2.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.8.0",
|
"@eslint/js": "^9.23.0",
|
||||||
"@types/node": "^22.2.0",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@types/react": "^18.3.3",
|
"@types/node": "^24.0.0",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react": "^19.2.7",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/react-timeago": "^4.1.7",
|
"@types/react-timeago": "^4.1.7",
|
||||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
"@vitejs/plugin-react-swc": "^4.2.2",
|
||||||
"autoprefixer": "^10.4.20",
|
"eslint": "^9.23.0",
|
||||||
"eslint": "^9.8.0",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
"eslint-plugin-react-refresh": "^0.4.26",
|
||||||
"eslint-plugin-react-refresh": "^0.4.9",
|
"globals": "^16.5.0",
|
||||||
"globals": "^15.9.0",
|
"postcss": "^8.5.6",
|
||||||
"postcss": "^8.4.41",
|
"tailwindcss": "^4.1.18",
|
||||||
"tailwindcss": "^3.4.9",
|
"typescript": "^5.9.3",
|
||||||
"typescript": "^5.5.3",
|
"typescript-eslint": "^8.51.0",
|
||||||
"typescript-eslint": "^8.0.0",
|
"vite": "^7.3.0"
|
||||||
"vite": "^5.4.6"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
export default {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
'@tailwindcss/postcss': {},
|
||||||
autoprefixer: {},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
{
|
|
||||||
"applinks": {
|
|
||||||
"details": [
|
|
||||||
{
|
|
||||||
"appIDs": [
|
|
||||||
"Q824VHAT9S.moe.ppc.amnet"
|
|
||||||
],
|
|
||||||
"components": [
|
|
||||||
{
|
|
||||||
"#": "/cards*"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"#": "/servers"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"#": "/servers/"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"#": "/servers/add*"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"#": "/servers/*",
|
|
||||||
"exclude": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -24,7 +24,7 @@ export function AMCard(props: AMCardActionProps & { title: string, titlePerforms
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{props.actionClicked && (
|
{props.actionClicked && (
|
||||||
<Button className="flex-shrink-0" variant="ghost" size="icon" disabled={props.actionDisabled} onClick={props.actionClicked}>
|
<Button className="shrink-0" variant="ghost" size="icon" disabled={props.actionDisabled} onClick={props.actionClicked}>
|
||||||
{props.actionIcon ?? <ChevronRight className="h-4 w-4"/>}
|
{props.actionIcon ?? <ChevronRight className="h-4 w-4"/>}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export function AMHeader(props: { title: string, children?: Readonly<React.React
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<h2 className="text-4xl font-bold select-none flex-grow">{props.title}</h2>
|
<h2 className="text-4xl font-bold select-none grow">{props.title}</h2>
|
||||||
{props.children}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
<Separator/>
|
<Separator/>
|
||||||
|
|||||||
@@ -98,10 +98,9 @@ function AimeCardCreationForm(props: { callback?: () => void }) {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="0000 0000 0000 0000 0000" {...field} />
|
<Input placeholder="0000 0000 0000 0000 0000" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription className="select-none">
|
||||||
If you've got a card, enter the matrix code here. If not, you can
|
If you've got a card, enter the matrix code here. If not, you can
|
||||||
<span className="font-bold hover:cursor-pointer ml-1"
|
<span className="font-bold hover:cursor-pointer ml-1" onClick={() => form.setValue("id", generateCardNumber())}>
|
||||||
onClick={() => form.setValue("id", generateCardNumber())}>
|
|
||||||
generate a new one.
|
generate a new one.
|
||||||
</span>
|
</span>
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {Loader} from "lucide-react";
|
|||||||
|
|
||||||
export function LoadingIndicator() {
|
export function LoadingIndicator() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-grow gap-2 justify-center items-center text-gray-500 py-5">
|
<div className="flex grow gap-2 justify-center items-center text-gray-500 py-5">
|
||||||
<Loader className="h-8 w-8"/>
|
<Loader className="h-8 w-8"/>
|
||||||
<span className="text-center">Loading...</span>
|
<span className="text-center">Loading...</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,29 +10,30 @@ const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
|||||||
|
|
||||||
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||||
|
|
||||||
const AlertDialogOverlay = React.forwardRef<
|
function AlertDialogOverlay({
|
||||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
className,
|
||||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
...props
|
||||||
>(({ className, ...props }, ref) => (
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
<AlertDialogPrimitive.Overlay
|
<AlertDialogPrimitive.Overlay
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
|
||||||
/>
|
/>
|
||||||
))
|
)
|
||||||
|
}
|
||||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
const AlertDialogContent = React.forwardRef<
|
function AlertDialogContent({
|
||||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
className,
|
||||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
...props
|
||||||
>(({ className, ...props }, ref) => (
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||||
|
return (
|
||||||
<AlertDialogPortal>
|
<AlertDialogPortal>
|
||||||
<AlertDialogOverlay />
|
<AlertDialogOverlay />
|
||||||
<AlertDialogPrimitive.Content
|
<AlertDialogPrimitive.Content
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||||
className
|
className
|
||||||
@@ -40,7 +41,8 @@ const AlertDialogContent = React.forwardRef<
|
|||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</AlertDialogPortal>
|
</AlertDialogPortal>
|
||||||
))
|
)
|
||||||
|
}
|
||||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||||
|
|
||||||
const AlertDialogHeader = ({
|
const AlertDialogHeader = ({
|
||||||
@@ -71,49 +73,52 @@ const AlertDialogFooter = ({
|
|||||||
)
|
)
|
||||||
AlertDialogFooter.displayName = "AlertDialogFooter"
|
AlertDialogFooter.displayName = "AlertDialogFooter"
|
||||||
|
|
||||||
const AlertDialogTitle = React.forwardRef<
|
function AlertDialogTitle({
|
||||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
className,
|
||||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
...props
|
||||||
>(({ className, ...props }, ref) => (
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
<AlertDialogPrimitive.Title
|
<AlertDialogPrimitive.Title
|
||||||
ref={ref}
|
|
||||||
className={cn("text-lg font-semibold", className)}
|
className={cn("text-lg font-semibold", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
)
|
||||||
|
}
|
||||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||||
|
|
||||||
const AlertDialogDescription = React.forwardRef<
|
function AlertDialogDescription({
|
||||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
className,
|
||||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
...props
|
||||||
>(({ className, ...props }, ref) => (
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
<AlertDialogPrimitive.Description
|
<AlertDialogPrimitive.Description
|
||||||
ref={ref}
|
|
||||||
className={cn("text-sm text-muted-foreground", className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
)
|
||||||
|
}
|
||||||
AlertDialogDescription.displayName =
|
AlertDialogDescription.displayName =
|
||||||
AlertDialogPrimitive.Description.displayName
|
AlertDialogPrimitive.Description.displayName
|
||||||
|
|
||||||
const AlertDialogAction = React.forwardRef<
|
function AlertDialogAction({
|
||||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
className,
|
||||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
...props
|
||||||
>(({ className, ...props }, ref) => (
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||||
|
return (
|
||||||
<AlertDialogPrimitive.Action
|
<AlertDialogPrimitive.Action
|
||||||
ref={ref}
|
|
||||||
className={cn(buttonVariants(), className)}
|
className={cn(buttonVariants(), className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
)
|
||||||
|
}
|
||||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||||
|
|
||||||
const AlertDialogCancel = React.forwardRef<
|
function AlertDialogCancel({
|
||||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
className,
|
||||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
...props
|
||||||
>(({ className, ...props }, ref) => (
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||||
|
return (
|
||||||
<AlertDialogPrimitive.Cancel
|
<AlertDialogPrimitive.Cancel
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
buttonVariants({ variant: "outline" }),
|
buttonVariants({ variant: "outline" }),
|
||||||
"mt-2 sm:mt-0",
|
"mt-2 sm:mt-0",
|
||||||
@@ -121,7 +126,8 @@ const AlertDialogCancel = React.forwardRef<
|
|||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
)
|
||||||
|
}
|
||||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -19,41 +19,45 @@ const alertVariants = cva(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const Alert = React.forwardRef<
|
function Alert({
|
||||||
HTMLDivElement,
|
className,
|
||||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
variant,
|
||||||
>(({ className, variant, ...props }, ref) => (
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>) {
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
|
||||||
role="alert"
|
role="alert"
|
||||||
className={cn(alertVariants({ variant }), className)}
|
className={cn(alertVariants({ variant }), className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
)
|
||||||
|
}
|
||||||
Alert.displayName = "Alert"
|
Alert.displayName = "Alert"
|
||||||
|
|
||||||
const AlertTitle = React.forwardRef<
|
function AlertTitle({
|
||||||
HTMLParagraphElement,
|
className,
|
||||||
React.HTMLAttributes<HTMLHeadingElement>
|
...props
|
||||||
>(({ className, ...props }, ref) => (
|
}: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||||
|
return (
|
||||||
<h5
|
<h5
|
||||||
ref={ref}
|
|
||||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
)
|
||||||
|
}
|
||||||
AlertTitle.displayName = "AlertTitle"
|
AlertTitle.displayName = "AlertTitle"
|
||||||
|
|
||||||
const AlertDescription = React.forwardRef<
|
function AlertDescription({
|
||||||
HTMLParagraphElement,
|
className,
|
||||||
React.HTMLAttributes<HTMLParagraphElement>
|
...props
|
||||||
>(({ className, ...props }, ref) => (
|
}: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
|
||||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
)
|
||||||
|
}
|
||||||
AlertDescription.displayName = "AlertDescription"
|
AlertDescription.displayName = "AlertDescription"
|
||||||
|
|
||||||
export { Alert, AlertTitle, AlertDescription }
|
export { Alert, AlertTitle, AlertDescription }
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
@@ -39,18 +39,21 @@ export interface ButtonProps
|
|||||||
asChild?: boolean
|
asChild?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
function Button({
|
||||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
className,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: ButtonProps) {
|
||||||
const Comp = asChild ? Slot : "button"
|
const Comp = asChild ? Slot : "button"
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
ref={ref}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
|
||||||
Button.displayName = "Button"
|
Button.displayName = "Button"
|
||||||
|
|
||||||
export { Button, buttonVariants }
|
export { Button, buttonVariants }
|
||||||
|
|||||||
@@ -2,78 +2,85 @@ import * as React from "react"
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const Card = React.forwardRef<
|
function Card({
|
||||||
HTMLDivElement,
|
className,
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
...props
|
||||||
>(({ className, ...props }, ref) => (
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
"rounded-lg border bg-card text-card-foreground shadow-xs",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
)
|
||||||
|
}
|
||||||
Card.displayName = "Card"
|
Card.displayName = "Card"
|
||||||
|
|
||||||
const CardHeader = React.forwardRef<
|
function CardHeader({
|
||||||
HTMLDivElement,
|
className,
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
...props
|
||||||
>(({ className, ...props }, ref) => (
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
|
||||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
)
|
||||||
|
}
|
||||||
CardHeader.displayName = "CardHeader"
|
CardHeader.displayName = "CardHeader"
|
||||||
|
|
||||||
const CardTitle = React.forwardRef<
|
function CardTitle({
|
||||||
HTMLParagraphElement,
|
className,
|
||||||
React.HTMLAttributes<HTMLHeadingElement>
|
...props
|
||||||
>(({ className, ...props }, ref) => (
|
}: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||||
|
return (
|
||||||
<h3
|
<h3
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-2xl font-semibold leading-none tracking-tight",
|
"text-2xl font-semibold leading-none tracking-tight",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
)
|
||||||
|
}
|
||||||
CardTitle.displayName = "CardTitle"
|
CardTitle.displayName = "CardTitle"
|
||||||
|
|
||||||
const CardDescription = React.forwardRef<
|
function CardDescription({
|
||||||
HTMLParagraphElement,
|
className,
|
||||||
React.HTMLAttributes<HTMLParagraphElement>
|
...props
|
||||||
>(({ className, ...props }, ref) => (
|
}: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||||
|
return (
|
||||||
<p
|
<p
|
||||||
ref={ref}
|
|
||||||
className={cn("text-sm text-muted-foreground", className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
)
|
||||||
|
}
|
||||||
CardDescription.displayName = "CardDescription"
|
CardDescription.displayName = "CardDescription"
|
||||||
|
|
||||||
const CardContent = React.forwardRef<
|
function CardContent({
|
||||||
HTMLDivElement,
|
className,
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
...props
|
||||||
>(({ className, ...props }, ref) => (
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
return (
|
||||||
))
|
<div className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
CardContent.displayName = "CardContent"
|
CardContent.displayName = "CardContent"
|
||||||
|
|
||||||
const CardFooter = React.forwardRef<
|
function CardFooter({
|
||||||
HTMLDivElement,
|
className,
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
...props
|
||||||
>(({ className, ...props }, ref) => (
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
|
||||||
className={cn("flex items-center p-6 pt-0", className)}
|
className={cn("flex items-center p-6 pt-0", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
)
|
||||||
|
}
|
||||||
CardFooter.displayName = "CardFooter"
|
CardFooter.displayName = "CardFooter"
|
||||||
|
|
||||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||||
|
|||||||
@@ -16,16 +16,18 @@ const ContextMenuSub = ContextMenuPrimitive.Sub
|
|||||||
|
|
||||||
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
|
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
|
||||||
|
|
||||||
const ContextMenuSubTrigger = React.forwardRef<
|
function ContextMenuSubTrigger({
|
||||||
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
|
className,
|
||||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||||
inset?: boolean
|
inset?: boolean
|
||||||
}
|
}) {
|
||||||
>(({ className, inset, children, ...props }, ref) => (
|
return (
|
||||||
<ContextMenuPrimitive.SubTrigger
|
<ContextMenuPrimitive.SubTrigger
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||||
inset && "pl-8",
|
inset && "pl-8",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
@@ -34,67 +36,74 @@ const ContextMenuSubTrigger = React.forwardRef<
|
|||||||
{children}
|
{children}
|
||||||
<ChevronRight className="ml-auto h-4 w-4" />
|
<ChevronRight className="ml-auto h-4 w-4" />
|
||||||
</ContextMenuPrimitive.SubTrigger>
|
</ContextMenuPrimitive.SubTrigger>
|
||||||
))
|
)
|
||||||
|
}
|
||||||
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
|
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
|
||||||
|
|
||||||
const ContextMenuSubContent = React.forwardRef<
|
function ContextMenuSubContent({
|
||||||
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
|
className,
|
||||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
|
...props
|
||||||
>(({ className, ...props }, ref) => (
|
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
|
||||||
|
return (
|
||||||
<ContextMenuPrimitive.SubContent
|
<ContextMenuPrimitive.SubContent
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
"z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
)
|
||||||
|
}
|
||||||
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
|
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
|
||||||
|
|
||||||
const ContextMenuContent = React.forwardRef<
|
function ContextMenuContent({
|
||||||
React.ElementRef<typeof ContextMenuPrimitive.Content>,
|
className,
|
||||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
|
...props
|
||||||
>(({ className, ...props }, ref) => (
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
<ContextMenuPrimitive.Portal>
|
<ContextMenuPrimitive.Portal>
|
||||||
<ContextMenuPrimitive.Content
|
<ContextMenuPrimitive.Content
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
"z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</ContextMenuPrimitive.Portal>
|
</ContextMenuPrimitive.Portal>
|
||||||
))
|
)
|
||||||
|
}
|
||||||
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
|
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
|
||||||
|
|
||||||
const ContextMenuItem = React.forwardRef<
|
function ContextMenuItem({
|
||||||
React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
className,
|
||||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
|
||||||
inset?: boolean
|
inset?: boolean
|
||||||
}
|
}) {
|
||||||
>(({ className, inset, ...props }, ref) => (
|
return (
|
||||||
<ContextMenuPrimitive.Item
|
<ContextMenuPrimitive.Item
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
|
||||||
inset && "pl-8",
|
inset && "pl-8",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
)
|
||||||
|
}
|
||||||
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
|
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
|
||||||
|
|
||||||
const ContextMenuCheckboxItem = React.forwardRef<
|
function ContextMenuCheckboxItem({
|
||||||
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
|
className,
|
||||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
|
children,
|
||||||
>(({ className, children, checked, ...props }, ref) => (
|
checked,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
|
||||||
|
return (
|
||||||
<ContextMenuPrimitive.CheckboxItem
|
<ContextMenuPrimitive.CheckboxItem
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
@@ -107,18 +116,20 @@ const ContextMenuCheckboxItem = React.forwardRef<
|
|||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
</ContextMenuPrimitive.CheckboxItem>
|
</ContextMenuPrimitive.CheckboxItem>
|
||||||
))
|
)
|
||||||
|
}
|
||||||
ContextMenuCheckboxItem.displayName =
|
ContextMenuCheckboxItem.displayName =
|
||||||
ContextMenuPrimitive.CheckboxItem.displayName
|
ContextMenuPrimitive.CheckboxItem.displayName
|
||||||
|
|
||||||
const ContextMenuRadioItem = React.forwardRef<
|
function ContextMenuRadioItem({
|
||||||
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
|
className,
|
||||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
|
children,
|
||||||
>(({ className, children, ...props }, ref) => (
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
|
||||||
|
return (
|
||||||
<ContextMenuPrimitive.RadioItem
|
<ContextMenuPrimitive.RadioItem
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -130,17 +141,19 @@ const ContextMenuRadioItem = React.forwardRef<
|
|||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
</ContextMenuPrimitive.RadioItem>
|
</ContextMenuPrimitive.RadioItem>
|
||||||
))
|
)
|
||||||
|
}
|
||||||
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
|
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
|
||||||
|
|
||||||
const ContextMenuLabel = React.forwardRef<
|
function ContextMenuLabel({
|
||||||
React.ElementRef<typeof ContextMenuPrimitive.Label>,
|
className,
|
||||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
|
||||||
inset?: boolean
|
inset?: boolean
|
||||||
}
|
}) {
|
||||||
>(({ className, inset, ...props }, ref) => (
|
return (
|
||||||
<ContextMenuPrimitive.Label
|
<ContextMenuPrimitive.Label
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-2 py-1.5 text-sm font-semibold text-foreground",
|
"px-2 py-1.5 text-sm font-semibold text-foreground",
|
||||||
inset && "pl-8",
|
inset && "pl-8",
|
||||||
@@ -148,19 +161,21 @@ const ContextMenuLabel = React.forwardRef<
|
|||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
)
|
||||||
|
}
|
||||||
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
|
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
|
||||||
|
|
||||||
const ContextMenuSeparator = React.forwardRef<
|
function ContextMenuSeparator({
|
||||||
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
|
className,
|
||||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
|
...props
|
||||||
>(({ className, ...props }, ref) => (
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
<ContextMenuPrimitive.Separator
|
<ContextMenuPrimitive.Separator
|
||||||
ref={ref}
|
|
||||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
)
|
||||||
|
}
|
||||||
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
|
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
|
||||||
|
|
||||||
const ContextMenuShortcut = ({
|
const ContextMenuShortcut = ({
|
||||||
|
|||||||
@@ -12,29 +12,31 @@ const DialogPortal = DialogPrimitive.Portal
|
|||||||
|
|
||||||
const DialogClose = DialogPrimitive.Close
|
const DialogClose = DialogPrimitive.Close
|
||||||
|
|
||||||
const DialogOverlay = React.forwardRef<
|
function DialogOverlay({
|
||||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
className,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
...props
|
||||||
>(({ className, ...props }, ref) => (
|
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
)
|
||||||
|
}
|
||||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
const DialogContent = React.forwardRef<
|
function DialogContent({
|
||||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
className,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
children,
|
||||||
>(({ className, children, ...props }, ref) => (
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
|
||||||
|
return (
|
||||||
<DialogPortal>
|
<DialogPortal>
|
||||||
<DialogOverlay />
|
<DialogOverlay />
|
||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||||
className
|
className
|
||||||
@@ -42,13 +44,14 @@ const DialogContent = React.forwardRef<
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
))
|
)
|
||||||
|
}
|
||||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||||
|
|
||||||
const DialogHeader = ({
|
const DialogHeader = ({
|
||||||
@@ -79,31 +82,33 @@ const DialogFooter = ({
|
|||||||
)
|
)
|
||||||
DialogFooter.displayName = "DialogFooter"
|
DialogFooter.displayName = "DialogFooter"
|
||||||
|
|
||||||
const DialogTitle = React.forwardRef<
|
function DialogTitle({
|
||||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
className,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
...props
|
||||||
>(({ className, ...props }, ref) => (
|
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
<DialogPrimitive.Title
|
<DialogPrimitive.Title
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-lg font-semibold leading-none tracking-tight",
|
"text-lg font-semibold leading-none tracking-tight",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
)
|
||||||
|
}
|
||||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||||
|
|
||||||
const DialogDescription = React.forwardRef<
|
function DialogDescription({
|
||||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
className,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
...props
|
||||||
>(({ className, ...props }, ref) => (
|
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
<DialogPrimitive.Description
|
<DialogPrimitive.Description
|
||||||
ref={ref}
|
|
||||||
className={cn("text-sm text-muted-foreground", className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
)
|
||||||
|
}
|
||||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -20,26 +20,28 @@ const DrawerPortal = DrawerPrimitive.Portal
|
|||||||
|
|
||||||
const DrawerClose = DrawerPrimitive.Close
|
const DrawerClose = DrawerPrimitive.Close
|
||||||
|
|
||||||
const DrawerOverlay = React.forwardRef<
|
function DrawerOverlay({
|
||||||
React.ElementRef<typeof DrawerPrimitive.Overlay>,
|
className,
|
||||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
|
...props
|
||||||
>(({ className, ...props }, ref) => (
|
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
<DrawerPrimitive.Overlay
|
<DrawerPrimitive.Overlay
|
||||||
ref={ref}
|
|
||||||
className={cn("fixed inset-0 z-50 bg-black/80", className)}
|
className={cn("fixed inset-0 z-50 bg-black/80", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
)
|
||||||
|
}
|
||||||
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
|
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
|
||||||
|
|
||||||
const DrawerContent = React.forwardRef<
|
function DrawerContent({
|
||||||
React.ElementRef<typeof DrawerPrimitive.Content>,
|
className,
|
||||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
|
children,
|
||||||
>(({ className, children, ...props }, ref) => (
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
|
||||||
|
return (
|
||||||
<DrawerPortal>
|
<DrawerPortal>
|
||||||
<DrawerOverlay />
|
<DrawerOverlay />
|
||||||
<DrawerPrimitive.Content
|
<DrawerPrimitive.Content
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
|
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
|
||||||
className
|
className
|
||||||
@@ -50,7 +52,8 @@ const DrawerContent = React.forwardRef<
|
|||||||
{children}
|
{children}
|
||||||
</DrawerPrimitive.Content>
|
</DrawerPrimitive.Content>
|
||||||
</DrawerPortal>
|
</DrawerPortal>
|
||||||
))
|
)
|
||||||
|
}
|
||||||
DrawerContent.displayName = "DrawerContent"
|
DrawerContent.displayName = "DrawerContent"
|
||||||
|
|
||||||
const DrawerHeader = ({
|
const DrawerHeader = ({
|
||||||
@@ -75,31 +78,33 @@ const DrawerFooter = ({
|
|||||||
)
|
)
|
||||||
DrawerFooter.displayName = "DrawerFooter"
|
DrawerFooter.displayName = "DrawerFooter"
|
||||||
|
|
||||||
const DrawerTitle = React.forwardRef<
|
function DrawerTitle({
|
||||||
React.ElementRef<typeof DrawerPrimitive.Title>,
|
className,
|
||||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
|
...props
|
||||||
>(({ className, ...props }, ref) => (
|
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||||
|
return (
|
||||||
<DrawerPrimitive.Title
|
<DrawerPrimitive.Title
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-lg font-semibold leading-none tracking-tight",
|
"text-lg font-semibold leading-none tracking-tight",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
)
|
||||||
|
}
|
||||||
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
|
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
|
||||||
|
|
||||||
const DrawerDescription = React.forwardRef<
|
function DrawerDescription({
|
||||||
React.ElementRef<typeof DrawerPrimitive.Description>,
|
className,
|
||||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
|
...props
|
||||||
>(({ className, ...props }, ref) => (
|
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||||
|
return (
|
||||||
<DrawerPrimitive.Description
|
<DrawerPrimitive.Description
|
||||||
ref={ref}
|
|
||||||
className={cn("text-sm text-muted-foreground", className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
)
|
||||||
|
}
|
||||||
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
|
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -70,46 +70,43 @@ const FormItemContext = React.createContext<FormItemContextValue>(
|
|||||||
{} as FormItemContextValue
|
{} as FormItemContextValue
|
||||||
)
|
)
|
||||||
|
|
||||||
const FormItem = React.forwardRef<
|
function FormItem({
|
||||||
HTMLDivElement,
|
className,
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
...props
|
||||||
>(({ className, ...props }, ref) => {
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
const id = React.useId()
|
const id = React.useId()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormItemContext.Provider value={{ id }}>
|
<FormItemContext.Provider value={{ id }}>
|
||||||
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
<div className={cn("space-y-2", className)} {...props} />
|
||||||
</FormItemContext.Provider>
|
</FormItemContext.Provider>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
FormItem.displayName = "FormItem"
|
FormItem.displayName = "FormItem"
|
||||||
|
|
||||||
const FormLabel = React.forwardRef<
|
function FormLabel({
|
||||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
className,
|
||||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
...props
|
||||||
>(({ className, ...props }, ref) => {
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
const { error, formItemId } = useFormField()
|
const { error, formItemId } = useFormField()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Label
|
<Label
|
||||||
ref={ref}
|
|
||||||
className={cn(error && "text-destructive", className)}
|
className={cn(error && "text-destructive", className)}
|
||||||
htmlFor={formItemId}
|
htmlFor={formItemId}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
FormLabel.displayName = "FormLabel"
|
FormLabel.displayName = "FormLabel"
|
||||||
|
|
||||||
const FormControl = React.forwardRef<
|
function FormControl({
|
||||||
React.ElementRef<typeof Slot>,
|
...props
|
||||||
React.ComponentPropsWithoutRef<typeof Slot>
|
}: React.ComponentProps<typeof Slot>) {
|
||||||
>(({ ...props }, ref) => {
|
|
||||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Slot
|
<Slot
|
||||||
ref={ref}
|
|
||||||
id={formItemId}
|
id={formItemId}
|
||||||
aria-describedby={
|
aria-describedby={
|
||||||
!error
|
!error
|
||||||
@@ -120,30 +117,30 @@ const FormControl = React.forwardRef<
|
|||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
FormControl.displayName = "FormControl"
|
FormControl.displayName = "FormControl"
|
||||||
|
|
||||||
const FormDescription = React.forwardRef<
|
function FormDescription({
|
||||||
HTMLParagraphElement,
|
className,
|
||||||
React.HTMLAttributes<HTMLParagraphElement>
|
...props
|
||||||
>(({ className, ...props }, ref) => {
|
}: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||||
const { formDescriptionId } = useFormField()
|
const { formDescriptionId } = useFormField()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<p
|
<p
|
||||||
ref={ref}
|
|
||||||
id={formDescriptionId}
|
id={formDescriptionId}
|
||||||
className={cn("text-sm text-muted-foreground", className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
FormDescription.displayName = "FormDescription"
|
FormDescription.displayName = "FormDescription"
|
||||||
|
|
||||||
const FormMessage = React.forwardRef<
|
function FormMessage({
|
||||||
HTMLParagraphElement,
|
className,
|
||||||
React.HTMLAttributes<HTMLParagraphElement>
|
children,
|
||||||
>(({ className, children, ...props }, ref) => {
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||||
const { error, formMessageId } = useFormField()
|
const { error, formMessageId } = useFormField()
|
||||||
const body = error ? String(error?.message) : children
|
const body = error ? String(error?.message) : children
|
||||||
|
|
||||||
@@ -153,7 +150,6 @@ const FormMessage = React.forwardRef<
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<p
|
<p
|
||||||
ref={ref}
|
|
||||||
id={formMessageId}
|
id={formMessageId}
|
||||||
className={cn("text-sm font-medium text-destructive", className)}
|
className={cn("text-sm font-medium text-destructive", className)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -161,7 +157,7 @@ const FormMessage = React.forwardRef<
|
|||||||
{body}
|
{body}
|
||||||
</p>
|
</p>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
FormMessage.displayName = "FormMessage"
|
FormMessage.displayName = "FormMessage"
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -5,21 +5,22 @@ import { cn } from "@/lib/utils"
|
|||||||
export interface InputProps
|
export interface InputProps
|
||||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||||
|
|
||||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
function Input({
|
||||||
({ className, type, ...props }, ref) => {
|
className,
|
||||||
|
type,
|
||||||
|
...props
|
||||||
|
}: InputProps) {
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
|
||||||
Input.displayName = "Input"
|
Input.displayName = "Input"
|
||||||
|
|
||||||
export { Input }
|
export { Input }
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva } from "class-variance-authority"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
@@ -8,17 +8,17 @@ const labelVariants = cva(
|
|||||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
)
|
)
|
||||||
|
|
||||||
const Label = React.forwardRef<
|
function Label({
|
||||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
className,
|
||||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
...props
|
||||||
VariantProps<typeof labelVariants>
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
>(({ className, ...props }, ref) => (
|
return (
|
||||||
<LabelPrimitive.Root
|
<LabelPrimitive.Root
|
||||||
ref={ref}
|
|
||||||
className={cn(labelVariants(), className)}
|
className={cn(labelVariants(), className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
)
|
||||||
|
}
|
||||||
Label.displayName = LabelPrimitive.Root.displayName
|
Label.displayName = LabelPrimitive.Root.displayName
|
||||||
|
|
||||||
export { Label }
|
export { Label }
|
||||||
|
|||||||
@@ -3,27 +3,25 @@ import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const Separator = React.forwardRef<
|
function Separator({
|
||||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
className,
|
||||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
orientation = "horizontal",
|
||||||
>(
|
decorative = true,
|
||||||
(
|
...props
|
||||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||||
ref
|
return (
|
||||||
) => (
|
|
||||||
<SeparatorPrimitive.Root
|
<SeparatorPrimitive.Root
|
||||||
ref={ref}
|
|
||||||
decorative={decorative}
|
decorative={decorative}
|
||||||
orientation={orientation}
|
orientation={orientation}
|
||||||
className={cn(
|
className={cn(
|
||||||
"shrink-0 bg-border",
|
"shrink-0 bg-border",
|
||||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
orientation === "horizontal" ? "h-px w-full" : "h-full w-px",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||||
|
|
||||||
export { Separator }
|
export { Separator }
|
||||||
|
|||||||
@@ -7,23 +7,24 @@ import { cn } from "@/lib/utils"
|
|||||||
|
|
||||||
const ToastProvider = ToastPrimitives.Provider
|
const ToastProvider = ToastPrimitives.Provider
|
||||||
|
|
||||||
const ToastViewport = React.forwardRef<
|
function ToastViewport({
|
||||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
className,
|
||||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
...props
|
||||||
>(({ className, ...props }, ref) => (
|
}: React.ComponentProps<typeof ToastPrimitives.Viewport>) {
|
||||||
|
return (
|
||||||
<ToastPrimitives.Viewport
|
<ToastPrimitives.Viewport
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
"fixed top-0 z-100 flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
)
|
||||||
|
}
|
||||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||||
|
|
||||||
const toastVariants = cva(
|
const toastVariants = cva(
|
||||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-(--radix-toast-swipe-end-x) data-[swipe=move]:translate-x-(--radix-toast-swipe-move-x) data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
@@ -38,44 +39,44 @@ const toastVariants = cva(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const Toast = React.forwardRef<
|
function Toast({
|
||||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
className,
|
||||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
variant,
|
||||||
VariantProps<typeof toastVariants>
|
...props
|
||||||
>(({ className, variant, ...props }, ref) => {
|
}: React.ComponentProps<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>) {
|
||||||
return (
|
return (
|
||||||
<ToastPrimitives.Root
|
<ToastPrimitives.Root
|
||||||
ref={ref}
|
|
||||||
className={cn(toastVariants({ variant }), className)}
|
className={cn(toastVariants({ variant }), className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
Toast.displayName = ToastPrimitives.Root.displayName
|
Toast.displayName = ToastPrimitives.Root.displayName
|
||||||
|
|
||||||
const ToastAction = React.forwardRef<
|
function ToastAction({
|
||||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
className,
|
||||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
...props
|
||||||
>(({ className, ...props }, ref) => (
|
}: React.ComponentProps<typeof ToastPrimitives.Action>) {
|
||||||
|
return (
|
||||||
<ToastPrimitives.Action
|
<ToastPrimitives.Action
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 hover:group-[.destructive]:border-destructive/30 hover:group-[.destructive]:bg-destructive hover:group-[.destructive]:text-destructive-foreground focus:group-[.destructive]:ring-destructive",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
)
|
||||||
|
}
|
||||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||||
|
|
||||||
const ToastClose = React.forwardRef<
|
function ToastClose({
|
||||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
className,
|
||||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
...props
|
||||||
>(({ className, ...props }, ref) => (
|
}: React.ComponentProps<typeof ToastPrimitives.Close>) {
|
||||||
|
return (
|
||||||
<ToastPrimitives.Close
|
<ToastPrimitives.Close
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-hidden focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 hover:group-[.destructive]:text-red-50 focus:group-[.destructive]:ring-red-400 focus:group-[.destructive]:ring-offset-red-600",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
toast-close=""
|
toast-close=""
|
||||||
@@ -83,31 +84,34 @@ const ToastClose = React.forwardRef<
|
|||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</ToastPrimitives.Close>
|
</ToastPrimitives.Close>
|
||||||
))
|
)
|
||||||
|
}
|
||||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||||
|
|
||||||
const ToastTitle = React.forwardRef<
|
function ToastTitle({
|
||||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
className,
|
||||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
...props
|
||||||
>(({ className, ...props }, ref) => (
|
}: React.ComponentProps<typeof ToastPrimitives.Title>) {
|
||||||
|
return (
|
||||||
<ToastPrimitives.Title
|
<ToastPrimitives.Title
|
||||||
ref={ref}
|
|
||||||
className={cn("text-sm font-semibold", className)}
|
className={cn("text-sm font-semibold", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
)
|
||||||
|
}
|
||||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||||
|
|
||||||
const ToastDescription = React.forwardRef<
|
function ToastDescription({
|
||||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
className,
|
||||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
...props
|
||||||
>(({ className, ...props }, ref) => (
|
}: React.ComponentProps<typeof ToastPrimitives.Description>) {
|
||||||
|
return (
|
||||||
<ToastPrimitives.Description
|
<ToastPrimitives.Description
|
||||||
ref={ref}
|
|
||||||
className={cn("text-sm opacity-90", className)}
|
className={cn("text-sm opacity-90", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
)
|
||||||
|
}
|
||||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||||
|
|
||||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||||
|
|||||||
@@ -9,12 +9,13 @@ const Tooltip = TooltipPrimitive.Root
|
|||||||
|
|
||||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||||
|
|
||||||
const TooltipContent = React.forwardRef<
|
function TooltipContent({
|
||||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
className,
|
||||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
sideOffset = 4,
|
||||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||||
|
return (
|
||||||
<TooltipPrimitive.Content
|
<TooltipPrimitive.Content
|
||||||
ref={ref}
|
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
@@ -22,7 +23,8 @@ const TooltipContent = React.forwardRef<
|
|||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
)
|
||||||
|
}
|
||||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||||
|
|
||||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||||
|
|||||||
+136
-55
@@ -1,62 +1,143 @@
|
|||||||
@tailwind base;
|
@import 'tailwindcss';
|
||||||
@tailwind components;
|
@import 'tw-animate-css';
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
@utility container {
|
||||||
|
margin-inline: auto;
|
||||||
|
padding-inline: 2rem;
|
||||||
|
@media (width >= --theme(--breakpoint-sm)) {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
@media (width >= 1400px) {
|
||||||
|
max-width: 1400px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
|
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
|
||||||
|
--animate-accordion-down: accordion-down 0.2s ease-out;
|
||||||
|
--animate-accordion-up: accordion-up 0.2s ease-out;
|
||||||
|
|
||||||
|
@keyframes accordion-down {
|
||||||
|
from {
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
height: var(--radix-accordion-content-height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes accordion-up {
|
||||||
|
from {
|
||||||
|
height: var(--radix-accordion-content-height);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
||||||
|
so we've added these compatibility styles to make sure everything still
|
||||||
|
looks the same as it did with Tailwind CSS v3.
|
||||||
|
|
||||||
|
If we ever want to remove these styles, we need to add an explicit border
|
||||||
|
color utility to any element that depends on these defaults.
|
||||||
|
*/
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
*,
|
||||||
--background: 0 0% 100%;
|
::after,
|
||||||
--foreground: 222.2 84% 4.9%;
|
::before,
|
||||||
--card: 0 0% 100%;
|
::backdrop,
|
||||||
--card-foreground: 222.2 84% 4.9%;
|
::file-selector-button {
|
||||||
--popover: 0 0% 100%;
|
border-color: var(--color-gray-200, currentcolor);
|
||||||
--popover-foreground: 222.2 84% 4.9%;
|
|
||||||
--primary: 222.2 47.4% 11.2%;
|
|
||||||
--primary-foreground: 210 40% 98%;
|
|
||||||
--secondary: 210 40% 96.1%;
|
|
||||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
|
||||||
--muted: 210 40% 96.1%;
|
|
||||||
--muted-foreground: 215.4 16.3% 46.9%;
|
|
||||||
--accent: 210 40% 96.1%;
|
|
||||||
--accent-foreground: 222.2 47.4% 11.2%;
|
|
||||||
--destructive: 0 84.2% 60.2%;
|
|
||||||
--destructive-foreground: 210 40% 98%;
|
|
||||||
--border: 214.3 31.8% 91.4%;
|
|
||||||
--input: 214.3 31.8% 91.4%;
|
|
||||||
--ring: 222.2 84% 4.9%;
|
|
||||||
--radius: 0.5rem;
|
|
||||||
--chart-1: 12 76% 61%;
|
|
||||||
--chart-2: 173 58% 39%;
|
|
||||||
--chart-3: 197 37% 24%;
|
|
||||||
--chart-4: 43 74% 66%;
|
|
||||||
--chart-5: 27 87% 67%;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.dark {
|
:root {
|
||||||
--background: 222.2 84% 4.9%;
|
--background: hsl(0 0% 100%);
|
||||||
--foreground: 210 40% 98%;
|
--foreground: hsl(222.2 84% 4.9%);
|
||||||
--card: 222.2 84% 4.9%;
|
--card: hsl(0 0% 100%);
|
||||||
--card-foreground: 210 40% 98%;
|
--card-foreground: hsl(222.2 84% 4.9%);
|
||||||
--popover: 222.2 84% 4.9%;
|
--popover: hsl(0 0% 100%);
|
||||||
--popover-foreground: 210 40% 98%;
|
--popover-foreground: hsl(222.2 84% 4.9%);
|
||||||
--primary: 210 40% 98%;
|
--primary: hsl(222.2 47.4% 11.2%);
|
||||||
--primary-foreground: 222.2 47.4% 11.2%;
|
--primary-foreground: hsl(210 40% 98%);
|
||||||
--secondary: 217.2 32.6% 17.5%;
|
--secondary: hsl(210 40% 96.1%);
|
||||||
--secondary-foreground: 210 40% 98%;
|
--secondary-foreground: hsl(222.2 47.4% 11.2%);
|
||||||
--muted: 217.2 32.6% 17.5%;
|
--muted: hsl(210 40% 96.1%);
|
||||||
--muted-foreground: 215 20.2% 65.1%;
|
--muted-foreground: hsl(215.4 16.3% 46.9%);
|
||||||
--accent: 217.2 32.6% 17.5%;
|
--accent: hsl(210 40% 96.1%);
|
||||||
--accent-foreground: 210 40% 98%;
|
--accent-foreground: hsl(222.2 47.4% 11.2%);
|
||||||
--destructive: 0 62.8% 30.6%;
|
--destructive: hsl(0 84.2% 60.2%);
|
||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: hsl(210 40% 98%);
|
||||||
--border: 217.2 32.6% 17.5%;
|
--border: hsl(214.3 31.8% 91.4%);
|
||||||
--input: 217.2 32.6% 17.5%;
|
--input: hsl(214.3 31.8% 91.4%);
|
||||||
--ring: 212.7 26.8% 83.9%;
|
--ring: hsl(222.2 84% 4.9%);
|
||||||
--chart-1: 220 70% 50%;
|
--radius: 0.5rem;
|
||||||
--chart-2: 160 60% 45%;
|
--chart-1: hsl(12 76% 61%);
|
||||||
--chart-3: 30 80% 55%;
|
--chart-2: hsl(173 58% 39%);
|
||||||
--chart-4: 280 65% 60%;
|
--chart-3: hsl(197 37% 24%);
|
||||||
--chart-5: 340 75% 55%;
|
--chart-4: hsl(43 74% 66%);
|
||||||
}
|
--chart-5: hsl(27 87% 67%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: hsl(222.2 84% 4.9%);
|
||||||
|
--foreground: hsl(210 40% 98%);
|
||||||
|
--card: hsl(222.2 84% 4.9%);
|
||||||
|
--card-foreground: hsl(210 40% 98%);
|
||||||
|
--popover: hsl(222.2 84% 4.9%);
|
||||||
|
--popover-foreground: hsl(210 40% 98%);
|
||||||
|
--primary: hsl(210 40% 98%);
|
||||||
|
--primary-foreground: hsl(222.2 47.4% 11.2%);
|
||||||
|
--secondary: hsl(217.2 32.6% 17.5%);
|
||||||
|
--secondary-foreground: hsl(210 40% 98%);
|
||||||
|
--muted: hsl(217.2 32.6% 17.5%);
|
||||||
|
--muted-foreground: hsl(215 20.2% 65.1%);
|
||||||
|
--accent: hsl(217.2 32.6% 17.5%);
|
||||||
|
--accent-foreground: hsl(210 40% 98%);
|
||||||
|
--destructive: hsl(0 62.8% 30.6%);
|
||||||
|
--destructive-foreground: hsl(210 40% 98%);
|
||||||
|
--border: hsl(217.2 32.6% 17.5%);
|
||||||
|
--input: hsl(217.2 32.6% 17.5%);
|
||||||
|
--ring: hsl(212.7 26.8% 83.9%);
|
||||||
|
--chart-1: hsl(220 70% 50%);
|
||||||
|
--chart-2: hsl(160 60% 45%);
|
||||||
|
--chart-3: hsl(30 80% 55%);
|
||||||
|
--chart-4: hsl(280 65% 60%);
|
||||||
|
--chart-5: hsl(340 75% 55%);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
|||||||
@@ -46,11 +46,17 @@ const router = createHashRouter([
|
|||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
function App() {
|
||||||
<StrictMode>
|
return (
|
||||||
<ThemeProvider defaultTheme="system" storageKey="vite-ui-theme">
|
<ThemeProvider defaultTheme="system" storageKey="vite-ui-theme">
|
||||||
<RouterProvider router={router}/>
|
<RouterProvider router={router}/>
|
||||||
<Toaster/>
|
<Toaster/>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App/>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ function CardList(props: { cards: ReadonlyArray<AimeCard> | null, cardSelected:
|
|||||||
return (<>
|
return (<>
|
||||||
<h4 className="text-2xl font-bold">{props.cards?.length ? "Cards" : "Additional Cards"}</h4>
|
<h4 className="text-2xl font-bold">{props.cards?.length ? "Cards" : "Additional Cards"}</h4>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<CardCreationDialog className="flex-grow"/>
|
<CardCreationDialog className="grow"/>
|
||||||
<Button onClick={() => navigate('/cards')} variant="outline" size="icon">
|
<Button onClick={() => navigate('/cards')} variant="outline" size="icon">
|
||||||
<Wallet className="h-4 w-4"/>
|
<Wallet className="h-4 w-4"/>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -187,7 +187,7 @@ function ServerCardInPage() {
|
|||||||
<AlertPanel server={server} connectionError={connectionError} onReloadRequested={() => setServerReloadCounter(serverReloadCounter + 1)}/>
|
<AlertPanel server={server} connectionError={connectionError} onReloadRequested={() => setServerReloadCounter(serverReloadCounter + 1)}/>
|
||||||
|
|
||||||
{server && (
|
{server && (
|
||||||
<div className="flex flex-col gap-4 w-full flex-grow">
|
<div className="flex flex-col gap-4 w-full grow">
|
||||||
{selectedCard && (<>
|
{selectedCard && (<>
|
||||||
<h4 className="text-2xl font-bold">{gameName ? `Play ${gameName}` : "Selected Card"}</h4>
|
<h4 className="text-2xl font-bold">{gameName ? `Play ${gameName}` : "Selected Card"}</h4>
|
||||||
|
|
||||||
@@ -197,7 +197,7 @@ function ServerCardInPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</AimeCardEntry>
|
</AimeCardEntry>
|
||||||
|
|
||||||
<Button className="flex-grow-0 bg-green-600 mb-5"
|
<Button className="grow-0 bg-green-600 mb-5"
|
||||||
disabled={(cardInStatus || connectionError || !server)}
|
disabled={(cardInStatus || connectionError || !server)}
|
||||||
onClick={() => performCardInAction(selectedCard, server as AimeServer, setCardInStatus, toast)}>
|
onClick={() => performCardInAction(selectedCard, server as AimeServer, setCardInStatus, toast)}>
|
||||||
{cardInStatus ? "Carding in..." : <>Card In <Nfc className="ml-2 h-4 w-4"/></>}
|
{cardInStatus ? "Carding in..." : <>Card In <Nfc className="ml-2 h-4 w-4"/></>}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ function CardManagerPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</AMHeader>
|
</AMHeader>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 w-full flex-grow">
|
<div className="flex flex-col gap-4 w-full grow">
|
||||||
{cards ? <CardList cards={cards}/> : <LoadingIndicator/>}
|
{cards ? <CardList cards={cards}/> : <LoadingIndicator/>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ function PrivacyPolicy() {
|
|||||||
</Button>
|
</Button>
|
||||||
</AMHeader>
|
</AMHeader>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 w-full flex-grow">
|
<div className="flex flex-col gap-4 w-full grow">
|
||||||
<h4 className="font-semibold text-2xl">Privacy</h4>
|
<h4 className="font-semibold text-2xl">Privacy</h4>
|
||||||
<p>The AMNet card switcher collects and stores the following information locally (on-device):</p>
|
<p>The AMNet card switcher collects and stores the following information locally (on-device):</p>
|
||||||
<ul className="list-disc">
|
<ul className="list-disc">
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ function ServerListing() {
|
|||||||
</Button>
|
</Button>
|
||||||
</AMHeader>
|
</AMHeader>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 w-full flex-grow">
|
<div className="flex flex-col gap-4 w-full grow">
|
||||||
<ServerList servers={servers}/>
|
<ServerList servers={servers}/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -172,7 +172,7 @@ function ServerAddForm() {
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="address"
|
name="address"
|
||||||
render={({field}) => (
|
render={({field}) => (
|
||||||
<FormItem className="flex-grow">
|
<FormItem className="grow">
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field}
|
<Input {...field}
|
||||||
placeholder="Server Address (http://192.168.1.1:6070)"
|
placeholder="Server Address (http://192.168.1.1:6070)"
|
||||||
@@ -183,7 +183,7 @@ function ServerAddForm() {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<Button type="submit" disabled={blockSubmit} variant="outline" size="icon"
|
<Button type="submit" disabled={blockSubmit} variant="outline" size="icon"
|
||||||
className="flex-shrink-0">
|
className="shrink-0">
|
||||||
<Plus className="h-4 w-4"/>
|
<Plus className="h-4 w-4"/>
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
module.exports = {
|
|
||||||
darkMode: ["class"],
|
|
||||||
content: [
|
|
||||||
'./src/**/*.{ts,tsx}',
|
|
||||||
],
|
|
||||||
prefix: "",
|
|
||||||
theme: {
|
|
||||||
container: {
|
|
||||||
center: true,
|
|
||||||
padding: "2rem",
|
|
||||||
screens: {
|
|
||||||
"2xl": "1400px",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
border: "hsl(var(--border))",
|
|
||||||
input: "hsl(var(--input))",
|
|
||||||
ring: "hsl(var(--ring))",
|
|
||||||
background: "hsl(var(--background))",
|
|
||||||
foreground: "hsl(var(--foreground))",
|
|
||||||
primary: {
|
|
||||||
DEFAULT: "hsl(var(--primary))",
|
|
||||||
foreground: "hsl(var(--primary-foreground))",
|
|
||||||
},
|
|
||||||
secondary: {
|
|
||||||
DEFAULT: "hsl(var(--secondary))",
|
|
||||||
foreground: "hsl(var(--secondary-foreground))",
|
|
||||||
},
|
|
||||||
destructive: {
|
|
||||||
DEFAULT: "hsl(var(--destructive))",
|
|
||||||
foreground: "hsl(var(--destructive-foreground))",
|
|
||||||
},
|
|
||||||
muted: {
|
|
||||||
DEFAULT: "hsl(var(--muted))",
|
|
||||||
foreground: "hsl(var(--muted-foreground))",
|
|
||||||
},
|
|
||||||
accent: {
|
|
||||||
DEFAULT: "hsl(var(--accent))",
|
|
||||||
foreground: "hsl(var(--accent-foreground))",
|
|
||||||
},
|
|
||||||
popover: {
|
|
||||||
DEFAULT: "hsl(var(--popover))",
|
|
||||||
foreground: "hsl(var(--popover-foreground))",
|
|
||||||
},
|
|
||||||
card: {
|
|
||||||
DEFAULT: "hsl(var(--card))",
|
|
||||||
foreground: "hsl(var(--card-foreground))",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
borderRadius: {
|
|
||||||
lg: "var(--radius)",
|
|
||||||
md: "calc(var(--radius) - 2px)",
|
|
||||||
sm: "calc(var(--radius) - 4px)",
|
|
||||||
},
|
|
||||||
keyframes: {
|
|
||||||
"accordion-down": {
|
|
||||||
from: { height: "0" },
|
|
||||||
to: { height: "var(--radix-accordion-content-height)" },
|
|
||||||
},
|
|
||||||
"accordion-up": {
|
|
||||||
from: { height: "var(--radix-accordion-content-height)" },
|
|
||||||
to: { height: "0" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
animation: {
|
|
||||||
"accordion-down": "accordion-down 0.2s ease-out",
|
|
||||||
"accordion-up": "accordion-up 0.2s ease-out",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [require("tailwindcss-animate")],
|
|
||||||
}
|
|
||||||
@@ -1,68 +1,104 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
# AMNet
|
# AMNet
|
||||||
[TestFlight](https://testflight.apple.com/join/kHbeJTsQ) • [Website](http://card.ppc.moe) • [Server DLLs](https://gitea.tendokyu.moe/ppc/amnet/releases)
|
[Latest .ipa](https://amnet-dist.ppc.moe/amnet-1.3.9.ipa) • [Firebase](https://appdistribution.firebase.dev/i/6acc3151357deaa8) • [Website](http://card.ppc.moe) • [Server DLLs](https://gitea.tendokyu.moe/ppc/amnet/releases)
|
||||||
|
|
||||||
An Aime Card Switcher for segatools-based games.
|
An Amusement IC Card Switcher for segatools-based games.
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
AMNet is designed to simulate the `DEVICE/aime.txt` file, allowing multiple users to log into a game using their phone.
|
AMNet is designed to acts as dynamic version of the `DEVICE/aime.txt` file, allowing multiple users to log into a game using their own mobile devices.
|
||||||
This was originally designed for use with maimai/Chunithm, but should be compatible with any segatools-based game with Aime support.
|
This was originally intended for use with maimai and CHUNITHM, but should be compatible with any segatools-based game with that uses Amusement IC for account management.
|
||||||
|
|
||||||
### Setup
|
> [!WARNING]
|
||||||
|
> TestFlight is no longer available and must be sideloaded using AltStore or similar tooling for the forseeable future.
|
||||||
|
|
||||||
|
## Setup
|
||||||
1. Download the latest `amnet.dll` from the releases page
|
1. Download the latest `amnet.dll` from the releases page
|
||||||
2. Move `amnet.dll` to the game directory
|
2. Move `amnet.dll` to the game directory
|
||||||
3. Edit `segatools.ini` to use the new DLL:
|
3. Edit `segatools.ini`, replacing the existing [aimeio] section with this one (read all the comments and change settings as needed):
|
||||||
```ini
|
```ini
|
||||||
[aimeio]
|
[aimeio]
|
||||||
; To use a custom card reader IO DLL enter its path here.
|
; To use a custom card reader IO DLL enter its path here.
|
||||||
; Leave empty if you want to use Segatools built-in keyboard input.
|
; Leave empty if you want to use Segatools built-in keyboard input.
|
||||||
path=amnet.dll
|
path=amnet.dll
|
||||||
|
|
||||||
; AMNet-specific options (all optional)
|
; AMNet-specific options (all optional)
|
||||||
; gameId - the 4 character game ID (will show the game icon in the UI)
|
; gameId - the 4 character game ID (will show the game icon in the UI)
|
||||||
; serverName - the name of the server to show to users (defaults to the computer name if not set)
|
; serverName - the name of the server to show to users (defaults to the computer name if not set)
|
||||||
; serverAddress - the address to listen on, including the protocol (defaults to http://+:6070, where + is a wildcard)
|
; serverAddress - the address to listen on, including the protocol (don't set/change unless you know what you're doing)
|
||||||
; enableKeyboardMode - whether to enable hold-enter-to-login mode. Reads the file set at aimePath for card number (or DEVICE/aime.txt if not set).
|
; enableKeyboardMode - whether to enable hold-enter-to-login mode. Reads the file set at aimePath for card number (or DEVICE/aime.txt if not set).
|
||||||
gameId=SDEZ
|
; useAimeDBForPhysicalCards - Whether codes from real cards should use AiMeDB to retrieve access codes. If the game is using a hosted network, set this to 1 to load the same account data/profile as you would get on a physical cab.
|
||||||
;serverName=CHUNI-PENGUIN
|
gameId=SDEZ
|
||||||
;serverAddress=http://+:6070
|
;serverName=CHUNI-PENGUIN
|
||||||
;enableKeyboardMode=1
|
;serverAddress=http://0.0.0.0:6070
|
||||||
```
|
;enableKeyboardMode=1
|
||||||
|
;useAimeDBForPhysicalCards=1
|
||||||
|
```
|
||||||
4. Launch the game, and check the amdaemon console for server startup messages
|
4. Launch the game, and check the amdaemon console for server startup messages
|
||||||
5. Access the using one of the interfaces (see below), connect to the running server, add a card and start playing!
|
5. Access the using one of the interfaces (see below), add a new server `http://<your-ip-address>:6070`, import your existing card (or create a new one) and start playing!
|
||||||
|
|
||||||
### Web Interface
|
### Physical Cards
|
||||||
The web interface can be accessed by navigating to http://card.ppc.moe/ on a device on the same network as the server.
|
AMNet 1.3 adds support for scanning physical cards on iOS devices. If you own a physical Aime/Banapass/NESiCA/e-Amusement card, you can scan it using the app and use it in-game.
|
||||||
This website is also installable as a PWA, which will allow it to be registered as an app on your device.
|
By changing the `useAimeDBForPhysicalCards` in segatools.ini, you can choose how the card is read on a per-game basis:
|
||||||
|
|
||||||
> Because the server can only listen on HTTP, the website needs to be visited over HTTP as well. This may cause issues with some devices, as they may block HTTP requests on HTTPS websites.
|
- `0` will send the 20-digit access code on the back of the card to the game and load the save data directly (default option).
|
||||||
|
- `1` - Send the card's IDm to the game and use AiMeDB to retrieve the 20-digit code assigned to the card.
|
||||||
|
|
||||||
[](http://card.ppc.moe)
|
Mode `1` makes the app function in the same way to a real card reader and should be used if you want the same access code to show when scanning the card on a cab.
|
||||||
|
You should also use this option if you're connecting to a hosted server (AquaNet/rinnet/etc).
|
||||||
|
|
||||||
### Native Application
|
Additionally, if `useAimeDBForPhysicalCards` is set to `1` and the game is failing to read the card, make sure an up-to-date version of segatools is being used.
|
||||||
The AMNet application is available (and fully supported) for iOS devices, and can be downloaded by joining the TestFlight (see links at the top of the file) or built manually.
|
|
||||||
Android is technically supported but hasn't been tested; as such APKs aren't provided at this moment.
|
## Interfaces
|
||||||
|
AMNet can be used on most platforms, see below for options available for each platform:
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th>Platform</th>
|
||||||
|
<th>Method</th>
|
||||||
|
<th>Notes</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>iOS</td>
|
||||||
|
<td><a href="https://amnet-dist.ppc.moe/amnet-1.3.9.ipa" target="_blank">.ipa</a></td>
|
||||||
|
<td>*TestFlight has been shut down and won't be back for the forseeable future, sideload using AltStore or similar tooling</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td rowspan="2">Android</td>
|
||||||
|
<td><a href="https://appdistribution.firebase.dev/i/6acc3151357deaa8" target="_blank">Firebase</a></td>
|
||||||
|
<td>Recommended for reduced download/install size and easier upgrading</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>.apk</td>
|
||||||
|
<td>Larger download, manual installation required.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>All</td>
|
||||||
|
<td><a href="http://card.ppc.moe" target="_blank">Web</a></td>
|
||||||
|
<td>Works on all platforms and is installable as a PWA. Ensure it's used on <code>http</code> to avoid CORS-related errors</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="./res/native.png" height="600"/>
|
<img src="./res/native.png" height="600"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
### Server (AMNet.Server)
|
### Server (amnet-server and amnet-server-test)
|
||||||
Building the server from source requires the following dependencies:
|
Building the server from source requires a recent version of **Rust** in
|
||||||
Visual Studio 2022 (or Build Tools) with **Desktop Development with C++** and **.NET Desktop Development** workloads, including **MSVC x64/x86** compiler and a recent **Windows 10/11 SDK**.
|
addition to an **MSVC compiler** and (probably) a recent **Windows 10/11 SDK**.
|
||||||
|
|
||||||
After cloning the repository, the server can be built by publishing the project.
|
After cloning the repository, the server can be built using cargo, specifying a target if an x86 version is desired.
|
||||||
Building the server without publish mode will result in a DLL that cannot be run by the game.
|
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
dotnet publish -c Release -r win-x64 -p:Version=1.0.0 AMNet.Server/AMNet.Server.csproj
|
cargo build --release
|
||||||
```
|
|
||||||
|
|
||||||
The dll will be available at `.\AMNet.Server\bin\Release\net8.0-windows\win-x64\publish\amnet.dll`.
|
# find the .dll at target/release/amnet.dll
|
||||||
|
```
|
||||||
|
|
||||||
### Website (amnet-web)
|
### Website (amnet-web)
|
||||||
The web interface is built using Vite and React. Building the web interface requires Node.js (or alternative runtime).
|
The web interface is built using Vite and React. Building the web interface requires Node.js (or alternative runtime).
|
||||||
@@ -79,7 +115,7 @@ npm run build # build the website, outputs to ./dist
|
|||||||
The mobile app is built using Expo and React Native. The app requires Node.js and npm to build, along with either the Android SDK or Xcode for iOS.
|
The mobile app is built using Expo and React Native. The app requires Node.js and npm to build, along with either the Android SDK or Xcode for iOS.
|
||||||
After cloning the repository, install dependencies then run the app, or prebuild for the platform of your choice then complete the build process for a standalone app:
|
After cloning the repository, install dependencies then run the app, or prebuild for the platform of your choice then complete the build process for a standalone app:
|
||||||
|
|
||||||
> amnet-native relies on one or more native libraries, so Expo Go cannot be used. The app must be built and run on a physical device or simulator.
|
> amnet-native relies on multiple native libraries not included in Expo Go. The app must be built from scratch and run on a physical device or simulator.
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
cd amnet-native
|
cd amnet-native
|
||||||
|
|||||||
Reference in New Issue
Block a user