mirror of
https://gitea.tendokyu.moe/ppc/amnet.git
synced 2026-10-05 21:28:08 +03:00
Compare 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 |
@@ -0,0 +1,8 @@
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/*.tsbuildinfo
|
||||
.git
|
||||
target
|
||||
amnet-native
|
||||
amnet-server-test
|
||||
res
|
||||
@@ -1,9 +1,6 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
# push:
|
||||
# tags:
|
||||
# - '^v?(?:[0-9]+.?){1,4}$'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
@@ -33,26 +30,31 @@ jobs:
|
||||
needs: get-upload-url
|
||||
name: Build amnet-server
|
||||
|
||||
env:
|
||||
DOTNET_CLI_TELEMETRY_OPTOUT: 1
|
||||
strategy:
|
||||
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:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '9.0.x'
|
||||
|
||||
- name: Set version
|
||||
|
||||
- name: Set target
|
||||
run: |
|
||||
$tag="${{ github.event.inputs.tag || github.ref_name }}".TrimStart("v")
|
||||
echo "VERSION=$tag" >> $env:GITHUB_ENV
|
||||
rustup target add ${{ matrix.target }}
|
||||
rustup set default-host ${{ matrix.target }}
|
||||
|
||||
- name: Publish
|
||||
run: dotnet publish -c Release -r win-x64 -o .\build-out -p:Version=$env:VERSION AMNet.Server\AMNet.Server.csproj
|
||||
- name: Perform build
|
||||
run: cargo build --release
|
||||
|
||||
- name: Upload artifact
|
||||
- name: Upload .dll
|
||||
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:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
+16
-121
@@ -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
|
||||
.history/
|
||||
|
||||
# JetBrains Rider
|
||||
*.sln.iml
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
@@ -274,4 +154,19 @@ docs/pages/versions/*/react-native/*.diff
|
||||
**/ios/.xcode.env.local
|
||||
|
||||
# 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
|
||||
+29
-23
@@ -1,14 +1,14 @@
|
||||
import React, {useEffect} from "react";
|
||||
import React from "react";
|
||||
import {StatusBar} from "expo-status-bar";
|
||||
import {useColorScheme, View} from "react-native";
|
||||
import {Server, WalletCards} from "lucide-react-native";
|
||||
import {Platform, useColorScheme, View} from "react-native";
|
||||
import {NavigationContainer} from "@react-navigation/native";
|
||||
import {SafeAreaProvider} from "react-native-safe-area-context";
|
||||
import {MD3Colors} from "react-native-paper/lib/typescript/types";
|
||||
import {createBottomTabNavigator} from "@react-navigation/bottom-tabs";
|
||||
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 {DefaultTheme as NavigationLightTheme, DarkTheme as NavigationDarkTheme} from '@react-navigation/native';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
adaptNavigationTheme,
|
||||
@@ -16,15 +16,18 @@ import {
|
||||
MD3LightTheme,
|
||||
MD3Theme,
|
||||
PaperProvider,
|
||||
Paragraph
|
||||
Text
|
||||
} from 'react-native-paper';
|
||||
|
||||
import RealmContext from "./models/RealmContext";
|
||||
import linking from "./Linking";
|
||||
import RealmContext from "./models/RealmContext";
|
||||
|
||||
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 CardManagerStack from "./app/CardManagerStack";
|
||||
import ServerManagerStack from "./app/ServerManagerStack";
|
||||
|
||||
@@ -69,25 +72,22 @@ const {LightTheme, DarkTheme} = adaptNavigationTheme({
|
||||
});
|
||||
|
||||
const {RealmProvider} = RealmContext;
|
||||
|
||||
const Tab = createBottomTabNavigator();
|
||||
const Stack = createNativeStackNavigator();
|
||||
|
||||
function AppContent() {
|
||||
return (<Tab.Navigator tabBar={p => <AMUITabBar {...p}/>} screenOptions={{headerShown: false}}>
|
||||
<Tab.Screen name="servers"
|
||||
component={ServerManagerStack}
|
||||
options={{
|
||||
title: 'Servers',
|
||||
tabBarIcon: ({color, size}) => (<Server color={color} size={size}/>)
|
||||
}}/>
|
||||
return (
|
||||
<Tab.Navigator tabBar={p => <AMUITabBar {...p}/>} screenOptions={{headerShown: false}}>
|
||||
<Tab.Screen name="servers"
|
||||
component={ServerManagerStack}
|
||||
options={{tabBarIcon: ({color, size}) => (<Server color={color} size={size}/>)}}/>
|
||||
|
||||
<Tab.Screen name="cards"
|
||||
component={CardManagerStack}
|
||||
options={{
|
||||
title: "Cards",
|
||||
tabBarIcon: ({color, size}) => (<WalletCards color={color} size={size}/>)
|
||||
}}/>
|
||||
</Tab.Navigator>)
|
||||
<Tab.Screen name="cards"
|
||||
component={CardManagerStack}
|
||||
options={{tabBarIcon: ({color, size}) => (<WalletCards color={color} size={size}/>)}}/>
|
||||
</Tab.Navigator>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AppRoot() {
|
||||
@@ -100,7 +100,7 @@ export default function AppRoot() {
|
||||
return (
|
||||
<View style={{flex: 1, justifyContent: "center", alignItems: "center", gap: 10}}>
|
||||
<ActivityIndicator animating/>
|
||||
<Paragraph>Starting...</Paragraph>
|
||||
<Text variant="bodyMedium">Starting...</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -116,9 +116,15 @@ export default function AppRoot() {
|
||||
{/* main app */}
|
||||
<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-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.Navigator>
|
||||
</NavigationContainer>
|
||||
|
||||
+78
-74
@@ -1,79 +1,83 @@
|
||||
{
|
||||
"name": "AMNet",
|
||||
"scheme": "amnet",
|
||||
"version": "1.2.0",
|
||||
"slug": "amnet-native",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"ios": {
|
||||
"bundleIdentifier": "moe.ppc.amnet",
|
||||
"supportsTablet": true,
|
||||
"associatedDomains": [
|
||||
"applinks:card.ppc.moe"
|
||||
],
|
||||
"infoPlist": {
|
||||
"UIViewControllerBasedStatusBarAppearance": "NO"
|
||||
},
|
||||
"config": {
|
||||
"usesNonExemptEncryption": false
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"package": "moe.ppc.amnet",
|
||||
"versionCode": 3,
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/icon-android.png",
|
||||
"backgroundColor": "#DED8FD"
|
||||
},
|
||||
"splash": {
|
||||
"resizeMode": "cover",
|
||||
"image": "./assets/icon-android.png",
|
||||
"backgroundColor": "#DED8FD",
|
||||
"dark": {
|
||||
"backgroundColor": "#3B5ACC"
|
||||
"expo": {
|
||||
"name": "AMNet",
|
||||
"scheme": "amnet",
|
||||
"version": "1.3.2",
|
||||
"slug": "amnet-native",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"newArchEnabled": true,
|
||||
"ios": {
|
||||
"bundleIdentifier": "moe.ppc.amnet",
|
||||
"supportsTablet": true,
|
||||
"infoPlist": {
|
||||
"NSAppTransportSecurity": {
|
||||
"NSAllowsArbitraryLoads": true
|
||||
},
|
||||
"UIDesignRequiresCompatibility": true,
|
||||
"UIViewControllerBasedStatusBarAppearance": false
|
||||
},
|
||||
"config": {
|
||||
"usesNonExemptEncryption": false
|
||||
}
|
||||
},
|
||||
"intentFilters": [
|
||||
{
|
||||
"autoVerify": true,
|
||||
"action": "VIEW",
|
||||
"data": [
|
||||
{
|
||||
"scheme": "http",
|
||||
"host": "card.ppc.moe",
|
||||
"pathPrefix": "/#/servers"
|
||||
},
|
||||
{
|
||||
"scheme": "http",
|
||||
"host": "card.ppc.moe",
|
||||
"pathPrefix": "/#/cards"
|
||||
},
|
||||
{
|
||||
"scheme": "https",
|
||||
"host": "card.ppc.moe",
|
||||
"pathPrefix": "/#/servers"
|
||||
},
|
||||
{
|
||||
"scheme": "https",
|
||||
"host": "card.ppc.moe",
|
||||
"pathPrefix": "/#/cards"
|
||||
}
|
||||
],
|
||||
"category": [
|
||||
"BROWSABLE",
|
||||
"DEFAULT"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"plugins": [
|
||||
[
|
||||
"expo-build-properties",
|
||||
{
|
||||
"android": {
|
||||
"usesCleartextTraffic": true
|
||||
"android": {
|
||||
"package": "moe.ppc.amnet",
|
||||
"versionCode": 9,
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/icon-android.png",
|
||||
"backgroundColor": "#DED8FD"
|
||||
},
|
||||
"splash": {
|
||||
"resizeMode": "cover",
|
||||
"image": "./assets/icon-android.png",
|
||||
"backgroundColor": "#DED8FD",
|
||||
"dark": {
|
||||
"backgroundColor": "#3B5ACC"
|
||||
}
|
||||
}
|
||||
},
|
||||
"intentFilters": [
|
||||
{
|
||||
"autoVerify": true,
|
||||
"action": "VIEW",
|
||||
"data": [
|
||||
{
|
||||
"scheme": "http",
|
||||
"host": "card.ppc.moe",
|
||||
"pathPrefix": "/#/servers"
|
||||
},
|
||||
{
|
||||
"scheme": "http",
|
||||
"host": "card.ppc.moe",
|
||||
"pathPrefix": "/#/cards"
|
||||
},
|
||||
{
|
||||
"scheme": "https",
|
||||
"host": "card.ppc.moe",
|
||||
"pathPrefix": "/#/servers"
|
||||
},
|
||||
{
|
||||
"scheme": "https",
|
||||
"host": "card.ppc.moe",
|
||||
"pathPrefix": "/#/cards"
|
||||
}
|
||||
],
|
||||
"category": [
|
||||
"BROWSABLE",
|
||||
"DEFAULT"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"plugins": [
|
||||
[
|
||||
"expo-build-properties",
|
||||
{
|
||||
"android": {
|
||||
"usesCleartextTraffic": true
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ContextMenu from "react-native-context-menu-view";
|
||||
|
||||
import {AimeCard} from "../components/AimeCard";
|
||||
import {ICCard} from "../components/ICCard";
|
||||
import {EmptyStatePlaceholder} from "../components/EmptyStatePlaceholder";
|
||||
|
||||
import {AMCard} from "../models/AMCard";
|
||||
@@ -21,19 +21,20 @@ function CardEntry(props: { card: AMCard, navigation: any }) {
|
||||
}
|
||||
|
||||
switch (indexPath.toString()) {
|
||||
// copy card
|
||||
// copy card access code
|
||||
case "0":
|
||||
case "0,0":
|
||||
await Clipboard.setStringAsync(props.card.code);
|
||||
return;
|
||||
|
||||
// edit card
|
||||
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;
|
||||
|
||||
// remove card
|
||||
case "1,1":
|
||||
realm.write(() => realm.delete(props.card));
|
||||
AMCard.delete(realm, props.card);
|
||||
return;
|
||||
}
|
||||
}, [realm, props.card, props.navigation]);
|
||||
@@ -50,7 +51,7 @@ function CardEntry(props: { card: AMCard, navigation: any }) {
|
||||
{title: "Edit Card", systemIcon: "square.and.pencil"},
|
||||
{title: "Delete Card", destructive: true, systemIcon: "trash"}]
|
||||
}]}>
|
||||
<AimeCard card={props.card} showFullCardNumber/>
|
||||
<ICCard card={props.card} showFullCardNumber/>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,14 +15,20 @@ export default function CardManagerStack({navigation}) {
|
||||
headerLargeTitle: true,
|
||||
headerBlurEffect: "regular",
|
||||
headerStyle: {
|
||||
backgroundColor: theme.colors.background,
|
||||
backgroundColor: theme.colors.background
|
||||
},
|
||||
headerTitleStyle: {
|
||||
fontWeight: "bold"
|
||||
},
|
||||
headerLargeTitleStyle: {
|
||||
fontWeight: "bold"
|
||||
}
|
||||
}}>
|
||||
<Stack.Screen name="card-listing"
|
||||
component={CardManager}
|
||||
options={{
|
||||
title: "Cards",
|
||||
headerRight: () => Platform.OS === 'android' ? null : <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>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import {useModalStyles} from "../utils/ModalStyles";
|
||||
import {Card, Paragraph, useTheme} from "react-native-paper";
|
||||
import {StatusBar} from "expo-status-bar";
|
||||
import {Card, Text, useTheme} from "react-native-paper";
|
||||
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 {AMServer} from "../models/AMServer";
|
||||
|
||||
@@ -24,7 +25,7 @@ export default function ServerAddModal({route, navigation}) {
|
||||
useLayoutEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerLeft: () => <TouchableOpacity onPress={() => navigation.goBack()}>
|
||||
<Paragraph style={modalStyles.headerText}>Cancel</Paragraph>
|
||||
<Text variant="bodyMedium" style={modalStyles.headerText}>Cancel</Text>
|
||||
</TouchableOpacity>,
|
||||
});
|
||||
}, [navigation, modalStyles]);
|
||||
@@ -34,41 +35,74 @@ export default function ServerAddModal({route, navigation}) {
|
||||
let urlValid = false;
|
||||
|
||||
try {
|
||||
new URL(serverAddress);
|
||||
urlValid = true;
|
||||
const protocol = new URL(serverAddress).protocol;
|
||||
urlValid = protocol.localeCompare("http:", undefined, {sensitivity: "case"}) === 0 || protocol.localeCompare("https:", undefined, {sensitivity: "case"}) === 0;
|
||||
} catch {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
const themeOptions = urlValid ? modalStyles.headerText : {color: theme.colors.outline};
|
||||
navigation.setOptions({
|
||||
headerRight: () => <TouchableOpacity disabled={!urlValid} onPress={() => saveServer()}>
|
||||
<Paragraph style={{...themeOptions, fontWeight: "600"}}>Save</Paragraph>
|
||||
headerRight: () => <TouchableOpacity disabled={!urlValid} onPress={() => saveServer(serverAddress)}>
|
||||
<Text variant="bodyMedium" style={{...themeOptions, fontWeight: "600"}}>Save</Text>
|
||||
</TouchableOpacity>
|
||||
})
|
||||
}, [navigation, modalStyles, serverAddress]);
|
||||
|
||||
const saveServer = useCallback(() => {
|
||||
let serverObject = realm.objects<AMServer>("server").find(x => x.serverAddress == serverAddress);
|
||||
const saveServer = useCallback(async (address: string) => {
|
||||
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) {
|
||||
realm.write(() => {
|
||||
serverObject = realm.create<AMServer>("server", AMServer.generate(serverAddress));
|
||||
serverObject = realm.create<AMServer>(AMServer.schema.name, AMServer.generate(address));
|
||||
});
|
||||
}
|
||||
|
||||
navigation.goBack();
|
||||
setTimeout(() => navigation.navigate('server', {
|
||||
serverId: serverObject._id.toHexString()
|
||||
}), 500);
|
||||
}, [serverAddress, realm]);
|
||||
setTimeout(() => {
|
||||
navigation.popTo('app', {
|
||||
screen: "servers",
|
||||
params: {
|
||||
screen: "server",
|
||||
params: {
|
||||
serverId: serverObject._id.toHexString()
|
||||
}
|
||||
}
|
||||
});
|
||||
}, 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.Content>
|
||||
<View style={{...modalStyles.formItemRow, gap: 10}}>
|
||||
<Paragraph style={{...modalStyles.formItemName, minWidth: 0}}>
|
||||
<Text variant="bodyMedium" style={{...modalStyles.formItemName, minWidth: 0}}>
|
||||
Address
|
||||
</Paragraph>
|
||||
</Text>
|
||||
<TextInput style={modalStyles.formItemInput}
|
||||
autoFocus
|
||||
numberOfLines={1}
|
||||
@@ -84,10 +118,11 @@ export default function ServerAddModal({route, navigation}) {
|
||||
</Card.Content>
|
||||
</Card>
|
||||
|
||||
<Paragraph style={modalStyles.formFootnote}>
|
||||
If you're using HTTPS, ensure a trusted certificate has been configured via HTTP.SYS
|
||||
</Paragraph>
|
||||
</View>)
|
||||
<Text variant="bodyMedium" style={modalStyles.formFootnote}>
|
||||
AMNet uses HTTP by default. HTTPS will not work unless specifically configured using HTTP.SYS
|
||||
</Text>
|
||||
</View>
|
||||
</>)
|
||||
}
|
||||
|
||||
function handleDeepLink(realm, navigation, route, setAddress: (address: string) => void) {
|
||||
@@ -104,7 +139,7 @@ function handleDeepLink(realm, navigation, route, setAddress: (address: string)
|
||||
navigation.goBack();
|
||||
|
||||
// allow the homepage to render before moving on
|
||||
setTimeout(() => navigation.navigate('server', {
|
||||
setTimeout(() => navigation.popTo('server', {
|
||||
serverId: item[0]._id.toHexString()
|
||||
}), 1000);
|
||||
} else {
|
||||
|
||||
@@ -1,40 +1,31 @@
|
||||
import {BSON} from "realm";
|
||||
import {ActivityIndicator, Button, Text, useTheme} from "react-native-paper";
|
||||
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,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View
|
||||
} from "react-native";
|
||||
import {Alert, Platform, RefreshControl, ScrollView, Share, StyleSheet, View} from "react-native";
|
||||
import {
|
||||
CircleCheck,
|
||||
CircleDot,
|
||||
CircleEllipsis,
|
||||
CreditCard,
|
||||
Nfc,
|
||||
Plus,
|
||||
SmartphoneNfcIcon,
|
||||
Share as ShareIcon,
|
||||
WifiOff,
|
||||
TvMinimalPlay,
|
||||
Star
|
||||
Star,
|
||||
SquareArrowOutUpRight
|
||||
} from "lucide-react-native";
|
||||
|
||||
import {EmptyStatePlaceholder} from "../components/EmptyStatePlaceholder";
|
||||
import {AMUIStatusAlert} from "../components/AMUIStatusAlert";
|
||||
import {AMUICardDetail} from "../components/AMUICard";
|
||||
import {AimeCard} from "../components/AimeCard";
|
||||
import {ICCard} from "../components/ICCard";
|
||||
|
||||
import RealmContext from "../models/RealmContext";
|
||||
import {AMServer} from "../models/AMServer";
|
||||
import {AMCard} from "../models/AMCard";
|
||||
import ObjectId = BSON.ObjectId;
|
||||
|
||||
import {
|
||||
AMCardInRequest,
|
||||
AMServerConnectionInfo,
|
||||
AMServerInstanceMetrics,
|
||||
fetchServerInfo,
|
||||
@@ -42,26 +33,59 @@ import {
|
||||
performCardIn
|
||||
} 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;
|
||||
|
||||
export default function ServerCardIn({route, navigation}) {
|
||||
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 cards = useQuery({
|
||||
type: AMCard,
|
||||
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
|
||||
const [serverRefreshing, setServerRefreshing] = useState<boolean>(false);
|
||||
const [serverReloadCounter, setServerReloadCounter] = useState<number>(0);
|
||||
const [serverConnectionSuccess, setServerConnectionSuccess] = useState<boolean | null>(null);
|
||||
|
||||
const [userSelectedCard, setUserSelectedCard] = useState<AMCard>(null);
|
||||
const [cardInCompleted, setCardInCompleted] = useState<boolean>(null);
|
||||
const [userSelectedCard, setUserSelectedCard] = useState<AMCard | null>(null);
|
||||
const [cardInCompleted, setCardInCompleted] = useState<boolean | null>(null);
|
||||
|
||||
const disableCardIn = useMemo(() => cardInCompleted !== null || !serverConnectionSuccess, [cardInCompleted, serverConnectionSuccess]);
|
||||
const serverDefaultCard = useMemo(() => {
|
||||
@@ -69,8 +93,8 @@ export default function ServerCardIn({route, navigation}) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return cards.find(x => x.lastUsedGameId === server.gameId);
|
||||
}, [cards, server?.gameId]);
|
||||
return realm.objects<AMCardRecentUse>(AMCardRecentUse.schema.name).find(x => x.gameId === server.gameId)?.card;
|
||||
}, [realm, cards, server?.gameId]);
|
||||
|
||||
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]);
|
||||
@@ -83,30 +107,20 @@ export default function ServerCardIn({route, navigation}) {
|
||||
navigation.setOptions({
|
||||
title: server.serverName,
|
||||
headerRight: () => (
|
||||
<TouchableOpacity onPress={() => Share.share({url: server.shareUrl})}>
|
||||
<ShareIcon color={theme.colors.primary}/>
|
||||
</TouchableOpacity>
|
||||
<MenuView actions={menuActions} onPressAction={menuCallback}>
|
||||
<CircleEllipsis color={theme.colors.primary}/>
|
||||
</MenuView>
|
||||
)
|
||||
});
|
||||
}, [navigation, server]);
|
||||
|
||||
// invalid card/server selection
|
||||
useEffect(() => {
|
||||
if (server?.isValid() !== true) {
|
||||
navigation.goBack();
|
||||
return;
|
||||
}
|
||||
}, [server]);
|
||||
|
||||
useEffect(() => {
|
||||
if (userSelectedCard?.isValid() === false) {
|
||||
setUserSelectedCard(null);
|
||||
}
|
||||
}, [userSelectedCard]);
|
||||
|
||||
// handle auto-refresh
|
||||
useEffect(() => {
|
||||
if (!serverConnectionSuccess || server?.isValid() !== true) {
|
||||
if (!serverConnectionSuccess) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -117,6 +131,13 @@ export default function ServerCardIn({route, navigation}) {
|
||||
}
|
||||
}, [serverConnectionSuccess, server]);
|
||||
|
||||
// invalid card/server selection
|
||||
useEffect(() => {
|
||||
if (userSelectedCard?.isValid() === false) {
|
||||
setUserSelectedCard(null);
|
||||
}
|
||||
}, [userSelectedCard]);
|
||||
|
||||
// server info update
|
||||
useEffect(() => {
|
||||
const abortController = new AbortController();
|
||||
@@ -145,16 +166,24 @@ export default function ServerCardIn({route, navigation}) {
|
||||
}
|
||||
}, [serverId, serverReloadCounter, realm]);
|
||||
|
||||
const cardInCallback = useCallback(() => {
|
||||
setCardInCompleted(false);
|
||||
|
||||
const cardInCallback = useCallback((card: AMCard) => {
|
||||
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();
|
||||
realm.write(() => {
|
||||
server.lastUsed = now;
|
||||
selectedCard.lastUsed = now;
|
||||
selectedCard.lastUsedGameId = server.gameId;
|
||||
card.lastUsed = now;
|
||||
|
||||
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);
|
||||
@@ -166,7 +195,7 @@ export default function ServerCardIn({route, navigation}) {
|
||||
|
||||
setTimeout(() => abortController.abort(), 5000);
|
||||
return () => abortController.abort();
|
||||
}, [server, selectedCard]);
|
||||
}, [server]);
|
||||
|
||||
const performRefresh = () => {
|
||||
setServerRefreshing(true);
|
||||
@@ -181,7 +210,7 @@ export default function ServerCardIn({route, navigation}) {
|
||||
return (
|
||||
<View style={{flex: 1, justifyContent: "center", alignItems: "center", gap: 10}}>
|
||||
<ActivityIndicator animating/>
|
||||
<Paragraph>Connecting to server...</Paragraph>
|
||||
<Text variant="bodyMedium">Connecting to server...</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -191,20 +220,21 @@ export default function ServerCardIn({route, navigation}) {
|
||||
<View style={styles.container}>
|
||||
<StatusAlert server={server} connectionEstablished={serverConnectionSuccess}/>
|
||||
<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 ? <>
|
||||
<AimeCard actionDisabled
|
||||
card={selectedCard}
|
||||
actionIcon={<CircleCheck color="green" size={24}/>}>
|
||||
<ICCard actionDisabled
|
||||
card={selectedCard}
|
||||
actionIcon={<CircleCheck color="green" size={24}/>}>
|
||||
{serverDefaultCard?.code == selectedCard.code && <DefaultCardDetail/>}
|
||||
</AimeCard>
|
||||
<Button textColor="white"
|
||||
onPress={cardInCallback}
|
||||
disabled={disableCardIn}
|
||||
contentStyle={{flexDirection: 'row-reverse'}}
|
||||
mode={serverConnectionSuccess ? "contained" : "outlined"}
|
||||
buttonColor={cardInCompleted === false ? "red" : "green"}
|
||||
icon={() => <Nfc color={disableCardIn ? "gray" : "white"} size={18}/>}>
|
||||
</ICCard>
|
||||
<Button style={{flex: 1}}
|
||||
textColor="white"
|
||||
disabled={disableCardIn}
|
||||
contentStyle={{flexDirection: 'row-reverse'}}
|
||||
mode={serverConnectionSuccess ? "contained" : "outlined"}
|
||||
buttonColor={cardInCompleted === false ? "red" : "green"}
|
||||
onPress={() => cardInCallback(selectedCard)}
|
||||
icon={() => <SquareArrowOutUpRight color={disableCardIn ? "gray" : "white"} size={18}/>}>
|
||||
{cardInCompleted === null ? "Card In" : cardInCompleted ? "Card In Success" : "Failed"}
|
||||
</Button>
|
||||
</>
|
||||
@@ -213,34 +243,34 @@ export default function ServerCardIn({route, navigation}) {
|
||||
<Button mode="text"
|
||||
icon={() => <Plus color={theme.colors.primary} size={18}/>}
|
||||
onPress={() => navigation.navigate('add-card')}>
|
||||
Add card
|
||||
Add Card
|
||||
</Button>
|
||||
</View>}
|
||||
</View>
|
||||
}
|
||||
</View>
|
||||
|
||||
{cards.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<Title style={styles.sectionTitle}>Additional Cards</Title>
|
||||
<Text variant="titleLarge" style={styles.sectionTitle}>Additional Cards</Text>
|
||||
<Button mode="contained"
|
||||
textColor="white"
|
||||
buttonColor={theme.colors.tertiary}
|
||||
icon={() => <Plus color="white" size={18}/>}
|
||||
contentStyle={{flexDirection: 'row-reverse'}}
|
||||
icon={() => <Plus color="white" size={18}/>}
|
||||
onPress={() => navigation.navigate('add-card')}>
|
||||
Add card
|
||||
Add Card
|
||||
</Button>
|
||||
|
||||
{unselectedCards.length
|
||||
? unselectedCards.map((card: AMCard) => (
|
||||
<AimeCard card={card}
|
||||
key={card._id.toHexString()}
|
||||
actionIcon={<CircleDot color={theme.colors.tertiary} size={24}/>}
|
||||
onPress={() => setUserSelectedCard(card)}>
|
||||
<ICCard card={card}
|
||||
key={card._id.toHexString()}
|
||||
actionIcon={<CircleDot color={theme.colors.tertiary} size={24}/>}
|
||||
onPress={() => setUserSelectedCard(card)}>
|
||||
{serverDefaultCard?.code == card.code && <DefaultCardDetail/>}
|
||||
</AimeCard>))
|
||||
</ICCard>))
|
||||
: <View style={styles.emptyPlaceholderView}>
|
||||
<EmptyStatePlaceholder message="No additional cards"
|
||||
icon={<SmartphoneNfcIcon color="gray"/>}/>
|
||||
<EmptyStatePlaceholder message="No additional cards" icon={<SmartphoneNfcIcon color="gray"/>}/>
|
||||
</View>}
|
||||
</View>)}
|
||||
</View>
|
||||
@@ -255,11 +285,11 @@ function StatusAlert(props: {
|
||||
if (!props.connectionEstablished) {
|
||||
return (
|
||||
<AMUIStatusAlert color="red" icon={WifiOff} title="Connection Failed">
|
||||
<Paragraph>
|
||||
There was an issue connecting to <Paragraph
|
||||
style={{fontFamily: "JetBrainsMono_400Regular"}}>{props.server.serverAddress}</Paragraph>.
|
||||
<Text variant="bodyMedium">
|
||||
There was an issue connecting to <Text variant="bodyMedium"
|
||||
style={{fontFamily: "JetBrainsMono_400Regular"}}>{props.server.serverAddress}</Text>.
|
||||
Check the network connection and try again.
|
||||
</Paragraph>
|
||||
</Text>
|
||||
</AMUIStatusAlert>
|
||||
)
|
||||
}
|
||||
@@ -268,9 +298,9 @@ function StatusAlert(props: {
|
||||
if (props.server.timeSinceLastPoll === null) {
|
||||
return (
|
||||
<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.
|
||||
</Paragraph>
|
||||
</Text>
|
||||
</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({
|
||||
section: {
|
||||
gap: 10
|
||||
@@ -304,7 +356,7 @@ const styles = StyleSheet.create({
|
||||
gap: 15
|
||||
},
|
||||
container: {
|
||||
paddingTop: Platform.OS === 'android' ? 120 : 0,
|
||||
paddingTop: Platform.select({android: 120, default: 0}),
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 80,
|
||||
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 {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 {AMUICard, AMUICardDetail} from "../components/AMUICard";
|
||||
import {EmptyStatePlaceholder} from "../components/EmptyStatePlaceholder";
|
||||
|
||||
import RealmContext from "../models/RealmContext";
|
||||
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 }) {
|
||||
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(() => {
|
||||
props.navigation.navigate('server', {
|
||||
serverId: props.server._id.toHexString()
|
||||
});
|
||||
}, [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 (
|
||||
<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>
|
||||
<Globe size={18} color="gray"/>
|
||||
<Text variant="bodyMedium" style={{color: "gray", fontFamily: "JetBrainsMono_400Regular"}}>
|
||||
{props.server.serverAddress}
|
||||
</Text>
|
||||
</AMUICardDetail>
|
||||
{gameName && (
|
||||
<AMUICardDetail>
|
||||
<Globe size={18} color="gray"/>
|
||||
<Paragraph style={{color: "gray", fontFamily: "JetBrainsMono_400Regular"}}>
|
||||
{props.server.serverAddress}
|
||||
</Paragraph>
|
||||
<Joystick size={18} color="gray"/>
|
||||
<Text variant="bodyMedium" style={{color: "gray"}}>{gameName}</Text>
|
||||
</AMUICardDetail>
|
||||
{gameName && (
|
||||
<AMUICardDetail>
|
||||
<Joystick size={18} color="gray"/>
|
||||
<Paragraph style={{color: "gray"}}>{gameName}</Paragraph>
|
||||
</AMUICardDetail>
|
||||
)}
|
||||
<AMUICardDetail>
|
||||
<History size={18} color="gray"/>
|
||||
{props.server.lastUsed
|
||||
? (<TimeAgo style={{color: "gray"}} dateTo={props.server.lastUsed}/>)
|
||||
: (<Paragraph style={{color: "gray"}}>never</Paragraph>)}
|
||||
</AMUICardDetail>
|
||||
</AMUICard>
|
||||
</ContextMenu>
|
||||
)}
|
||||
<AMUICardDetail>
|
||||
<History size={18} color="gray"/>
|
||||
{props.server.lastUsed
|
||||
? (<TimeAgo style={{color: "gray"}} dateTo={props.server.lastUsed}/>)
|
||||
: (<Text variant="bodyMedium" style={{color: "gray"}}>never</Text>)}
|
||||
</AMUICardDetail>
|
||||
</AMUICard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -87,7 +57,7 @@ export default function ServerManager({navigation}) {
|
||||
q = q.filtered("serverName CONTAINS[c] $0 OR serverAddress CONTAINS[c] $0", searchQuery);
|
||||
}
|
||||
|
||||
return q.sorted("lastUsed");
|
||||
return q.sorted("lastUsed", true);
|
||||
}, [query, searchQuery]);
|
||||
|
||||
// enable searchbar
|
||||
|
||||
@@ -18,16 +18,19 @@ export default function ServerManagerStack({navigation}) {
|
||||
headerBlurEffect: "regular",
|
||||
headerStyle: {
|
||||
backgroundColor: theme.colors.background
|
||||
},
|
||||
headerTitleStyle: {
|
||||
fontWeight: "bold"
|
||||
},
|
||||
headerLargeTitleStyle: {
|
||||
fontWeight: "bold"
|
||||
}
|
||||
}}>
|
||||
<Stack.Screen name="server-listing"
|
||||
component={ServerManager}
|
||||
options={{
|
||||
title: "Servers",
|
||||
headerRight: () => Platform.OS === 'android' ? null : <TouchableOpacity
|
||||
onPress={() => navigation.navigate('add-server')}>
|
||||
<Plus/>
|
||||
</TouchableOpacity>
|
||||
headerRight: () => Platform.OS === 'android' ? null : <TouchableOpacity onPress={() => navigation.navigate('add-server')}><Plus color={theme.colors.primary}/></TouchableOpacity>
|
||||
}}/>
|
||||
|
||||
<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>
|
||||
</>)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
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 {
|
||||
actionIcon?: Readonly<React.ReactNode>,
|
||||
@@ -21,7 +21,7 @@ export function AMUICard(props: AMUICardActionProps & AMUICardProps) {
|
||||
}
|
||||
|
||||
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}/>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
@@ -48,7 +48,7 @@ function CardComponent(props: AMUICardProps & AMUICardActionProps) {
|
||||
gap: 5
|
||||
}}>
|
||||
<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}}>
|
||||
{props.children}
|
||||
</View>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {View} from "react-native";
|
||||
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";
|
||||
|
||||
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>
|
||||
<View style={{alignItems: "center", flexDirection: "row", gap: 10}}>
|
||||
{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>
|
||||
{props.children}
|
||||
</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 {Paragraph} from "react-native-paper";
|
||||
import {Text} from "react-native-paper";
|
||||
|
||||
export function EmptyStatePlaceholder(props: { message: string, icon: React.ReactNode }) {
|
||||
return (
|
||||
<View style={{display: "flex", gap: 5, alignItems: "center"}}>
|
||||
{props.icon}
|
||||
<Paragraph style={{color: "gray", fontSize: 14}}>
|
||||
<Text variant="bodyMedium" style={{color: "gray", fontSize: 14}}>
|
||||
{props.message}
|
||||
</Paragraph>
|
||||
</Text>
|
||||
</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 {StyleProp, TextStyle} from 'react-native';
|
||||
import {formatDistance} from 'date-fns/formatDistance';
|
||||
import {Paragraph} from "react-native-paper";
|
||||
|
||||
interface Props {
|
||||
dateTo: Date;
|
||||
@@ -28,11 +28,11 @@ const TimeAgo: React.FC<Props> = ({
|
||||
}, [dateFrom, updateInterval]);
|
||||
|
||||
return (
|
||||
<Paragraph {...{style}}>
|
||||
<Text variant="bodyMedium" {...{style}}>
|
||||
{formatDistance(dateTo, dateFrom || currentDate, {
|
||||
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);
|
||||
|
||||
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 = [
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import {Realm} from "realm";
|
||||
|
||||
export interface AMCardData {
|
||||
code: string;
|
||||
name: string;
|
||||
import {ICType} from "../lib/iccard";
|
||||
|
||||
added: Date;
|
||||
lastUsed?: Date | null;
|
||||
}
|
||||
import {AMCardData} from "./AMCardData";
|
||||
import {AMCardRecentUse} from "./AMCardRecentUse";
|
||||
import {AMPhysicalCardData} from "./AMPhysicalCardData";
|
||||
|
||||
export class AMCard extends Realm.Object<AMCard> implements AMCardData {
|
||||
_id!: Realm.BSON.ObjectId;
|
||||
@@ -15,8 +13,15 @@ export class AMCard extends Realm.Object<AMCard> implements AMCardData {
|
||||
name: string;
|
||||
|
||||
added: Date;
|
||||
lastUsed?: Date;
|
||||
lastUsedGameId?: string | null;
|
||||
lastUsed: Date;
|
||||
|
||||
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) {
|
||||
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 = {
|
||||
name: "card",
|
||||
primaryKey: "_id",
|
||||
@@ -34,10 +51,12 @@ export class AMCard extends Realm.Object<AMCard> implements AMCardData {
|
||||
properties: {
|
||||
_id: "objectId",
|
||||
code: "string",
|
||||
name: {type: "string", default: "New Card"},
|
||||
added: {type: "date", default: new Date()},
|
||||
lastUsed: {type: "date", optional: true},
|
||||
lastUsedGameId: {type: "string", optional: true}
|
||||
name: {type: "string", default: "New Card", indexed: true},
|
||||
added: {type: "date", default: new Date(), indexed: true},
|
||||
lastUsed: {type: "date", optional: true, indexed: 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}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -12,7 +12,9 @@ export class AMServer extends Realm.Object<AMServer> implements AMServerConnecti
|
||||
|
||||
lastUsed: Date;
|
||||
createdAt: Date;
|
||||
|
||||
|
||||
preferredCardIdFormat?: "idm" | "access" | null;
|
||||
|
||||
get shareUrl() {
|
||||
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},
|
||||
gameId: {type: "string", optional: true},
|
||||
serverAddress: "string",
|
||||
serverName: {type: "string", default: "New Server"},
|
||||
serverName: {type: "string", default: "New Server", indexed: "full-text"},
|
||||
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 {AMCard} from "./AMCard";
|
||||
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
|
||||
export default createRealmContext({
|
||||
schemaVersion: 3,
|
||||
schema: [AMCard, AMServer],
|
||||
deleteRealmIfMigrationNeeded: false
|
||||
});
|
||||
schemaVersion: 5,
|
||||
schema: [AMPhysicalCardData, AMCardRecentUse, AMCard, AMServer],
|
||||
onMigration: migrationFunction,
|
||||
deleteRealmIfMigrationNeeded: false,
|
||||
});
|
||||
|
||||
Generated
+2179
-5385
File diff suppressed because it is too large
Load Diff
+36
-33
@@ -1,45 +1,48 @@
|
||||
{
|
||||
"name": "amnet-native",
|
||||
"version": "1.1.0",
|
||||
"version": "1.3.9",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"ios-prebuild": "expo prebuild --platform ios",
|
||||
"postinstall": "patch-package"
|
||||
"android-prebuild": "expo prebuild --platform android"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo-google-fonts/jetbrains-mono": "^0.2.3",
|
||||
"@react-navigation/bottom-tabs": "^6.6.1",
|
||||
"@react-navigation/native": "^6.1.18",
|
||||
"@react-navigation/native-stack": "^6.11.0",
|
||||
"@realm/react": "^0.11.0",
|
||||
"@types/react": "~18.3.12",
|
||||
"@babel/runtime": "^7.28.4",
|
||||
"@expo-google-fonts/jetbrains-mono": "^0.4.1",
|
||||
"@react-native-menu/menu": "^2.0.0",
|
||||
"@react-navigation/bottom-tabs": "^7.9.0",
|
||||
"@react-navigation/native": "^7.1.26",
|
||||
"@react-navigation/native-stack": "^7.9.0",
|
||||
"@realm/react": "^0.20.0",
|
||||
"amnet-shared": "file:../amnet-shared",
|
||||
"date-fns": "^3.6.0",
|
||||
"expo": "^52.0.0",
|
||||
"expo-blur": "~14.0.1",
|
||||
"expo-build-properties": "^0.13.1",
|
||||
"expo-clipboard": "~7.0.0",
|
||||
"expo-constants": "~17.0.3",
|
||||
"expo-dev-client": "~5.0.4",
|
||||
"expo-linking": "~7.0.3",
|
||||
"expo-status-bar": "~2.0.0",
|
||||
"expo-system-ui": "~4.0.4",
|
||||
"lucide-react-native": "^0.427.0",
|
||||
"patch-package": "^8.0.0",
|
||||
"react": "18.3.1",
|
||||
"react-native": "0.76.5",
|
||||
"react-native-context-menu-view": "^1.16.0",
|
||||
"react-native-gesture-handler": "~2.20.2",
|
||||
"react-native-get-random-values": "^1.11.0",
|
||||
"react-native-paper": "^5.12.5",
|
||||
"react-native-reanimated": "~3.16.1",
|
||||
"react-native-safe-area-context": "4.12.0",
|
||||
"react-native-screens": "~4.1.0",
|
||||
"react-native-svg": "15.8.0",
|
||||
"react-native-url-polyfill": "^2.0.0",
|
||||
"realm": "^12.14.0",
|
||||
"typescript": "~5.3.3"
|
||||
"babel-preset-expo": "^54.0.9",
|
||||
"date-fns": "^4.1.0",
|
||||
"expo": "^54.0.30",
|
||||
"expo-build-properties": "~1.0.10",
|
||||
"expo-clipboard": "~8.0.8",
|
||||
"expo-dev-client": "~6.0.20",
|
||||
"expo-linking": "~8.0.11",
|
||||
"expo-splash-screen": "~31.0.13",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"lucide-react-native": "^0.562.0",
|
||||
"pusher-js": "^8.4.0",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-context-menu-view": "^1.21.0",
|
||||
"react-native-gesture-handler": "^2.30.0",
|
||||
"react-native-get-random-values": "^2.0.0",
|
||||
"react-native-paper": "^5.14.5",
|
||||
"react-native-reanimated": "~4.2.1",
|
||||
"react-native-safe-area-context": "5.6.2",
|
||||
"react-native-screens": "~4.19.0",
|
||||
"react-native-svg": "15.15.1",
|
||||
"react-native-url-polyfill": "^3.0.0",
|
||||
"realm": "^20.1.0"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -13,7 +13,14 @@ const modalStyles = StyleSheet.create({
|
||||
},
|
||||
formItemDivider: {
|
||||
marginVertical: 15
|
||||
}
|
||||
},
|
||||
rootLayout: {
|
||||
display: "flex",
|
||||
gap: 10,
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: 15,
|
||||
paddingTop: 25
|
||||
},
|
||||
});
|
||||
|
||||
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;
|
||||
interface AMCardInRequest {
|
||||
cardId: string;
|
||||
physicalCardIDm?: string;
|
||||
}
|
||||
/**
|
||||
* 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 const CARD_ID_REGEX: RegExp;
|
||||
declare const AMNET_COMPANY_ID = 597;
|
||||
declare function generateCardNumber(): string;
|
||||
/**
|
||||
* 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;
|
||||
|
||||
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;
|
||||
interface AMCardInRequest {
|
||||
cardId: string;
|
||||
physicalCardIDm?: string;
|
||||
}
|
||||
/**
|
||||
* 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 const CARD_ID_REGEX: RegExp;
|
||||
declare const AMNET_COMPANY_ID = 597;
|
||||
declare function generateCardNumber(): string;
|
||||
/**
|
||||
* 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;
|
||||
|
||||
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
+145
-70
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
+129
-67
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
+575
-751
File diff suppressed because it is too large
Load Diff
@@ -16,7 +16,12 @@
|
||||
".": "./dist/index.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsup": "^8.2.4",
|
||||
"typescript": "^5.5.4"
|
||||
"@types/lodash": "^4.17.21",
|
||||
"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 {
|
||||
cardId: string;
|
||||
physicalCardIDm?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,7 +52,7 @@ export async function performCardIn(server: AMServerConnectionInfo, request: AMC
|
||||
if (server.apiVersion < MIN_AMNET_API_VERSION || server.apiVersion > AMNET_API_VERSION) {
|
||||
throw new Error("Unsupported server API version");
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const response = await fetch(`${new URL(server.serverAddress).origin}/amnet/signin`, {
|
||||
method: "POST",
|
||||
@@ -78,7 +79,7 @@ export async function fetchServerInfo(serverAddress: string, abort: AbortSignal)
|
||||
const response = await fetch(`${new URL(serverAddress).origin}/amnet/info`, {
|
||||
signal: abort
|
||||
});
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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 AMNET_COMPANY_ID = 597;
|
||||
|
||||
export function generateCardNumber(): string {
|
||||
let cardId = '';
|
||||
|
||||
while (true) {
|
||||
// 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;
|
||||
// serial between 5000 and 99999999999
|
||||
const serial = Math.floor(Math.random() * 99999994999) + 5000;
|
||||
return encodeAccessCode(AMNET_COMPANY_ID, serial, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,17 +4,37 @@
|
||||
*/
|
||||
export function getGameName(id: string): string | null {
|
||||
switch (id?.toUpperCase()) {
|
||||
case "SDGT":
|
||||
case "SDGT": // IDAC JPN
|
||||
case "SDJJ": // IDAC CHN
|
||||
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";
|
||||
|
||||
case "SDEZ": // maimai DX
|
||||
case "SDGA": // maimai DX INTL
|
||||
case "SDGB": // maimai DX CHN
|
||||
return "maimai DX";
|
||||
|
||||
case "SDDT":
|
||||
return "O.N.G.E.K.I.";
|
||||
case "SDEZ":
|
||||
return "maimai DX";
|
||||
|
||||
case "SDED":
|
||||
return "Card Maker";
|
||||
|
||||
case "SBZV":
|
||||
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:
|
||||
return null;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export * from './api';
|
||||
export * from './card';
|
||||
export * from './gameIds';
|
||||
export * from './gameIds';
|
||||
|
||||
export {decodeAccessCode as DecodeAMAccessCode, encodeAccessCode as EncodeAMAccessCode} from './card-encoding';
|
||||
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2016",
|
||||
"baseUrl": ".",
|
||||
"target": "ES2017",
|
||||
"module": "CommonJS",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"skipLibCheck": true,
|
||||
"baseUrl": "."
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
]
|
||||
}
|
||||
|
||||
Generated
+2455
-2183
File diff suppressed because it is too large
Load Diff
+39
-39
@@ -10,49 +10,49 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.1",
|
||||
"@radix-ui/react-context-menu": "^2.2.1",
|
||||
"@radix-ui/react-dialog": "^1.1.1",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-toast": "^1.2.1",
|
||||
"@radix-ui/react-tooltip": "^1.1.2",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.1.4",
|
||||
"@radix-ui/react-toast": "^1.2.15",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"amnet-shared": "file:../amnet-shared",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"core-js": "^3.39.0",
|
||||
"detectincognitojs": "^1.3.5",
|
||||
"dexie": "^4.0.8",
|
||||
"dexie-react-hooks": "^1.1.7",
|
||||
"lucide-react": "^0.427.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.52.2",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"react-timeago": "^7.2.0",
|
||||
"tailwind-merge": "^2.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vaul": "^0.9.1",
|
||||
"zod": "^3.23.8"
|
||||
"core-js": "^3.47.0",
|
||||
"detectincognitojs": "^1.6.2",
|
||||
"dexie": "^4.2.1",
|
||||
"dexie-react-hooks": "^4.2.0",
|
||||
"lucide-react": "^0.562.0",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-hook-form": "^7.69.0",
|
||||
"react-router-dom": "^7.11.0",
|
||||
"react-timeago": "^8.3.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.8.0",
|
||||
"@types/node": "^22.2.0",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@eslint/js": "^9.23.0",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-timeago": "^4.1.7",
|
||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.8.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.9",
|
||||
"globals": "^15.9.0",
|
||||
"postcss": "^8.4.41",
|
||||
"tailwindcss": "^3.4.9",
|
||||
"typescript": "^5.5.3",
|
||||
"typescript-eslint": "^8.0.0",
|
||||
"vite": "^5.4.6"
|
||||
"@vitejs/plugin-react-swc": "^4.2.2",
|
||||
"eslint": "^9.23.0",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.26",
|
||||
"globals": "^16.5.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.51.0",
|
||||
"vite": "^7.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
{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"/>}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -5,7 +5,7 @@ export function AMHeader(props: { title: string, children?: Readonly<React.React
|
||||
return (
|
||||
<>
|
||||
<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}
|
||||
</div>
|
||||
<Separator/>
|
||||
|
||||
@@ -98,10 +98,9 @@ function AimeCardCreationForm(props: { callback?: () => void }) {
|
||||
<FormControl>
|
||||
<Input placeholder="0000 0000 0000 0000 0000" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<FormDescription className="select-none">
|
||||
If you've got a card, enter the matrix code here. If not, you can
|
||||
<span className="font-bold hover:cursor-pointer ml-1"
|
||||
onClick={() => form.setValue("id", generateCardNumber())}>
|
||||
<span className="font-bold hover:cursor-pointer ml-1" onClick={() => form.setValue("id", generateCardNumber())}>
|
||||
generate a new one.
|
||||
</span>
|
||||
</FormDescription>
|
||||
|
||||
@@ -2,7 +2,7 @@ import {Loader} from "lucide-react";
|
||||
|
||||
export function LoadingIndicator() {
|
||||
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"/>
|
||||
<span className="text-center">Loading...</span>
|
||||
</div>
|
||||
|
||||
@@ -10,37 +10,39 @@ const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
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 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
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
))
|
||||
)
|
||||
}
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||
|
||||
const AlertDialogHeader = ({
|
||||
@@ -71,57 +73,61 @@ const AlertDialogFooter = ({
|
||||
)
|
||||
AlertDialogFooter.displayName = "AlertDialogFooter"
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
AlertDialogDescription.displayName =
|
||||
AlertDialogPrimitive.Description.displayName
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Action
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"mt-2 sm:mt-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"mt-2 sm:mt-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||
|
||||
export {
|
||||
|
||||
@@ -19,41 +19,45 @@ const alertVariants = cva(
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
function AlertTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||
return (
|
||||
<h5
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
|
||||
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
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: {
|
||||
variant: {
|
||||
@@ -39,18 +39,21 @@ export interface ButtonProps
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
|
||||
@@ -2,78 +2,85 @@ import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
function Card({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
function CardHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
function CardTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||
return (
|
||||
<h3
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
function CardDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||
return (
|
||||
<p
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
function CardContent({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div className={cn("p-6 pt-0", className)} {...props} />
|
||||
)
|
||||
}
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
function CardFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
|
||||
@@ -16,151 +16,166 @@ const ContextMenuSub = ContextMenuPrimitive.Sub
|
||||
|
||||
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
|
||||
|
||||
const ContextMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
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",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
))
|
||||
function ContextMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
className={cn(
|
||||
"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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const ContextMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
|
||||
|
||||
const ContextMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Content
|
||||
ref={ref}
|
||||
function ContextMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubContent
|
||||
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 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
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
))
|
||||
)
|
||||
}
|
||||
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
|
||||
|
||||
function ContextMenuContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Content
|
||||
className={cn(
|
||||
"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
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
|
||||
|
||||
const ContextMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Item
|
||||
ref={ref}
|
||||
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",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
function ContextMenuItem({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Item
|
||||
className={cn(
|
||||
"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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
|
||||
|
||||
const ContextMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
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",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
function ContextMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
className={cn(
|
||||
"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
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
ContextMenuCheckboxItem.displayName =
|
||||
ContextMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const ContextMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
))
|
||||
function ContextMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
className={cn(
|
||||
"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
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const ContextMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold text-foreground",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
function ContextMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Label
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold text-foreground",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
|
||||
|
||||
const ContextMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
function ContextMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Separator
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
|
||||
|
||||
const ContextMenuShortcut = ({
|
||||
|
||||
@@ -12,43 +12,46 @@ const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
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 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
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{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">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
/>
|
||||
)
|
||||
}
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<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" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
@@ -79,31 +82,33 @@ const DialogFooter = ({
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
|
||||
@@ -20,37 +20,40 @@ const DrawerPortal = DrawerPrimitive.Portal
|
||||
|
||||
const DrawerClose = DrawerPrimitive.Close
|
||||
|
||||
const DrawerOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn("fixed inset-0 z-50 bg-black/80", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
function DrawerOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
||||
return (
|
||||
<DrawerPrimitive.Overlay
|
||||
className={cn("fixed inset-0 z-50 bg-black/80", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
|
||||
|
||||
const DrawerContent = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DrawerPortal>
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
))
|
||||
function DrawerContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
|
||||
return (
|
||||
<DrawerPortal>
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
className={cn(
|
||||
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
)
|
||||
}
|
||||
DrawerContent.displayName = "DrawerContent"
|
||||
|
||||
const DrawerHeader = ({
|
||||
@@ -75,31 +78,33 @@ const DrawerFooter = ({
|
||||
)
|
||||
DrawerFooter.displayName = "DrawerFooter"
|
||||
|
||||
const DrawerTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
function DrawerTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||
return (
|
||||
<DrawerPrimitive.Title
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
|
||||
|
||||
const DrawerDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
function DrawerDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||
return (
|
||||
<DrawerPrimitive.Description
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
|
||||
@@ -70,46 +70,43 @@ const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
|
||||
const FormItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
function FormItem({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
||||
<div className={cn("space-y-2", className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
})
|
||||
}
|
||||
FormItem.displayName = "FormItem"
|
||||
|
||||
const FormLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
function FormLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
ref={ref}
|
||||
className={cn(error && "text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
FormLabel.displayName = "FormLabel"
|
||||
|
||||
const FormControl = React.forwardRef<
|
||||
React.ElementRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({ ...props }, ref) => {
|
||||
function FormControl({
|
||||
...props
|
||||
}: React.ComponentProps<typeof Slot>) {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
@@ -120,30 +117,30 @@ const FormControl = React.forwardRef<
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
FormControl.displayName = "FormControl"
|
||||
|
||||
const FormDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
function FormDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
FormDescription.displayName = "FormDescription"
|
||||
|
||||
const FormMessage = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
function FormMessage({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message) : children
|
||||
|
||||
@@ -153,7 +150,6 @@ const FormMessage = React.forwardRef<
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formMessageId}
|
||||
className={cn("text-sm font-medium text-destructive", className)}
|
||||
{...props}
|
||||
@@ -161,7 +157,7 @@ const FormMessage = React.forwardRef<
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
})
|
||||
}
|
||||
FormMessage.displayName = "FormMessage"
|
||||
|
||||
export {
|
||||
|
||||
@@ -5,21 +5,22 @@ import { cn } from "@/lib/utils"
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
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",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
function Input({
|
||||
className,
|
||||
type,
|
||||
...props
|
||||
}: InputProps) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
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-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react"
|
||||
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"
|
||||
|
||||
@@ -8,17 +8,17 @@ const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
|
||||
@@ -3,27 +3,25 @@ import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref
|
||||
) => (
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"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
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
}
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
|
||||
export { Separator }
|
||||
|
||||
@@ -7,23 +7,24 @@ import { cn } from "@/lib/utils"
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
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]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
function ToastViewport({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToastPrimitives.Viewport>) {
|
||||
return (
|
||||
<ToastPrimitives.Viewport
|
||||
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]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
|
||||
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: {
|
||||
variant: {
|
||||
@@ -38,76 +39,79 @@ const toastVariants = cva(
|
||||
}
|
||||
)
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
function Toast({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>) {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
Toast.displayName = ToastPrimitives.Root.displayName
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
function ToastAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToastPrimitives.Action>) {
|
||||
return (
|
||||
<ToastPrimitives.Action
|
||||
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-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
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
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",
|
||||
className
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
))
|
||||
function ToastClose({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToastPrimitives.Close>) {
|
||||
return (
|
||||
<ToastPrimitives.Close
|
||||
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-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
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
)
|
||||
}
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
function ToastTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToastPrimitives.Title>) {
|
||||
return (
|
||||
<ToastPrimitives.Title
|
||||
className={cn("text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
function ToastDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToastPrimitives.Description>) {
|
||||
return (
|
||||
<ToastPrimitives.Description
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||
|
||||
@@ -9,20 +9,22 @@ const Tooltip = TooltipPrimitive.Root
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Content
|
||||
sideOffset={sideOffset}
|
||||
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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
|
||||
+136
-55
@@ -1,62 +1,143 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import 'tailwindcss';
|
||||
@import 'tw-animate-css';
|
||||
|
||||
@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 {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--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%;
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentcolor);
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
:root {
|
||||
--background: hsl(0 0% 100%);
|
||||
--foreground: hsl(222.2 84% 4.9%);
|
||||
--card: hsl(0 0% 100%);
|
||||
--card-foreground: hsl(222.2 84% 4.9%);
|
||||
--popover: hsl(0 0% 100%);
|
||||
--popover-foreground: hsl(222.2 84% 4.9%);
|
||||
--primary: hsl(222.2 47.4% 11.2%);
|
||||
--primary-foreground: hsl(210 40% 98%);
|
||||
--secondary: hsl(210 40% 96.1%);
|
||||
--secondary-foreground: hsl(222.2 47.4% 11.2%);
|
||||
--muted: hsl(210 40% 96.1%);
|
||||
--muted-foreground: hsl(215.4 16.3% 46.9%);
|
||||
--accent: hsl(210 40% 96.1%);
|
||||
--accent-foreground: hsl(222.2 47.4% 11.2%);
|
||||
--destructive: hsl(0 84.2% 60.2%);
|
||||
--destructive-foreground: hsl(210 40% 98%);
|
||||
--border: hsl(214.3 31.8% 91.4%);
|
||||
--input: hsl(214.3 31.8% 91.4%);
|
||||
--ring: hsl(222.2 84% 4.9%);
|
||||
--radius: 0.5rem;
|
||||
--chart-1: hsl(12 76% 61%);
|
||||
--chart-2: hsl(173 58% 39%);
|
||||
--chart-3: hsl(197 37% 24%);
|
||||
--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 {
|
||||
|
||||
@@ -46,11 +46,17 @@ const router = createHashRouter([
|
||||
}
|
||||
])
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider defaultTheme="system" storageKey="vite-ui-theme">
|
||||
<RouterProvider router={router}/>
|
||||
<Toaster/>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App/>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -66,7 +66,7 @@ function CardList(props: { cards: ReadonlyArray<AimeCard> | null, cardSelected:
|
||||
return (<>
|
||||
<h4 className="text-2xl font-bold">{props.cards?.length ? "Cards" : "Additional Cards"}</h4>
|
||||
<div className="flex gap-2">
|
||||
<CardCreationDialog className="flex-grow"/>
|
||||
<CardCreationDialog className="grow"/>
|
||||
<Button onClick={() => navigate('/cards')} variant="outline" size="icon">
|
||||
<Wallet className="h-4 w-4"/>
|
||||
</Button>
|
||||
@@ -187,7 +187,7 @@ function ServerCardInPage() {
|
||||
<AlertPanel server={server} connectionError={connectionError} onReloadRequested={() => setServerReloadCounter(serverReloadCounter + 1)}/>
|
||||
|
||||
{server && (
|
||||
<div className="flex flex-col gap-4 w-full flex-grow">
|
||||
<div className="flex flex-col gap-4 w-full grow">
|
||||
{selectedCard && (<>
|
||||
<h4 className="text-2xl font-bold">{gameName ? `Play ${gameName}` : "Selected Card"}</h4>
|
||||
|
||||
@@ -197,7 +197,7 @@ function ServerCardInPage() {
|
||||
</Button>
|
||||
</AimeCardEntry>
|
||||
|
||||
<Button className="flex-grow-0 bg-green-600 mb-5"
|
||||
<Button className="grow-0 bg-green-600 mb-5"
|
||||
disabled={(cardInStatus || connectionError || !server)}
|
||||
onClick={() => performCardInAction(selectedCard, server as AimeServer, setCardInStatus, toast)}>
|
||||
{cardInStatus ? "Carding in..." : <>Card In <Nfc className="ml-2 h-4 w-4"/></>}
|
||||
|
||||
@@ -53,7 +53,7 @@ function CardManagerPage() {
|
||||
</Button>
|
||||
</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/>}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ function PrivacyPolicy() {
|
||||
</Button>
|
||||
</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>
|
||||
<p>The AMNet card switcher collects and stores the following information locally (on-device):</p>
|
||||
<ul className="list-disc">
|
||||
|
||||
@@ -43,7 +43,7 @@ function ServerListing() {
|
||||
</Button>
|
||||
</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}/>
|
||||
</div>
|
||||
|
||||
@@ -172,7 +172,7 @@ function ServerAddForm() {
|
||||
control={form.control}
|
||||
name="address"
|
||||
render={({field}) => (
|
||||
<FormItem className="flex-grow">
|
||||
<FormItem className="grow">
|
||||
<FormControl>
|
||||
<Input {...field}
|
||||
placeholder="Server Address (http://192.168.1.1:6070)"
|
||||
@@ -183,7 +183,7 @@ function ServerAddForm() {
|
||||
)}
|
||||
/>
|
||||
<Button type="submit" disabled={blockSubmit} variant="outline" size="icon"
|
||||
className="flex-shrink-0">
|
||||
className="shrink-0">
|
||||
<Plus className="h-4 w-4"/>
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import * as animate from "tailwindcss-animate";
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
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: [animate],
|
||||
}
|
||||
@@ -1,20 +1,23 @@
|
||||
<div align="center">
|
||||
|
||||
# AMNet
|
||||
[TestFlight](https://testflight.apple.com/join/kHbeJTsQ) • [Firebase](https://appdistribution.firebase.dev/i/6acc3151357deaa8) • [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>
|
||||
|
||||
## Overview
|
||||
AMNet is designed to simulate the `DEVICE/aime.txt` file, allowing multiple users to log into a game using their phone.
|
||||
This was originally intended for use with maimai and Chunithm, but should be compatible with any segatools-based game with Aime support.
|
||||
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 intended for use with maimai and CHUNITHM, but should be compatible with any segatools-based game with that uses Amusement IC for account management.
|
||||
|
||||
> [!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
|
||||
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
|
||||
[aimeio]
|
||||
; To use a custom card reader IO DLL enter its path here.
|
||||
@@ -24,15 +27,29 @@ This was originally intended for use with maimai and Chunithm, but should be com
|
||||
; AMNet-specific options (all optional)
|
||||
; 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)
|
||||
; 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).
|
||||
; 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.
|
||||
gameId=SDEZ
|
||||
;serverName=CHUNI-PENGUIN
|
||||
;serverAddress=http://+:6070
|
||||
;serverAddress=http://0.0.0.0:6070
|
||||
;enableKeyboardMode=1
|
||||
;useAimeDBForPhysicalCards=1
|
||||
```
|
||||
4. Launch the game, and check the amdaemon console for server startup messages
|
||||
5. Access the using one of the interfaces (see below), add a new server `http://<your-ip-address>:6070`, 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!
|
||||
|
||||
### Physical Cards
|
||||
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.
|
||||
By changing the `useAimeDBForPhysicalCards` in segatools.ini, you can choose how the card is read on a per-game basis:
|
||||
|
||||
- `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.
|
||||
|
||||
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).
|
||||
|
||||
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.
|
||||
|
||||
## Interfaces
|
||||
AMNet can be used on most platforms, see below for options available for each platform:
|
||||
@@ -46,8 +63,8 @@ AMNet can be used on most platforms, see below for options available for each pl
|
||||
</tr>
|
||||
<tr>
|
||||
<td>iOS</td>
|
||||
<td><a href="https://testflight.apple.com/join/kHbeJTsQ" target="_blank">TestFlight</a></td>
|
||||
<td></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>
|
||||
@@ -71,18 +88,17 @@ AMNet can be used on most platforms, see below for options available for each pl
|
||||
</div>
|
||||
|
||||
## Building
|
||||
### Server (AMNet.Server)
|
||||
Building the server from source requires the following dependencies:
|
||||
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**.
|
||||
### Server (amnet-server and amnet-server-test)
|
||||
Building the server from source requires a recent version of **Rust** in
|
||||
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.
|
||||
Building the server without publish mode will result in a DLL that cannot be run by the game.
|
||||
After cloning the repository, the server can be built using cargo, specifying a target if an x86 version is desired.
|
||||
|
||||
```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\net9.0-windows\win-x64\publish\amnet.dll`.
|
||||
# find the .dll at target/release/amnet.dll
|
||||
```
|
||||
|
||||
### Website (amnet-web)
|
||||
The web interface is built using Vite and React. Building the web interface requires Node.js (or alternative runtime).
|
||||
|
||||
Reference in New Issue
Block a user