mirror of
https://gitea.tendokyu.moe/ppc/amnet.git
synced 2026-09-22 22:28:22 +03:00
Merge branch 'add-server-metrics'
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '^v?(?:[0-9]+.?){1,4}$'
|
||||
# push:
|
||||
# tags:
|
||||
# - '^v?(?:[0-9]+.?){1,4}$'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace AMNet.Server;
|
||||
/// <summary>
|
||||
/// Represents a storage location to load/read cards from
|
||||
/// </summary>
|
||||
public class CardPresenter
|
||||
internal class CardPresenter
|
||||
{
|
||||
private readonly object _cardLock = new();
|
||||
private StoredCard _currentCard;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
@@ -13,6 +14,10 @@ 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();
|
||||
@@ -38,6 +43,7 @@ public static class DllMain
|
||||
cancellationRegistration?.Dispose();
|
||||
});
|
||||
|
||||
Volatile.Write(ref ServerStartedAt, Environment.TickCount64);
|
||||
App.RunAsync();
|
||||
return 0;
|
||||
}
|
||||
@@ -45,6 +51,8 @@ public static class DllMain
|
||||
[UnmanagedCallersOnly(EntryPoint = "aime_io_nfc_poll")]
|
||||
public static int NfcPoll(byte unitNo)
|
||||
{
|
||||
Volatile.Write(ref LastPollTime, Environment.TickCount64);
|
||||
|
||||
if (!Config.EnableAimeTxt)
|
||||
{
|
||||
return 1;
|
||||
@@ -83,7 +91,7 @@ public static class DllMain
|
||||
}
|
||||
|
||||
[UnmanagedCallersOnly(EntryPoint = "aime_io_nfc_get_aime_id")]
|
||||
public static int GetAimeId(byte unitNo, nint luid, nint luidSize)
|
||||
public static int GetAimeId(byte unitNo, IntPtr luid, nint luidSize)
|
||||
{
|
||||
if (unitNo != 0)
|
||||
{
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AMNet.Server;
|
||||
|
||||
public record SystemState(
|
||||
[property: JsonPropertyName("apiVersion")] int ApiVersion,
|
||||
[property: JsonPropertyName("gameId")] string GameId,
|
||||
[property: JsonPropertyName("serverName")] string ServerName);
|
||||
|
||||
public record CardReadRequest(
|
||||
[property: JsonPropertyName("cardId")] string MatrixCode);
|
||||
|
||||
[JsonSerializable(typeof(SystemState))]
|
||||
[JsonSerializable(typeof(CardReadRequest))]
|
||||
[JsonSourceGenerationOptions(WriteIndented = true)]
|
||||
public partial class SerializerContext : JsonSerializerContext;
|
||||
@@ -1,6 +1,8 @@
|
||||
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;
|
||||
@@ -10,10 +12,20 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AMNet.Server;
|
||||
|
||||
internal static class WebServer
|
||||
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([]);
|
||||
@@ -53,12 +65,28 @@ internal static class WebServer
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseCors();
|
||||
app.MapGet("/amnet/info", () => Results.Ok(new SystemState(ApiVersion, Config.GameId, Config.ServerName)));
|
||||
|
||||
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;
|
||||
@@ -111,4 +139,9 @@ internal static class WebServer
|
||||
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,7 +1,7 @@
|
||||
{
|
||||
"name": "AMNet",
|
||||
"scheme": "amnet",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"slug": "amnet-native",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "automatic",
|
||||
|
||||
@@ -103,7 +103,7 @@ export default function CardAddModal({navigation, route}) {
|
||||
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.backdrop : undefined}}/>
|
||||
style={{...modalStyles.formItemInput, color: cardNumberReadOnly ? theme.colors.onSurfaceDisabled : undefined}}/>
|
||||
</View>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
|
||||
@@ -75,7 +75,7 @@ export default function CardManager({navigation}) {
|
||||
style={{padding: 16, paddingTop: 24}}
|
||||
data={cards}
|
||||
keyExtractor={i => i._id.toString()}
|
||||
contentContainerStyle={{paddingBottom: 40}}
|
||||
contentContainerStyle={{paddingBottom: 120}}
|
||||
ItemSeparatorComponent={() => <View style={{height: 15}}/>}
|
||||
renderItem={i => <CardEntry card={i.item} navigation={navigation}/>}/>
|
||||
)
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
import {BSON} from "realm";
|
||||
import {fetchServerInfo, getGameName, performCardIn} from "amnet-shared";
|
||||
import {useSafeAreaInsets} from "react-native-safe-area-context";
|
||||
import React, {useCallback, useEffect, useLayoutEffect, useMemo, useState} from "react";
|
||||
import {ActivityIndicator, Button, Paragraph, Title, useTheme} from "react-native-paper";
|
||||
import {Alert, RefreshControl, ScrollView, Share, StyleSheet, TouchableOpacity, View} from "react-native";
|
||||
import {
|
||||
CircleCheck,
|
||||
CircleDot,
|
||||
CreditCard,
|
||||
Nfc,
|
||||
Plus,
|
||||
SmartphoneNfcIcon,
|
||||
Share as ShareIcon,
|
||||
WifiOff,
|
||||
CircleCheck, CircleDot, CreditCard, Nfc, Plus, SmartphoneNfcIcon,
|
||||
Share as ShareIcon, WifiOff, TvMinimalPlay
|
||||
} from "lucide-react-native";
|
||||
|
||||
import {EmptyStatePlaceholder} from "../components/EmptyStatePlaceholder";
|
||||
@@ -22,12 +16,20 @@ import RealmContext from "../models/RealmContext";
|
||||
import {AMServer} from "../models/AMServer";
|
||||
import {AMCard} from "../models/AMCard";
|
||||
import ObjectId = BSON.ObjectId;
|
||||
import {
|
||||
AMServerConnectionInfo,
|
||||
AMServerInstanceMetrics,
|
||||
fetchServerInfo,
|
||||
getGameName,
|
||||
performCardIn
|
||||
} from "amnet-shared";
|
||||
|
||||
const {useRealm, useQuery, useObject} = RealmContext;
|
||||
|
||||
export default function ServerCardIn({route, navigation}) {
|
||||
const realm = useRealm();
|
||||
const theme = useTheme();
|
||||
const safeArea = useSafeAreaInsets();
|
||||
|
||||
// card listing state
|
||||
const query = useQuery(AMCard);
|
||||
@@ -61,7 +63,7 @@ export default function ServerCardIn({route, navigation}) {
|
||||
const server = useObject(AMServer, ObjectId.createFromHexString(serverId));
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!server) {
|
||||
if (server?.isValid() !== true) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -75,16 +77,7 @@ export default function ServerCardIn({route, navigation}) {
|
||||
});
|
||||
}, [navigation, server]);
|
||||
|
||||
// set selected card (once)
|
||||
useEffect(() => {
|
||||
if (selectedCard?.isValid() === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedCard(cards.length ? cards[0] : null);
|
||||
}, [realm]);
|
||||
|
||||
// refresh server info (server change or reload request)
|
||||
// card selection, server connection checks
|
||||
useEffect(() => {
|
||||
if (server?.isValid() !== true) {
|
||||
navigation.goBack();
|
||||
@@ -97,6 +90,10 @@ export default function ServerCardIn({route, navigation}) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedCard?.isValid() !== true) {
|
||||
setSelectedCard(cards.length ? cards[0] : null);
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
fetchServerInfo(server.serverAddress, abortController.signal).then(r => {
|
||||
if (r) {
|
||||
@@ -143,6 +140,11 @@ export default function ServerCardIn({route, navigation}) {
|
||||
return () => abortController.abort();
|
||||
}, [server, selectedCard]);
|
||||
|
||||
const performRefresh = () => {
|
||||
setServerRefreshing(true);
|
||||
setServerReloadCounter(serverReloadCounter + 1);
|
||||
}
|
||||
|
||||
const gameName = useMemo(() => getGameName(server.gameId), [server.gameId]);
|
||||
const disableCardIn = useMemo(() => cardInCompleted !== null || !connectionTestResult, [cardInCompleted, connectionTestResult]);
|
||||
|
||||
@@ -158,12 +160,7 @@ export default function ServerCardIn({route, navigation}) {
|
||||
return (
|
||||
<ScrollView contentInsetAdjustmentBehavior="automatic"
|
||||
contentContainerStyle={{...styles.container, paddingTop: connectionTestResult ? 0 : 10}}
|
||||
refreshControl={<RefreshControl refreshing={serverRefreshing}
|
||||
tintColor={theme.colors.onSurface}
|
||||
onRefresh={() => {
|
||||
setServerRefreshing(true);
|
||||
setServerReloadCounter(serverReloadCounter + 1);
|
||||
}}/>}>
|
||||
refreshControl={<RefreshControl refreshing={serverRefreshing} tintColor={theme.colors.onSurface} onRefresh={performRefresh}/>}>
|
||||
<StatusAlert server={server} connectionEstablished={connectionTestResult}/>
|
||||
<View style={styles.section}>
|
||||
<Title style={styles.sectionTitle}>{gameName ? `Play ${gameName}` : "Selected Card"}</Title>
|
||||
@@ -218,17 +215,31 @@ export default function ServerCardIn({route, navigation}) {
|
||||
)
|
||||
}
|
||||
|
||||
function StatusAlert(props: {server: AMServer, connectionEstablished: boolean}) {
|
||||
function StatusAlert(props: {
|
||||
server: AMServerConnectionInfo & Partial<AMServerInstanceMetrics>,
|
||||
connectionEstablished: boolean
|
||||
}) {
|
||||
if (!props.connectionEstablished) {
|
||||
return (
|
||||
<AMUIStatusAlert color={"red"} icon={WifiOff} title={"Connection Failed"}>
|
||||
<AMUIStatusAlert color="red" icon={WifiOff} title="Connection Failed">
|
||||
<Paragraph>
|
||||
There was an issue connecting to <Paragraph
|
||||
style={{fontFamily: "JetBrainsMono_400Regular"}}>{props.server.serverAddress}</Paragraph>.
|
||||
Check the network connection and try again.
|
||||
</Paragraph>
|
||||
</AMUIStatusAlert>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
// don't show if undefined (some older server versions)
|
||||
if (props.server.timeSinceLastPoll === null) {
|
||||
return (
|
||||
<AMUIStatusAlert color="#822eff" icon={TvMinimalPlay} title="Server Startup">
|
||||
<Paragraph>
|
||||
Scanning a card below may not have any effect until the service has fully started up.
|
||||
</Paragraph>
|
||||
</AMUIStatusAlert>
|
||||
)
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -247,7 +258,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
container: {
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 25,
|
||||
paddingBottom: 80,
|
||||
gap: 20
|
||||
}
|
||||
});
|
||||
|
||||
@@ -8,8 +8,8 @@ import {AMUICard, AMUICardDetail} from "../components/AMUICard";
|
||||
import TimeAgo from "../components/TimeAgo";
|
||||
|
||||
import RealmContext from "../models/RealmContext";
|
||||
import {getGameName} from "../../amnet-shared";
|
||||
import {AMServer} from "../models/AMServer";
|
||||
import {getGameName} from "amnet-shared";
|
||||
|
||||
const {useRealm, useQuery} = RealmContext;
|
||||
|
||||
@@ -120,7 +120,7 @@ export default function ServerManager({navigation}) {
|
||||
data={servers}
|
||||
keyExtractor={i => i._id.toString()}
|
||||
style={{padding: 16, paddingTop: 24}}
|
||||
contentContainerStyle={{paddingBottom: 40}}
|
||||
contentContainerStyle={{paddingBottom: 80}}
|
||||
ItemSeparatorComponent={() => <View style={{height: 15}}/>}
|
||||
renderItem={i => <ServerCard navigation={navigation} server={i.item}/>}/>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {Realm} from 'realm';
|
||||
import {AMServerInfoResponse} from "amnet-shared";
|
||||
import {AMServerConnectionInfo, AMServerMetadata} from "amnet-shared";
|
||||
|
||||
export class AMServer extends Realm.Object<AMServer> implements AMServerInfoResponse {
|
||||
export class AMServer extends Realm.Object<AMServer> implements AMServerConnectionInfo, AMServerMetadata {
|
||||
_id!: Realm.BSON.ObjectId;
|
||||
|
||||
apiVersion: number;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "amnet-native",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"android": "expo run:android",
|
||||
|
||||
Vendored
+20
-5
@@ -2,23 +2,38 @@
|
||||
* The latest version of the AMNet API this library supports
|
||||
*/
|
||||
declare const AMNET_API_VERSION = 1;
|
||||
/**
|
||||
* The minimum version of the AMNet API this library supports
|
||||
*/
|
||||
declare const MIN_AMNET_API_VERSION = 1;
|
||||
interface AMCardInRequest {
|
||||
cardId: string;
|
||||
}
|
||||
/**
|
||||
* Information needed to perform a request against an AMNet server
|
||||
* Server connection info (api version, address, etc.)
|
||||
*/
|
||||
interface AMServerConnectionInfo {
|
||||
apiVersion: number;
|
||||
serverAddress: string;
|
||||
}
|
||||
/**
|
||||
* Received metadata from an AMNet server
|
||||
* User-definable server metadata
|
||||
*/
|
||||
interface AMServerInfoResponse extends AMServerConnectionInfo {
|
||||
interface AMServerMetadata {
|
||||
serverName: string;
|
||||
gameId?: string | null | undefined;
|
||||
}
|
||||
/**
|
||||
* Metrics about the current game session
|
||||
*/
|
||||
interface AMServerInstanceMetrics {
|
||||
timeSinceLastPoll?: number;
|
||||
sessionUptime?: number;
|
||||
}
|
||||
/**
|
||||
* The response from an AMNet server's info endpoint
|
||||
*/
|
||||
type AMServerInfo = AMServerMetadata & AMServerConnectionInfo & AMServerInstanceMetrics;
|
||||
/**
|
||||
* Performs the card-in request to the specified server
|
||||
* @param server The server to perform the request against
|
||||
@@ -31,7 +46,7 @@ declare function performCardIn(server: AMServerConnectionInfo, request: AMCardIn
|
||||
* @param serverAddress The origin address of the server to connect to
|
||||
* @param abort The abort signal to use for the request (for cancellation)
|
||||
*/
|
||||
declare function fetchServerInfo(serverAddress: string, abort: AbortSignal): Promise<AMServerInfoResponse | null>;
|
||||
declare function fetchServerInfo(serverAddress: string, abort: AbortSignal): Promise<AMServerInfo | null>;
|
||||
|
||||
declare const CARD_ID_REGEX: RegExp;
|
||||
declare function generateCardNumber(): string;
|
||||
@@ -47,4 +62,4 @@ declare function validateCardId(id: string): boolean;
|
||||
*/
|
||||
declare function getGameName(id: string): string | null;
|
||||
|
||||
export { type AMCardInRequest, AMNET_API_VERSION, type AMServerConnectionInfo, type AMServerInfoResponse, CARD_ID_REGEX, fetchServerInfo, generateCardNumber, getGameName, performCardIn, validateCardId };
|
||||
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 };
|
||||
|
||||
Vendored
+20
-5
@@ -2,23 +2,38 @@
|
||||
* The latest version of the AMNet API this library supports
|
||||
*/
|
||||
declare const AMNET_API_VERSION = 1;
|
||||
/**
|
||||
* The minimum version of the AMNet API this library supports
|
||||
*/
|
||||
declare const MIN_AMNET_API_VERSION = 1;
|
||||
interface AMCardInRequest {
|
||||
cardId: string;
|
||||
}
|
||||
/**
|
||||
* Information needed to perform a request against an AMNet server
|
||||
* Server connection info (api version, address, etc.)
|
||||
*/
|
||||
interface AMServerConnectionInfo {
|
||||
apiVersion: number;
|
||||
serverAddress: string;
|
||||
}
|
||||
/**
|
||||
* Received metadata from an AMNet server
|
||||
* User-definable server metadata
|
||||
*/
|
||||
interface AMServerInfoResponse extends AMServerConnectionInfo {
|
||||
interface AMServerMetadata {
|
||||
serverName: string;
|
||||
gameId?: string | null | undefined;
|
||||
}
|
||||
/**
|
||||
* Metrics about the current game session
|
||||
*/
|
||||
interface AMServerInstanceMetrics {
|
||||
timeSinceLastPoll?: number;
|
||||
sessionUptime?: number;
|
||||
}
|
||||
/**
|
||||
* The response from an AMNet server's info endpoint
|
||||
*/
|
||||
type AMServerInfo = AMServerMetadata & AMServerConnectionInfo & AMServerInstanceMetrics;
|
||||
/**
|
||||
* Performs the card-in request to the specified server
|
||||
* @param server The server to perform the request against
|
||||
@@ -31,7 +46,7 @@ declare function performCardIn(server: AMServerConnectionInfo, request: AMCardIn
|
||||
* @param serverAddress The origin address of the server to connect to
|
||||
* @param abort The abort signal to use for the request (for cancellation)
|
||||
*/
|
||||
declare function fetchServerInfo(serverAddress: string, abort: AbortSignal): Promise<AMServerInfoResponse | null>;
|
||||
declare function fetchServerInfo(serverAddress: string, abort: AbortSignal): Promise<AMServerInfo | null>;
|
||||
|
||||
declare const CARD_ID_REGEX: RegExp;
|
||||
declare function generateCardNumber(): string;
|
||||
@@ -47,4 +62,4 @@ declare function validateCardId(id: string): boolean;
|
||||
*/
|
||||
declare function getGameName(id: string): string | null;
|
||||
|
||||
export { type AMCardInRequest, AMNET_API_VERSION, type AMServerConnectionInfo, type AMServerInfoResponse, CARD_ID_REGEX, fetchServerInfo, generateCardNumber, getGameName, performCardIn, validateCardId };
|
||||
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 };
|
||||
|
||||
Vendored
+9
@@ -59,6 +59,7 @@ var src_exports = {};
|
||||
__export(src_exports, {
|
||||
AMNET_API_VERSION: () => AMNET_API_VERSION,
|
||||
CARD_ID_REGEX: () => CARD_ID_REGEX,
|
||||
MIN_AMNET_API_VERSION: () => MIN_AMNET_API_VERSION,
|
||||
fetchServerInfo: () => fetchServerInfo,
|
||||
generateCardNumber: () => generateCardNumber,
|
||||
getGameName: () => getGameName,
|
||||
@@ -69,8 +70,12 @@ module.exports = __toCommonJS(src_exports);
|
||||
|
||||
// src/api.ts
|
||||
var AMNET_API_VERSION = 1;
|
||||
var MIN_AMNET_API_VERSION = 1;
|
||||
function performCardIn(server, request, signal) {
|
||||
return __async(this, null, function* () {
|
||||
if (server.apiVersion < MIN_AMNET_API_VERSION || server.apiVersion > AMNET_API_VERSION) {
|
||||
throw new Error("Unsupported server API version");
|
||||
}
|
||||
try {
|
||||
const response = yield fetch(`${new URL(server.serverAddress).origin}/amnet/signin`, {
|
||||
method: "POST",
|
||||
@@ -92,6 +97,9 @@ function fetchServerInfo(serverAddress, abort) {
|
||||
const response = yield fetch(`${new URL(serverAddress).origin}/amnet/info`, {
|
||||
signal: abort
|
||||
});
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
return __spreadProps(__spreadValues({}, yield response.json()), {
|
||||
serverAddress
|
||||
});
|
||||
@@ -143,6 +151,7 @@ function getGameName(id) {
|
||||
0 && (module.exports = {
|
||||
AMNET_API_VERSION,
|
||||
CARD_ID_REGEX,
|
||||
MIN_AMNET_API_VERSION,
|
||||
fetchServerInfo,
|
||||
generateCardNumber,
|
||||
getGameName,
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+8
@@ -40,8 +40,12 @@ var __async = (__this, __arguments, generator) => {
|
||||
|
||||
// src/api.ts
|
||||
var AMNET_API_VERSION = 1;
|
||||
var MIN_AMNET_API_VERSION = 1;
|
||||
function performCardIn(server, request, signal) {
|
||||
return __async(this, null, function* () {
|
||||
if (server.apiVersion < MIN_AMNET_API_VERSION || server.apiVersion > AMNET_API_VERSION) {
|
||||
throw new Error("Unsupported server API version");
|
||||
}
|
||||
try {
|
||||
const response = yield fetch(`${new URL(server.serverAddress).origin}/amnet/signin`, {
|
||||
method: "POST",
|
||||
@@ -63,6 +67,9 @@ function fetchServerInfo(serverAddress, abort) {
|
||||
const response = yield fetch(`${new URL(serverAddress).origin}/amnet/info`, {
|
||||
signal: abort
|
||||
});
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
return __spreadProps(__spreadValues({}, yield response.json()), {
|
||||
serverAddress
|
||||
});
|
||||
@@ -113,6 +120,7 @@ function getGameName(id) {
|
||||
export {
|
||||
AMNET_API_VERSION,
|
||||
CARD_ID_REGEX,
|
||||
MIN_AMNET_API_VERSION,
|
||||
fetchServerInfo,
|
||||
generateCardNumber,
|
||||
getGameName,
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+32
-6
@@ -3,12 +3,17 @@
|
||||
*/
|
||||
export const AMNET_API_VERSION = 1;
|
||||
|
||||
/**
|
||||
* The minimum version of the AMNet API this library supports
|
||||
*/
|
||||
export const MIN_AMNET_API_VERSION = 1;
|
||||
|
||||
export interface AMCardInRequest {
|
||||
cardId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Information needed to perform a request against an AMNet server
|
||||
* Server connection info (api version, address, etc.)
|
||||
*/
|
||||
export interface AMServerConnectionInfo {
|
||||
apiVersion: number;
|
||||
@@ -16,20 +21,37 @@ export interface AMServerConnectionInfo {
|
||||
}
|
||||
|
||||
/**
|
||||
* Received metadata from an AMNet server
|
||||
* User-definable server metadata
|
||||
*/
|
||||
export interface AMServerInfoResponse extends AMServerConnectionInfo {
|
||||
export interface AMServerMetadata {
|
||||
serverName: string;
|
||||
gameId?: string | null | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Metrics about the current game session
|
||||
*/
|
||||
export interface AMServerInstanceMetrics {
|
||||
timeSinceLastPoll?: number;
|
||||
sessionUptime?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The response from an AMNet server's info endpoint
|
||||
*/
|
||||
export type AMServerInfo = AMServerMetadata & AMServerConnectionInfo & AMServerInstanceMetrics;
|
||||
|
||||
/**
|
||||
* Performs the card-in request to the specified server
|
||||
* @param server The server to perform the request against
|
||||
* @param request The card-in request to send
|
||||
* @param signal The abort signal to use for the request
|
||||
*/
|
||||
export async function performCardIn(server: AMServerConnectionInfo, request: AMCardInRequest, signal: AbortSignal) {
|
||||
export async function performCardIn(server: AMServerConnectionInfo, request: AMCardInRequest, signal: AbortSignal): Promise<boolean> {
|
||||
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",
|
||||
@@ -51,16 +73,20 @@ export async function performCardIn(server: AMServerConnectionInfo, request: AMC
|
||||
* @param serverAddress The origin address of the server to connect to
|
||||
* @param abort The abort signal to use for the request (for cancellation)
|
||||
*/
|
||||
export async function fetchServerInfo(serverAddress: string, abort: AbortSignal): Promise<AMServerInfoResponse | null> {
|
||||
export async function fetchServerInfo(serverAddress: string, abort: AbortSignal): Promise<AMServerInfo | null> {
|
||||
try {
|
||||
const response = await fetch(`${new URL(serverAddress).origin}/amnet/info`, {
|
||||
signal: abort
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...await response.json(),
|
||||
serverAddress: serverAddress
|
||||
} satisfies AMServerInfoResponse;
|
||||
} satisfies AMServerMetadata;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -8,20 +8,19 @@ import {AMCard, AMCardActionProps, AMCardDetail} from "@/components/am-card.tsx"
|
||||
|
||||
export function AimeCardEntry(props: AMCardActionProps & { card: AimeCard, children?: Readonly<React.ReactNode> }) {
|
||||
const isDesktop = useMediaQuery("(min-width: 512px)");
|
||||
|
||||
return (
|
||||
<AMCard title={props.card.name} actionIcon={props.actionIcon} actionClicked={props.actionClicked} actionDisabled={props.actionDisabled}>
|
||||
<AMCard title={props.card.name} actionIcon={props.actionIcon} actionClicked={props.actionClicked}
|
||||
actionDisabled={props.actionDisabled}>
|
||||
{props.card.lastUsed > 0 && (
|
||||
<AMCardDetail>
|
||||
<History className="h-5 w-5"/>
|
||||
<AMCardDetail icon={History}>
|
||||
<TimeAgo date={props.card.lastUsed}/>
|
||||
</AMCardDetail>
|
||||
)}
|
||||
<AMCardDetail>
|
||||
<CreditCard className="h-5 w-5 cursor-pointer" onClick={() => navigator.clipboard.writeText(props.card.id)}/>
|
||||
<code>{isDesktop
|
||||
? props.card.id.match(/.{1,4}/g)?.join(" ")
|
||||
: `•••• ${props.card.id.substring(props.card.id.length - 4)}`}
|
||||
<AMCardDetail icon={CreditCard} iconClicked={() => navigator.clipboard.writeText(props.card.id)}>
|
||||
<code>
|
||||
{isDesktop
|
||||
? props.card.id.match(/.{1,4}/g)?.join(" ")
|
||||
: `•••• ${props.card.id.substring(props.card.id.length - 4)}`}
|
||||
</code>
|
||||
</AMCardDetail>
|
||||
</AMCard>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import {ChevronRight} from "lucide-react";
|
||||
import {ChevronRight, LucideIcon} from "lucide-react";
|
||||
|
||||
import {Card} from "@/components/ui/card.tsx";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
|
||||
@@ -18,7 +19,7 @@ export function AMCard(props: AMCardActionProps & { title: string, titlePerforms
|
||||
onClick={() => props.titlePerformsAction && props.actionClicked ? props.actionClicked() : null}>
|
||||
{props.title}
|
||||
</h4>
|
||||
<div className="text-sm lg:text-md flex flex-auto flex-wrap gap-3">
|
||||
<div className="text-sm lg:text-md flex flex-auto flex-wrap gap-4">
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
@@ -32,9 +33,10 @@ export function AMCard(props: AMCardActionProps & { title: string, titlePerforms
|
||||
)
|
||||
}
|
||||
|
||||
export function AMCardDetail(props: { children?: Readonly<React.ReactNode> }) {
|
||||
export function AMCardDetail(props: { icon: LucideIcon, iconClicked?: () => void | Promise<any>, children?: Readonly<React.ReactNode> }) {
|
||||
return (
|
||||
<div className="inline-flex items-center max-w-full gap-2 text-gray-500 select-none md:select-text">
|
||||
{React.createElement(props.icon, {className: "h-5 w-5", onClick: props.iconClicked})}
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import {useMemo} from "react";
|
||||
import TimeAgo from "react-timeago";
|
||||
import {getGameName} from "amnet-shared";
|
||||
import {useNavigate} from "react-router-dom";
|
||||
import {Earth, History, Joystick, Trash} from "lucide-react";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger
|
||||
} from "@/components/ui/context-menu.tsx";
|
||||
|
||||
import {AimeServer, db} from "@/lib/database.ts";
|
||||
import {AMCard, AMCardDetail} from "@/components/am-card.tsx";
|
||||
|
||||
export function AMServer(props: { server: AimeServer }) {
|
||||
const navigate: (p: string) => void = useNavigate();
|
||||
const gameName = useMemo(() => props.server.gameId ? getGameName(props.server.gameId) : null, [props.server.gameId]);
|
||||
|
||||
return (
|
||||
<ContextMenu key={props.server.id}>
|
||||
<ContextMenuTrigger>
|
||||
<AMCard title={props.server.serverName} titlePerformsAction actionClicked={() => navigate(`/servers/${props.server.id}`)}>
|
||||
<AMCardDetail icon={Earth}>
|
||||
<code className="text-ellipsis whitespace-nowrap overflow-hidden">{props.server.serverAddress}</code>
|
||||
</AMCardDetail>
|
||||
<AMCardDetail icon={History}>
|
||||
{props.server.lastConnected > 0 ? <TimeAgo date={props.server.lastConnected}/> :
|
||||
<span>never</span>}
|
||||
</AMCardDetail>
|
||||
{gameName && <AMCardDetail icon={Joystick}>{gameName}</AMCardDetail>}
|
||||
</AMCard>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem inset onClick={() => navigate(`/servers/${props.server.id}`)}>
|
||||
Select Server
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator/>
|
||||
<ContextMenuItem className="text-red-500" onClick={() => db.servers.delete(props.server.id)}>
|
||||
<Trash className="mr-2 h-4 w-4"/> Remove Server
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>)
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import Dexie, { type EntityTable } from 'dexie';
|
||||
import {AMServerInfoResponse} from "amnet-shared";
|
||||
import {AMServerConnectionInfo, AMServerMetadata} from "amnet-shared";
|
||||
|
||||
export const CARD_LIMIT = 15;
|
||||
|
||||
interface AimeServer extends AMServerInfoResponse {
|
||||
interface AimeServer extends AMServerMetadata, AMServerConnectionInfo {
|
||||
id: number;
|
||||
lastConnected: number;
|
||||
}
|
||||
@@ -19,7 +19,7 @@ const db = new Dexie('amnet_db') as Dexie & {
|
||||
cards: EntityTable<AimeCard, 'id'>;
|
||||
};
|
||||
|
||||
db.version(1).stores({
|
||||
db.version(2).stores({
|
||||
servers: '++id, serverAddress',
|
||||
cards: 'id'
|
||||
});
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import {useLiveQuery} from "dexie-react-hooks";
|
||||
import {useEffect, useMemo, useState} from "react";
|
||||
import {useNavigate, useParams} from "react-router-dom";
|
||||
import {CircleDot, CircleCheckBig, Home, Nfc, Wallet, WifiOff} from "lucide-react";
|
||||
|
||||
import {AimeCard, AimeServer, db} from "@/lib/database";
|
||||
import {performCardIn, fetchServerInfo} from "amnet-shared";
|
||||
import {performCardIn, fetchServerInfo, getGameName, AMServerInfo} from "amnet-shared";
|
||||
import {CircleDot, CircleCheckBig, Home, Nfc, Wallet, WifiOff, TvMinimalPlay} from "lucide-react";
|
||||
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {useToast} from "@/components/ui/use-toast";
|
||||
@@ -21,6 +19,8 @@ import {
|
||||
AlertDialogTrigger
|
||||
} from "@/components/ui/alert-dialog.tsx";
|
||||
|
||||
import {AimeCard, AimeServer, db} from "@/lib/database";
|
||||
|
||||
import {AMHeader} from "@/components/am-header";
|
||||
import {AMFooter} from "@/components/am-footer";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
@@ -60,8 +60,6 @@ async function performCardInAction(card: AimeCard, server: AimeServer, setCardIn
|
||||
}
|
||||
}
|
||||
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
function CardList(props: { cards: ReadonlyArray<AimeCard> | null, cardSelected: (card: AimeCard) => void }) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -82,6 +80,39 @@ function CardList(props: { cards: ReadonlyArray<AimeCard> | null, cardSelected:
|
||||
</>);
|
||||
}
|
||||
|
||||
function AlertPanel(props: { server: AMServerInfo | null, connectionError: boolean, onReloadRequested: () => void}) {
|
||||
if (!props.server) {
|
||||
return <LoadingIndicator/>
|
||||
}
|
||||
|
||||
if (props.connectionError) {
|
||||
return <Alert variant="destructive">
|
||||
<WifiOff className="h-4 w-4"/>
|
||||
<AlertTitle>Connection Issue</AlertTitle>
|
||||
<AlertDescription>
|
||||
There was an issue connecting to <code
|
||||
className="max-w-full text-wrap">{props.server.serverAddress}</code>.
|
||||
Please check the game is running and <span className="underline cursor-pointer"
|
||||
onClick={props.onReloadRequested}>try again</span>.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
}
|
||||
|
||||
if (props.server.timeSinceLastPoll === null) {
|
||||
return <Alert variant="blank" className="text-violet-600 border-violet-600">
|
||||
<TvMinimalPlay className="h-4 w-4"/>
|
||||
<AlertTitle>Server Startup</AlertTitle>
|
||||
<AlertDescription>
|
||||
Scanning a card below may not have any effect until the service has fully started up. <span
|
||||
className="underline cursor-pointer"
|
||||
onClick={props.onReloadRequested}>reload this page</span> to perform another check.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
}
|
||||
|
||||
return <IncognitoStorageWarning/>
|
||||
}
|
||||
|
||||
function ServerCardInPage() {
|
||||
const {toast} = useToast();
|
||||
const {serverId} = useParams<{ serverId: string }>();
|
||||
@@ -98,6 +129,7 @@ function ServerCardInPage() {
|
||||
const [connectionError, setConnectionError] = useState(false);
|
||||
|
||||
const shareUrl = useMemo(() => `${location.origin}/#/servers/add?address=${encodeURIComponent(server?.serverAddress ?? "")}`, [server]);
|
||||
const gameName = useMemo(() => server?.gameId ? getGameName(server.gameId) : null, [server]);
|
||||
|
||||
// select newest card
|
||||
useEffect(() => setSelectedCard(cards?.sort((a, b) => b.lastUsed - a.lastUsed)[0] ?? null), [cards]);
|
||||
@@ -152,25 +184,12 @@ function ServerCardInPage() {
|
||||
</Button>
|
||||
</AMHeader>
|
||||
|
||||
{!server && (<LoadingIndicator/>)}
|
||||
{(server && !connectionError) && <IncognitoStorageWarning/>}
|
||||
{(server && connectionError) && (
|
||||
<Alert variant="destructive">
|
||||
<WifiOff className="h-4 w-4"/>
|
||||
<AlertTitle>Connection Issue</AlertTitle>
|
||||
<AlertDescription>
|
||||
There was an issue connecting to <code
|
||||
className="max-w-full text-wrap">{server.serverAddress}</code>.
|
||||
Please check the game is running and <span className="underline cursor-pointer"
|
||||
onClick={() => setServerReloadCounter(serverReloadCounter + 1)}>try again</span>.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<AlertPanel server={server} connectionError={connectionError} onReloadRequested={() => setServerReloadCounter(serverReloadCounter + 1)}/>
|
||||
|
||||
{server && (
|
||||
<div className="flex flex-col gap-4 w-full flex-grow">
|
||||
{selectedCard && (<>
|
||||
<h4 className="text-2xl font-bold">Selected Card</h4>
|
||||
<h4 className="text-2xl font-bold">{gameName ? `Play ${gameName}` : "Selected Card"}</h4>
|
||||
|
||||
<AimeCardEntry card={selectedCard}>
|
||||
<Button size="icon" variant="ghost" disabled>
|
||||
@@ -198,8 +217,7 @@ function ServerCardInPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AMFooter
|
||||
className="flex flex-col-reverse sm:flex-row sm:flex-wrap justify-center sm:justify-between sm:gap-2 gap-4">
|
||||
<AMFooter className="flex flex-col-reverse sm:flex-row sm:flex-wrap justify-center sm:justify-between sm:gap-2 gap-4">
|
||||
{server?.id && (
|
||||
<div className="flex items-center justify-center sm:gap-5 gap-2">
|
||||
{location.protocol === "https:" && (<>
|
||||
@@ -252,4 +270,4 @@ function ServerCardInPage() {
|
||||
);
|
||||
}
|
||||
|
||||
export default ServerCardInPage;
|
||||
export default ServerCardInPage;
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
import {z} from "zod";
|
||||
import TimeAgo from "react-timeago";
|
||||
import {useForm} from "react-hook-form";
|
||||
import {useLiveQuery} from "dexie-react-hooks";
|
||||
import {AMNET_API_VERSION} from "amnet-shared";
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {useLocation, useNavigate} from 'react-router-dom';
|
||||
import {zodResolver} from "@hookform/resolvers/zod";
|
||||
import {
|
||||
Earth,
|
||||
History,
|
||||
Plus,
|
||||
ShieldAlert,
|
||||
SquareArrowOutUpRight,
|
||||
Trash,
|
||||
WalletCards
|
||||
} from "lucide-react";
|
||||
|
||||
import {AimeServer, db} from "@/lib/database";
|
||||
import {zodResolver} from "@hookform/resolvers/zod";
|
||||
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {Button} from "@/components/ui/button";
|
||||
@@ -23,26 +21,38 @@ import {LoadingIndicator} from "@/components/loading-indicator";
|
||||
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert";
|
||||
import {Form, FormControl, FormField, FormItem, FormMessage} from "@/components/ui/form";
|
||||
import {HttpsMixedModeRequestWarning} from "@/components/https-mixed-mode-request-warning";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger
|
||||
} from "@/components/ui/context-menu";
|
||||
|
||||
import {AMFooter} from "@/components/am-footer";
|
||||
import {AMHeader} from "@/components/am-header";
|
||||
import {AMNET_API_VERSION} from "amnet-shared";
|
||||
import {AMCard, AMCardDetail} from "@/components/am-card";
|
||||
import {AMServer} from "@/components/am-server.tsx";
|
||||
|
||||
const serverAdditionSchema = z.object({
|
||||
address: z.string().startsWith("http")
|
||||
});
|
||||
|
||||
function ServerList(props: { servers: AimeServer[] | undefined }) {
|
||||
function ServerListing() {
|
||||
const navigate = useNavigate();
|
||||
const servers = useLiveQuery(() => db.servers.toArray());
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-5 container min-h-screen py-10">
|
||||
<AMHeader title="Servers">
|
||||
<Button variant="ghost" size={"default"} onClick={() => navigate('/cards')}>
|
||||
<WalletCards className="h-4 w-4 sm:mr-2"/>
|
||||
<span className="hidden sm:block">Manage Cards</span>
|
||||
</Button>
|
||||
</AMHeader>
|
||||
|
||||
<div className="flex flex-col gap-4 w-full flex-grow">
|
||||
<ServerList servers={servers}/>
|
||||
</div>
|
||||
|
||||
<AMFooter/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function ServerList(props: { servers: AimeServer[] | undefined }) {
|
||||
if (!props.servers) {
|
||||
return (<LoadingIndicator/>);
|
||||
}
|
||||
@@ -65,32 +75,7 @@ function ServerList(props: { servers: AimeServer[] | undefined }) {
|
||||
<HttpsMixedModeRequestWarning/>
|
||||
<ServerAddForm/>
|
||||
|
||||
{props.servers.map(s =>
|
||||
<ContextMenu key={s.id}>
|
||||
<ContextMenuTrigger>
|
||||
<AMCard title={s.serverName} titlePerformsAction actionClicked={() => navigate(`/servers/${s.id}`)}>
|
||||
{s.lastConnected > 0 && (
|
||||
<AMCardDetail>
|
||||
<History className="h-5 w-5"/>
|
||||
<TimeAgo date={s.lastConnected}/>
|
||||
</AMCardDetail>
|
||||
)}
|
||||
<AMCardDetail>
|
||||
<Earth className="h-5 w-5"/>
|
||||
<code className="text-ellipsis whitespace-nowrap overflow-hidden">{s.serverAddress}</code>
|
||||
</AMCardDetail>
|
||||
</AMCard>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem inset onClick={() => navigate(`/servers/${s.id}`)}>
|
||||
Select Server
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator/>
|
||||
<ContextMenuItem className="text-red-500" onClick={() => db.servers.delete(s.id)}>
|
||||
<Trash className="mr-2 h-4 w-4"/> Remove Server
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>)}
|
||||
{props.servers.map(s => <AMServer key={s.id} server={s}/>)}
|
||||
</>);
|
||||
}
|
||||
|
||||
@@ -224,26 +209,4 @@ function ServerAddForm() {
|
||||
);
|
||||
}
|
||||
|
||||
function ServerListing() {
|
||||
const navigate = useNavigate();
|
||||
const servers = useLiveQuery(() => db.servers.toArray());
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-5 container min-h-screen py-10">
|
||||
<AMHeader title="Servers">
|
||||
<Button variant="ghost" size={"default"} onClick={() => navigate('/cards')}>
|
||||
<WalletCards className="h-4 w-4 sm:mr-2"/>
|
||||
<span className="hidden sm:block">Manage Cards</span>
|
||||
</Button>
|
||||
</AMHeader>
|
||||
|
||||
<div className="flex flex-col gap-4 w-full flex-grow">
|
||||
<ServerList servers={servers}/>
|
||||
</div>
|
||||
|
||||
<AMFooter/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default ServerListing;
|
||||
|
||||
Reference in New Issue
Block a user