mirror of
https://github.com/mudabbir-ahmad/MAD-Treasure-Hunt.git
synced 2026-10-07 19:00:20 +00:00
Add initial JSON data files and implement core page components for game functionality
This commit is contained in:
1 parent
81847a9188
commit
ca27f8d6ed
54 files changed
+2424
-250
No files matched your search
@@ -0,0 +1,44 @@
|
||||
# Database Structure
|
||||
|
||||
This project uses split JSON files in `DB/` so each collection behaves like its own REST endpoint.
|
||||
|
||||
## Purpose
|
||||
|
||||
- Keep data modular and readable.
|
||||
- Mirror server-side endpoint structure before moving to a hosted REST API.
|
||||
- Allow the app to load all collections into one in-memory state via `app/Model/DbController.js`.
|
||||
|
||||
## Files
|
||||
|
||||
- `users.json`: user accounts and role state (`Uid`, `Gid`, `SGid`, `TGid`, `IsAcceptedAdmin`).
|
||||
- `groups.json`: top-level game groups (`Gid`), admin approvals, and group settings.
|
||||
- `subgroups.json`: subgroup definitions within each group (`SGid` scoped by `Gid`), join codes, cache trigger defaults.
|
||||
- `subgroup-memberships.json`: membership bridge from `Uid` to `Gid`/`SGid`.
|
||||
- `admin-waitlist.json`: pending admin requests for groups when active admins already exist.
|
||||
- `teams.json`: team entities for non-admin subgroup players.
|
||||
- `team-members.json`: membership bridge from `Uid` to `Tid`.
|
||||
- `game-data.json`: per-group game payloads (geocaches, map/game objects).
|
||||
- `game-types.json`: available game type options for UI flow.
|
||||
- `db.json`: index file listing split JSON resources.
|
||||
|
||||
## ID Rules
|
||||
|
||||
- `Uid`: unique user ID.
|
||||
- `Gid`: unique group/game ID.
|
||||
- `SGid`: subgroup ID within a group (`0` is admin subgroup).
|
||||
- `Tid`: unique team ID.
|
||||
- New IDs are generated from the max existing value in each endpoint collection (`users`, `groups`, `teams`) so no separate counter file is required.
|
||||
|
||||
## Admin Logic
|
||||
|
||||
- Group creator is set as admin (`SGid = 0`) and accepted.
|
||||
- New admin join requests go into `admin-waitlist.json` if active admins exist.
|
||||
- If no active admin exists, the requester is auto-promoted to accepted admin.
|
||||
- Group-level `ApprovedAdmins` stores accepted admin `Uid` values.
|
||||
|
||||
## Timestamp Format
|
||||
|
||||
- `CreatedAt` values are stored at minute precision:
|
||||
- format: `YYYY-MM-DDTHH:mmZ`
|
||||
- example: `2026-03-17T14:42Z`
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
[]
|
||||
|
||||
+12
-43
@@ -1,45 +1,14 @@
|
||||
{
|
||||
"users": [
|
||||
{
|
||||
"Uid": 1,
|
||||
"username": "admin",
|
||||
"password": "admin123",
|
||||
"Gid": null,
|
||||
"SGid": 0,
|
||||
"TGid": null
|
||||
},
|
||||
{
|
||||
"Uid": 2,
|
||||
"username": "user1",
|
||||
"password": "user123",
|
||||
"Gid": 1,
|
||||
"SGid": 1,
|
||||
"TGid": null
|
||||
}
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"Gid": 1,
|
||||
"GroupName": "Public Group"
|
||||
}
|
||||
],
|
||||
"subgroups": [
|
||||
{
|
||||
"SGid": 0,
|
||||
"SubGroupName": "Admin Subgroup",
|
||||
"Gid": null
|
||||
},
|
||||
{
|
||||
"SGid": 1,
|
||||
"SubGroupName": "User Subgroup",
|
||||
"Gid": 1
|
||||
}
|
||||
],
|
||||
"user_groups": [
|
||||
{
|
||||
"Uid": 2,
|
||||
"Gid": 1,
|
||||
"SGid": 1
|
||||
}
|
||||
]
|
||||
"databaseMode": "split-endpoints",
|
||||
"index": {
|
||||
"users": "users.json",
|
||||
"groups": "groups.json",
|
||||
"subgroups": "subgroups.json",
|
||||
"subgroup_memberships": "subgroup-memberships.json",
|
||||
"admin_waitlist": "admin-waitlist.json",
|
||||
"teams": "teams.json",
|
||||
"team_members": "team-members.json",
|
||||
"game_data": "game-data.json",
|
||||
"game_types": "game-types.json"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
[
|
||||
{
|
||||
"Gid": 1,
|
||||
"GeoCaches": [
|
||||
{
|
||||
"CacheId": "C1",
|
||||
"Title": "Main Entrance",
|
||||
"Latitude": 0,
|
||||
"Longitude": 0,
|
||||
"TriggerMeters": 20,
|
||||
"SGid": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
[
|
||||
"Join Global Game",
|
||||
"Join Private Game",
|
||||
"Manage Private Game"
|
||||
]
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
[
|
||||
{
|
||||
"Gid": 1,
|
||||
"GroupName": "Main Private Game",
|
||||
"BusinessOrSchoolName": "MAD Academy",
|
||||
"GameType": "private",
|
||||
"AdminJoinCode": "A91TQ",
|
||||
"CreatedByUid": 1,
|
||||
"MaxMemberSubgroups": 1,
|
||||
"TeamsEnabled": true,
|
||||
"ApprovedAdmins": [1],
|
||||
"CreatedAt": "2026-03-17T00:00Z"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
CREATE TABLE USER (
|
||||
Uid INTEGER PRIMARY KEY AUTOINCREMENT UNIQUE NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
password TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "GROUP" (
|
||||
Gid INTEGER PRIMARY KEY AUTOINCREMENT UNIQUE NOT NULL,
|
||||
GroupName TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE SUBGROUP (
|
||||
SGid INTEGER PRIMARY KEY AUTOINCREMENT UNIQUE NOT NULL,
|
||||
SubGroupName TEXT NOT NULL,
|
||||
Gid INTEGER NOT NULL,
|
||||
FOREIGN KEY (Gid) REFERENCES "GROUP"(Gid)
|
||||
);
|
||||
|
||||
CREATE TABLE USER_GROUP (
|
||||
Uid INTEGER NOT NULL,
|
||||
Gid INTEGER NOT NULL,
|
||||
SGid INTEGER,
|
||||
PRIMARY KEY (Uid, Gid),
|
||||
FOREIGN KEY (Uid) REFERENCES USER(Uid),
|
||||
FOREIGN KEY (Gid) REFERENCES "GROUP"(Gid),
|
||||
FOREIGN KEY (SGid) REFERENCES SUBGROUP(SGid)
|
||||
);
|
||||
@@ -0,0 +1,15 @@
|
||||
[
|
||||
{
|
||||
"Uid": 1,
|
||||
"Gid": 1,
|
||||
"SGid": 0,
|
||||
"IsAcceptedAdmin": true
|
||||
},
|
||||
{
|
||||
"Uid": 2,
|
||||
"Gid": 1,
|
||||
"SGid": 1,
|
||||
"IsAcceptedAdmin": false
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
[
|
||||
{
|
||||
"SGid": 0,
|
||||
"SubGroupName": "Admins",
|
||||
"Gid": 1,
|
||||
"JoinCode": null,
|
||||
"IsAdminGroup": true,
|
||||
"CacheTriggerMeters": 20
|
||||
},
|
||||
{
|
||||
"SGid": 1,
|
||||
"SubGroupName": "Members",
|
||||
"Gid": 1,
|
||||
"JoinCode": "M4P8R",
|
||||
"IsAdminGroup": false,
|
||||
"CacheTriggerMeters": 20
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
[]
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
[]
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
[
|
||||
{
|
||||
"Uid": 1,
|
||||
"username": "admin",
|
||||
"email": "admin@mad.local",
|
||||
"passwordHash": "H_145737963",
|
||||
"isBusiness": true,
|
||||
"IsAcceptedAdmin": true,
|
||||
"Gid": 1,
|
||||
"SGid": 0,
|
||||
"TGid": null
|
||||
},
|
||||
{
|
||||
"Uid": 2,
|
||||
"username": "member1",
|
||||
"email": "member1@mad.local",
|
||||
"passwordHash": "H_1322967599",
|
||||
"isBusiness": false,
|
||||
"IsAcceptedAdmin": false,
|
||||
"Gid": 1,
|
||||
"SGid": 1,
|
||||
"TGid": null
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
# Welcome to your Expo app 👋
|
||||
|
||||
This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app).
|
||||
# Welcome to the mobile application development repository for our Treasure Hunt Game! This project is built using React Native and Expo.
|
||||
|
||||
## Get started
|
||||
|
||||
@@ -16,35 +14,49 @@ This is an [Expo](https://expo.dev) project created with [`create-expo-app`](htt
|
||||
npx expo start
|
||||
```
|
||||
|
||||
In the output, you'll find options to open the app in a
|
||||
|
||||
- [development build](https://docs.expo.dev/develop/development-builds/introduction/)
|
||||
- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
|
||||
- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
|
||||
- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
|
||||
|
||||
You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
|
||||
# Database Structure
|
||||
|
||||
## Get a fresh project
|
||||
This project uses split JSON files in `DB/` so each collection behaves like its own REST endpoint.
|
||||
|
||||
When you're ready, run:
|
||||
## Purpose
|
||||
|
||||
```bash
|
||||
npm run reset-project
|
||||
```
|
||||
- Keep data modular and readable.
|
||||
- Mirror server-side endpoint structure before moving to a hosted REST API.
|
||||
- Allow the app to load all collections into one in-memory state via `app/Model/DbController.js`.
|
||||
|
||||
This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing.
|
||||
## Files
|
||||
|
||||
## Learn more
|
||||
- `meta.json`: global version and numeric ID counters.
|
||||
- `users.json`: user accounts and role state (`Uid`, `Gid`, `SGid`, `TGid`, `IsAcceptedAdmin`).
|
||||
- `groups.json`: top-level game groups (`Gid`), admin approvals, and group settings.
|
||||
- `subgroups.json`: subgroup definitions within each group (`SGid` scoped by `Gid`), join codes, cache trigger defaults.
|
||||
- `subgroup-memberships.json`: membership bridge from `Uid` to `Gid`/`SGid`.
|
||||
- `admin-waitlist.json`: pending admin requests for groups when active admins already exist.
|
||||
- `teams.json`: team entities for non-admin subgroup players.
|
||||
- `team-members.json`: membership bridge from `Uid` to `Tid`.
|
||||
- `game-data.json`: per-group game payloads (geocaches, map/game objects).
|
||||
- `game-types.json`: available game type options for UI flow.
|
||||
- `db.json`: index file listing split JSON resources.
|
||||
|
||||
To learn more about developing your project with Expo, look at the following resources:
|
||||
## ID Rules
|
||||
|
||||
- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides).
|
||||
- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web.
|
||||
- `Uid`: unique user ID.
|
||||
- `Gid`: unique group/game ID.
|
||||
- `SGid`: subgroup ID within a group (`0` is admin subgroup).
|
||||
- `Tid`: unique team ID.
|
||||
|
||||
## Join the community
|
||||
## Admin Logic
|
||||
|
||||
Join our community of developers creating universal apps.
|
||||
- Group creator is set as admin (`SGid = 0`) and accepted.
|
||||
- New admin join requests go into `admin-waitlist.json` if active admins exist.
|
||||
- If no active admin exists, the requester is auto-promoted to accepted admin.
|
||||
- Group-level `ApprovedAdmins` stores accepted admin `Uid` values.
|
||||
|
||||
## Timestamp Format
|
||||
|
||||
- `CreatedAt` values are stored at minute precision:
|
||||
- format: `YYYY-MM-DDTHH:mmZ`
|
||||
- example: `2026-03-17T14:42Z`
|
||||
|
||||
- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
|
||||
- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.
|
||||
+215
-34
@@ -1,48 +1,229 @@
|
||||
// This file contains functions to interact with the API or local database.
|
||||
import dbController from '../Model/DbController';
|
||||
|
||||
const API_BASE_URL = 'http://localhost:3000'; // Change this to the actual API endpoint when needed
|
||||
const apiConfig = {
|
||||
useLocalDb: true,
|
||||
baseUrl: 'http://localhost:3000',
|
||||
};
|
||||
|
||||
// Generic fetch function
|
||||
const APIFetch = async (endpoint, options = {}) => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
const setApiConfig = (nextConfig = {}) => {
|
||||
Object.assign(apiConfig, nextConfig);
|
||||
};
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.statusText}`);
|
||||
}
|
||||
const isSameId = (item, id) =>
|
||||
item.id?.toString() === id
|
||||
|| item.Uid?.toString() === id
|
||||
|| item.Gid?.toString() === id
|
||||
|| item.SGid?.toString() === id
|
||||
|| item.Tid?.toString() === id;
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Fetch error:', error);
|
||||
throw error;
|
||||
const normalize = (endpoint) => endpoint.replace(/^\/+/, '').split('/');
|
||||
|
||||
const localGet = (endpoint) => {
|
||||
const [resource, id] = normalize(endpoint);
|
||||
const state = dbController.getState();
|
||||
|
||||
if (!resource) {
|
||||
return state;
|
||||
}
|
||||
if (!state[resource]) {
|
||||
throw new Error(`Resource not found: ${resource}`);
|
||||
}
|
||||
if (!id) {
|
||||
return state[resource];
|
||||
}
|
||||
return state[resource].find((item) => isSameId(item, id)) || null;
|
||||
};
|
||||
|
||||
// Example function to fetch a user by username
|
||||
const fetchUserByUsername = async (username) => {
|
||||
return await APIFetch(`users?username=${username}`);
|
||||
const localPost = (endpoint, payload) => {
|
||||
const [resource] = normalize(endpoint);
|
||||
|
||||
if (resource === 'auth' && payload.action === 'login') {
|
||||
return dbController.login(payload.data);
|
||||
}
|
||||
if (resource === 'auth' && payload.action === 'register') {
|
||||
return dbController.register(payload.data);
|
||||
}
|
||||
if (resource === 'groups' && payload.action === 'joinPrivate') {
|
||||
return dbController.joinPrivateGame(payload.data);
|
||||
}
|
||||
if (resource === 'groups' && payload.action === 'joinAsAdmin') {
|
||||
return dbController.joinAsAdmin(payload.data);
|
||||
}
|
||||
if (resource === 'groups' && payload.action === 'createPrivate') {
|
||||
return dbController.createPrivateGame(payload.data);
|
||||
}
|
||||
if (resource === 'groups' && payload.action === 'requestAdmin') {
|
||||
return dbController.requestAdminApproval(payload.data);
|
||||
}
|
||||
if (resource === 'groups' && payload.action === 'approveAdmin') {
|
||||
return dbController.approveAdmin(payload.data);
|
||||
}
|
||||
if (resource === 'subgroups' && payload.action === 'create') {
|
||||
return dbController.createSubgroup(payload.data);
|
||||
}
|
||||
if (resource === 'teams' && payload.action === 'create') {
|
||||
return dbController.createTeam(payload.data);
|
||||
}
|
||||
if (resource === 'teams' && payload.action === 'join') {
|
||||
return dbController.joinTeamByCode(payload.data);
|
||||
}
|
||||
|
||||
const state = dbController.getState();
|
||||
if (!state[resource]) {
|
||||
throw new Error(`Resource not found: ${resource}`);
|
||||
}
|
||||
state[resource].push(payload);
|
||||
return payload;
|
||||
};
|
||||
|
||||
// Example function to fetch all groups
|
||||
const fetchGroups = async () => {
|
||||
return await APIFetch('groups');
|
||||
const localPut = (endpoint, payload) => {
|
||||
const [resource, id] = normalize(endpoint);
|
||||
const state = dbController.getState();
|
||||
if (!state[resource]) {
|
||||
throw new Error(`Resource not found: ${resource}`);
|
||||
}
|
||||
const index = state[resource].findIndex((item) =>
|
||||
isSameId(item, id),
|
||||
);
|
||||
if (index < 0) {
|
||||
throw new Error('Record not found');
|
||||
}
|
||||
state[resource][index] = { ...state[resource][index], ...payload };
|
||||
return state[resource][index];
|
||||
};
|
||||
|
||||
// Example function to fetch subgroups by group ID
|
||||
const fetchSubgroupsByGroupId = async (groupId) => {
|
||||
return await APIFetch(`groups/${groupId}/subgroups`);
|
||||
const localDelete = (endpoint) => {
|
||||
const [resource, id] = normalize(endpoint);
|
||||
const state = dbController.getState();
|
||||
if (!state[resource]) {
|
||||
throw new Error(`Resource not found: ${resource}`);
|
||||
}
|
||||
const index = state[resource].findIndex((item) =>
|
||||
isSameId(item, id),
|
||||
);
|
||||
if (index < 0) {
|
||||
return { deleted: false };
|
||||
}
|
||||
state[resource].splice(index, 1);
|
||||
return { deleted: true };
|
||||
};
|
||||
|
||||
// RESTful API functions
|
||||
const fetchItems = async (endpoint) => await APIFetch(endpoint);
|
||||
const createItem = async (endpoint, data) => await APIFetch(endpoint, { method: 'POST', body: JSON.stringify(data) });
|
||||
const updateItem = async (endpoint, data) => await APIFetch(endpoint, { method: 'PUT', body: JSON.stringify(data) });
|
||||
const deleteItem = async (endpoint) => await APIFetch(endpoint, { method: 'DELETE' });
|
||||
const remoteRequest = async (endpoint, options = {}) => {
|
||||
const response = await fetch(`${apiConfig.baseUrl}/${endpoint.replace(/^\/+/, '')}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export { APIFetch, fetchUserByUsername, fetchGroups, fetchSubgroupsByGroupId, fetchItems, createItem, updateItem, deleteItem };
|
||||
const APIFetch = async (endpoint) => {
|
||||
if (apiConfig.useLocalDb) {
|
||||
return localGet(endpoint);
|
||||
}
|
||||
return remoteRequest(endpoint, { method: 'GET' });
|
||||
};
|
||||
|
||||
const APIPost = async (endpoint, data) => {
|
||||
if (apiConfig.useLocalDb) {
|
||||
return localPost(endpoint, data);
|
||||
}
|
||||
return remoteRequest(endpoint, { method: 'POST', body: JSON.stringify(data) });
|
||||
};
|
||||
|
||||
const APIPut = async (endpoint, data) => {
|
||||
if (apiConfig.useLocalDb) {
|
||||
if (endpoint === 'groups/settings') {
|
||||
return dbController.updateGroupSettings(data);
|
||||
}
|
||||
if (endpoint === 'subgroups/cache') {
|
||||
return dbController.updateSubgroupCacheSettings(data);
|
||||
}
|
||||
return localPut(endpoint, data);
|
||||
}
|
||||
return remoteRequest(endpoint, { method: 'PUT', body: JSON.stringify(data) });
|
||||
};
|
||||
|
||||
const APIDelete = async (endpoint) => {
|
||||
if (apiConfig.useLocalDb) {
|
||||
return localDelete(endpoint);
|
||||
}
|
||||
return remoteRequest(endpoint, { method: 'DELETE' });
|
||||
};
|
||||
|
||||
const loginUser = async (email, password) => APIPost('auth', { action: 'login', data: { email, password } });
|
||||
const registerUser = async (payload) => APIPost('auth', { action: 'register', data: payload });
|
||||
const getGameTypes = async () => {
|
||||
if (apiConfig.useLocalDb) {
|
||||
return dbController.getGameTypes();
|
||||
}
|
||||
return APIFetch('game_types');
|
||||
};
|
||||
const joinPrivateGame = async (payload) => APIPost('groups', { action: 'joinPrivate', data: payload });
|
||||
const joinAsAdmin = async (payload) => APIPost('groups', { action: 'joinAsAdmin', data: payload });
|
||||
const createPrivateGame = async (payload) => APIPost('groups', { action: 'createPrivate', data: payload });
|
||||
const requestAdminAccess = async (payload) => APIPost('groups', { action: 'requestAdmin', data: payload });
|
||||
const approveAdminAccess = async (payload) => APIPost('groups', { action: 'approveAdmin', data: payload });
|
||||
const createSubgroup = async (payload) => APIPost('subgroups', { action: 'create', data: payload });
|
||||
const getSubgroups = async (gid) => {
|
||||
if (apiConfig.useLocalDb) {
|
||||
return dbController.getSubgroups(gid);
|
||||
}
|
||||
return APIFetch(`groups/${gid}/subgroups`);
|
||||
};
|
||||
const updateSubgroupCache = async (payload) => APIPut('subgroups/cache', payload);
|
||||
const updateGroupSettings = async (payload) => APIPut('groups/settings', payload);
|
||||
const createTeam = async (payload) => APIPost('teams', { action: 'create', data: payload });
|
||||
const joinTeam = async (payload) => APIPost('teams', { action: 'join', data: payload });
|
||||
const getLobby = async (gid) => {
|
||||
if (apiConfig.useLocalDb) {
|
||||
return dbController.getLobby(gid);
|
||||
}
|
||||
return APIFetch(`groups/${gid}/lobby`);
|
||||
};
|
||||
const getTeam = async (tid) => {
|
||||
if (apiConfig.useLocalDb) {
|
||||
return dbController.getTeam(tid);
|
||||
}
|
||||
return APIFetch(`teams/${tid}`);
|
||||
};
|
||||
const getMapPoints = async (gid, sgid = null) => {
|
||||
if (apiConfig.useLocalDb) {
|
||||
return dbController.getMapPoints(gid, sgid);
|
||||
}
|
||||
if (sgid === null || sgid === undefined) {
|
||||
return APIFetch(`groups/${gid}/map`);
|
||||
}
|
||||
return APIFetch(`groups/${gid}/map/${sgid}`);
|
||||
};
|
||||
|
||||
export {
|
||||
apiConfig,
|
||||
setApiConfig,
|
||||
APIFetch,
|
||||
APIPost,
|
||||
APIPut,
|
||||
APIDelete,
|
||||
loginUser,
|
||||
registerUser,
|
||||
getGameTypes,
|
||||
joinPrivateGame,
|
||||
joinAsAdmin,
|
||||
createPrivateGame,
|
||||
requestAdminAccess,
|
||||
approveAdminAccess,
|
||||
createSubgroup,
|
||||
getSubgroups,
|
||||
updateSubgroupCache,
|
||||
updateGroupSettings,
|
||||
createTeam,
|
||||
joinTeam,
|
||||
getLobby,
|
||||
getTeam,
|
||||
getMapPoints,
|
||||
};
|
||||
@@ -1,59 +1,32 @@
|
||||
// If a user's subgroupID is 0, Treat them as ADMIN
|
||||
// If a user's subgroupID is 1, Treat them as USER [For Public instances only subgroups avail are 0 and 1]
|
||||
// If a user's subgroupID is 2, Treat them as USER [For Private instances, subgroups could range from 0-999 where 0 = admin, non 0 = member group]
|
||||
// Use GroupID to link Subgroup to a group in the groups table, where subgroup --> defines permissions and whether the person is allowed to play or not.
|
||||
// within subgroups have TeamGroups --> TeamGroups can come and go, they are not a fixed table [Team groups are enabled per admin request.]
|
||||
// Team group ==> IF Teams are enabled a user can create a team group, if they're admin in team group they can kick people.
|
||||
// All team groups have a unique JOIN CODE.
|
||||
// User's table should have their [NULLABLE] unique Gid, SGid and TGid.
|
||||
// Gid == GroupID || SGid == SubgroupID || TGid == TeamGroupID
|
||||
// When a user logs in, we check their Gid, SGid and TGid to determine their permissions and what they can see on the front end.
|
||||
// If a user is in a team group, they can only see other members of that team group and the games that team group is playing.
|
||||
// If a user is in a subgroup, they can see all members of that subgroup and the games that subgroup is playing.
|
||||
// If a user is in the admin subgroup, they can see all members and all games.
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
|
||||
const useLoginLogic = () => {
|
||||
const [username, setUsername] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const navigation = useNavigation();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleLogin = async () => {
|
||||
try {
|
||||
// Replace with your actual API endpoint
|
||||
const response = await fetch('https://yourapi.com/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
alert("API error");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
// Assuming the API returns user data including Gid, SGid, and TGid
|
||||
const { Gid, SGid, TGid } = data;
|
||||
|
||||
// Dispatch user data to Redux store
|
||||
dispatch(setUser({ Gid, SGid, TGid }));
|
||||
|
||||
// Navigate to the appropriate screen based on user role
|
||||
if (SGid === 0) {
|
||||
navigation.navigate('AdminDashboard');
|
||||
} else if (SGid === 1) {
|
||||
navigation.navigate('UserDashboard');
|
||||
} else {
|
||||
navigation.navigate('PrivateDashboard');
|
||||
}
|
||||
} catch (error) {
|
||||
Alert.alert('Login Error', error.message);
|
||||
if (!email || !password) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (email.trim().toLowerCase().includes('admin')) {
|
||||
navigation.navigate('admin-hub');
|
||||
return true;
|
||||
}
|
||||
|
||||
navigation.navigate('game-type');
|
||||
return true;
|
||||
};
|
||||
|
||||
export default LoginLogic;
|
||||
return {
|
||||
email,
|
||||
password,
|
||||
setEmail,
|
||||
setPassword,
|
||||
handleLogin,
|
||||
};
|
||||
};
|
||||
|
||||
export default useLoginLogic;
|
||||
@@ -0,0 +1,502 @@
|
||||
import usersSeed from '../../DB/users.json';
|
||||
import groupsSeed from '../../DB/groups.json';
|
||||
import subgroupsSeed from '../../DB/subgroups.json';
|
||||
import subgroupMembershipsSeed from '../../DB/subgroup-memberships.json';
|
||||
import adminWaitlistSeed from '../../DB/admin-waitlist.json';
|
||||
import teamsSeed from '../../DB/teams.json';
|
||||
import teamMembersSeed from '../../DB/team-members.json';
|
||||
import gameDataSeed from '../../DB/game-data.json';
|
||||
import gameTypesSeed from '../../DB/game-types.json';
|
||||
import UserModel from './UserModel';
|
||||
import GroupModel from './GroupModel';
|
||||
import TeamModel from './TeamModel';
|
||||
|
||||
const buildSeedState = () => ({
|
||||
users: usersSeed,
|
||||
groups: groupsSeed,
|
||||
subgroups: subgroupsSeed,
|
||||
subgroup_memberships: subgroupMembershipsSeed,
|
||||
admin_waitlist: adminWaitlistSeed,
|
||||
teams: teamsSeed,
|
||||
team_members: teamMembersSeed,
|
||||
game_data: gameDataSeed,
|
||||
game_types: gameTypesSeed,
|
||||
});
|
||||
|
||||
class DbController {
|
||||
constructor(seed = buildSeedState()) {
|
||||
this.seed = JSON.parse(JSON.stringify(seed));
|
||||
this.reset();
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.db = JSON.parse(JSON.stringify(this.seed));
|
||||
}
|
||||
|
||||
hashPassword(value) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
hash = (hash << 5) - hash + value.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
return `H_${Math.abs(hash)}`;
|
||||
}
|
||||
|
||||
toMinuteIso(date = new Date()) {
|
||||
return `${date.toISOString().slice(0, 16)}Z`;
|
||||
}
|
||||
|
||||
generateJoinCode(length = 5) {
|
||||
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
let code = '';
|
||||
for (let i = 0; i < length; i += 1) {
|
||||
code += charset[Math.floor(Math.random() * charset.length)];
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
generateUniqueJoinCode(collection, key) {
|
||||
let code = this.generateJoinCode(Math.random() > 0.5 ? 5 : 6);
|
||||
while (collection.some((item) => item[key] === code)) {
|
||||
code = this.generateJoinCode(Math.random() > 0.5 ? 5 : 6);
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
nextId(counterKey) {
|
||||
const keyToCollection = {
|
||||
user: { collection: 'users', field: 'Uid' },
|
||||
group: { collection: 'groups', field: 'Gid' },
|
||||
team: { collection: 'teams', field: 'Tid' },
|
||||
};
|
||||
|
||||
const config = keyToCollection[counterKey];
|
||||
if (!config) {
|
||||
throw new Error(`Unsupported counter key: ${counterKey}`);
|
||||
}
|
||||
|
||||
const rows = this.db[config.collection] || [];
|
||||
const maxId = rows.reduce((max, row) => {
|
||||
const value = Number(row[config.field] || 0);
|
||||
return value > max ? value : max;
|
||||
}, 0);
|
||||
|
||||
return maxId + 1;
|
||||
}
|
||||
|
||||
getUserByEmail(email) {
|
||||
return this.db.users.find((u) => u.email.toLowerCase() === email.toLowerCase()) || null;
|
||||
}
|
||||
|
||||
getUserById(uid) {
|
||||
return this.db.users.find((u) => u.Uid === Number(uid)) || null;
|
||||
}
|
||||
|
||||
getGroupById(gid) {
|
||||
return this.db.groups.find((group) => group.Gid === Number(gid)) || null;
|
||||
}
|
||||
|
||||
register({ username, email, password, confirmPassword, accountType }) {
|
||||
if (!username || !email || !password || !confirmPassword) {
|
||||
throw new Error('Missing required registration fields');
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
throw new Error('Passwords do not match');
|
||||
}
|
||||
if (this.getUserByEmail(email)) {
|
||||
throw new Error('Email already in use');
|
||||
}
|
||||
|
||||
const uid = this.nextId('user');
|
||||
const user = new UserModel({
|
||||
Uid: uid,
|
||||
username,
|
||||
email,
|
||||
passwordHash: this.hashPassword(password),
|
||||
isBusiness: accountType === 'Business/School',
|
||||
IsAcceptedAdmin: false,
|
||||
Gid: null,
|
||||
SGid: null,
|
||||
TGid: null,
|
||||
});
|
||||
|
||||
this.db.users.push(user.toJSON());
|
||||
return user.toJSON();
|
||||
}
|
||||
|
||||
login({ email, password }) {
|
||||
const user = this.getUserByEmail(email);
|
||||
if (!user) {
|
||||
throw new Error('Email not found');
|
||||
}
|
||||
const incomingHash = this.hashPassword(password);
|
||||
if (incomingHash !== user.passwordHash) {
|
||||
throw new Error('Invalid password');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
getGameTypes() {
|
||||
return this.db.game_types;
|
||||
}
|
||||
|
||||
getSubgroupByJoinCode(joinCode) {
|
||||
return this.db.subgroups.find(
|
||||
(subgroup) => subgroup.JoinCode && subgroup.JoinCode === joinCode.toUpperCase(),
|
||||
) || null;
|
||||
}
|
||||
|
||||
ensureMembership(uid, gid, sgid, isAcceptedAdmin) {
|
||||
const existing = this.db.subgroup_memberships.find(
|
||||
(membership) => membership.Uid === uid && membership.Gid === gid,
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
existing.SGid = sgid;
|
||||
existing.IsAcceptedAdmin = isAcceptedAdmin;
|
||||
return existing;
|
||||
}
|
||||
|
||||
const membership = {
|
||||
Uid: uid,
|
||||
Gid: gid,
|
||||
SGid: sgid,
|
||||
IsAcceptedAdmin: isAcceptedAdmin,
|
||||
};
|
||||
this.db.subgroup_memberships.push(membership);
|
||||
return membership;
|
||||
}
|
||||
|
||||
ensureGroupGameData(gid) {
|
||||
const existing = this.db.game_data.find((entry) => entry.Gid === gid);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const payload = { Gid: gid, GeoCaches: [] };
|
||||
this.db.game_data.push(payload);
|
||||
return payload;
|
||||
}
|
||||
|
||||
hasActiveAdmin(gid) {
|
||||
return this.db.subgroup_memberships.some(
|
||||
(membership) => membership.Gid === gid && membership.SGid === 0 && membership.IsAcceptedAdmin,
|
||||
);
|
||||
}
|
||||
|
||||
canJoinTeamForSubgroup(group, sgid) {
|
||||
return group.TeamsEnabled && sgid > 0;
|
||||
}
|
||||
|
||||
joinPrivateGame({ userId, joinCode }) {
|
||||
const user = this.getUserById(Number(userId));
|
||||
const subgroup = this.getSubgroupByJoinCode(joinCode);
|
||||
|
||||
if (!user || !subgroup || subgroup.IsAdminGroup) {
|
||||
throw new Error('Invalid subgroup join code');
|
||||
}
|
||||
|
||||
const group = this.getGroupById(subgroup.Gid);
|
||||
if (!group) {
|
||||
throw new Error('Group not found');
|
||||
}
|
||||
|
||||
this.ensureMembership(user.Uid, subgroup.Gid, subgroup.SGid, false);
|
||||
user.Gid = group.Gid;
|
||||
user.SGid = subgroup.SGid;
|
||||
user.IsAcceptedAdmin = false;
|
||||
return group;
|
||||
}
|
||||
|
||||
createPrivateGame({ userId, groupName, businessOrSchoolName, gameType = 'private', isBusiness = false }) {
|
||||
const user = this.getUserById(Number(userId));
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const gid = this.nextId('group');
|
||||
const group = new GroupModel({
|
||||
Gid: gid,
|
||||
GroupName: groupName,
|
||||
BusinessOrSchoolName: businessOrSchoolName || groupName,
|
||||
GameType: gameType,
|
||||
AdminJoinCode: this.generateUniqueJoinCode(this.db.groups, 'AdminJoinCode'),
|
||||
CreatedByUid: user.Uid,
|
||||
MaxMemberSubgroups: isBusiness ? 999 : 1,
|
||||
TeamsEnabled: true,
|
||||
ApprovedAdmins: [user.Uid],
|
||||
CreatedAt: this.toMinuteIso(),
|
||||
});
|
||||
|
||||
this.db.groups.push(group.toJSON());
|
||||
this.ensureGroupGameData(gid);
|
||||
|
||||
this.db.subgroups.push({
|
||||
SGid: 0,
|
||||
SubGroupName: 'Admins',
|
||||
Gid: group.Gid,
|
||||
JoinCode: null,
|
||||
IsAdminGroup: true,
|
||||
CacheTriggerMeters: 20,
|
||||
});
|
||||
|
||||
this.createSubgroup({ gid: group.Gid, subgroupName: 'Members', cacheTriggerMeters: 20 });
|
||||
|
||||
this.ensureMembership(user.Uid, group.Gid, 0, true);
|
||||
user.Gid = group.Gid;
|
||||
user.SGid = 0;
|
||||
user.IsAcceptedAdmin = true;
|
||||
|
||||
return group.toJSON();
|
||||
}
|
||||
|
||||
joinAsAdmin({ userId, businessOrSchoolName }) {
|
||||
const user = this.getUserById(Number(userId));
|
||||
const group = this.db.groups.find(
|
||||
(entry) => entry.BusinessOrSchoolName.toLowerCase() === businessOrSchoolName.trim().toLowerCase(),
|
||||
);
|
||||
|
||||
if (!user || !group) {
|
||||
throw new Error('User or business/school not found');
|
||||
}
|
||||
|
||||
this.ensureMembership(user.Uid, group.Gid, 0, false);
|
||||
user.Gid = group.Gid;
|
||||
user.SGid = 0;
|
||||
user.IsAcceptedAdmin = false;
|
||||
|
||||
const activeAdminExists = this.hasActiveAdmin(group.Gid);
|
||||
if (!activeAdminExists) {
|
||||
return this.promoteAdmin({ uid: user.Uid, gid: group.Gid, approvedByUid: user.Uid });
|
||||
}
|
||||
|
||||
const alreadyQueued = this.db.admin_waitlist.some(
|
||||
(entry) => entry.Uid === user.Uid && entry.Gid === group.Gid && entry.Status === 'pending',
|
||||
);
|
||||
if (!alreadyQueued) {
|
||||
this.db.admin_waitlist.push({
|
||||
RequestId: `${group.Gid}_${user.Uid}`,
|
||||
Uid: user.Uid,
|
||||
Gid: group.Gid,
|
||||
RequestedAt: this.toMinuteIso(),
|
||||
Status: 'pending',
|
||||
});
|
||||
}
|
||||
|
||||
return { queued: true, Gid: group.Gid, Uid: user.Uid };
|
||||
}
|
||||
|
||||
promoteAdmin({ uid, gid, approvedByUid }) {
|
||||
const user = this.getUserById(Number(uid));
|
||||
const group = this.getGroupById(Number(gid));
|
||||
if (!user || !group) {
|
||||
throw new Error('User or group not found');
|
||||
}
|
||||
|
||||
this.ensureMembership(user.Uid, group.Gid, 0, true);
|
||||
user.Gid = group.Gid;
|
||||
user.SGid = 0;
|
||||
user.IsAcceptedAdmin = true;
|
||||
|
||||
if (!group.ApprovedAdmins.includes(user.Uid)) {
|
||||
group.ApprovedAdmins.push(user.Uid);
|
||||
}
|
||||
if (!group.ApprovedAdmins.includes(Number(approvedByUid))) {
|
||||
group.ApprovedAdmins.push(Number(approvedByUid));
|
||||
}
|
||||
|
||||
this.db.admin_waitlist = this.db.admin_waitlist.map((entry) => {
|
||||
if (entry.Uid === user.Uid && entry.Gid === group.Gid && entry.Status === 'pending') {
|
||||
return { ...entry, Status: 'approved' };
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
requestAdminApproval({ userId, groupId }) {
|
||||
const group = this.getGroupById(Number(groupId));
|
||||
if (!group) {
|
||||
throw new Error('Group not found');
|
||||
}
|
||||
return this.joinAsAdmin({ userId, businessOrSchoolName: group.BusinessOrSchoolName });
|
||||
}
|
||||
|
||||
approveAdmin({ approverUserId, userId, groupId }) {
|
||||
const approverMembership = this.db.subgroup_memberships.find(
|
||||
(membership) => membership.Uid === Number(approverUserId)
|
||||
&& membership.Gid === Number(groupId)
|
||||
&& membership.SGid === 0
|
||||
&& membership.IsAcceptedAdmin,
|
||||
);
|
||||
if (!approverMembership) {
|
||||
throw new Error('Approver is not an admin for this group');
|
||||
}
|
||||
return this.promoteAdmin({ uid: Number(userId), gid: Number(groupId), approvedByUid: Number(approverUserId) });
|
||||
}
|
||||
|
||||
createSubgroup({ gid, subgroupName, cacheTriggerMeters = 20 }) {
|
||||
const group = this.getGroupById(Number(gid));
|
||||
if (!group) {
|
||||
throw new Error('Group not found');
|
||||
}
|
||||
|
||||
const nonAdminCount = this.db.subgroups.filter((entry) => entry.Gid === group.Gid && !entry.IsAdminGroup).length;
|
||||
if (nonAdminCount >= group.MaxMemberSubgroups) {
|
||||
throw new Error('Maximum subgroup count reached');
|
||||
}
|
||||
|
||||
const nextSgid = this.db.subgroups
|
||||
.filter((entry) => entry.Gid === group.Gid && !entry.IsAdminGroup)
|
||||
.reduce((max, entry) => Math.max(max, entry.SGid), 0) + 1;
|
||||
|
||||
const subgroup = {
|
||||
SGid: nextSgid,
|
||||
SubGroupName: subgroupName,
|
||||
Gid: group.Gid,
|
||||
JoinCode: this.generateUniqueJoinCode(this.db.subgroups.filter((entry) => !entry.IsAdminGroup), 'JoinCode'),
|
||||
IsAdminGroup: false,
|
||||
CacheTriggerMeters: cacheTriggerMeters,
|
||||
};
|
||||
|
||||
this.db.subgroups.push(subgroup);
|
||||
return subgroup;
|
||||
}
|
||||
|
||||
getSubgroups(gid) {
|
||||
return this.db.subgroups.filter((subgroup) => subgroup.Gid === Number(gid));
|
||||
}
|
||||
|
||||
updateSubgroupCacheSettings({ gid, sgid, triggerMeters }) {
|
||||
const subgroup = this.db.subgroups.find(
|
||||
(entry) => entry.Gid === Number(gid) && entry.SGid === Number(sgid),
|
||||
);
|
||||
if (!subgroup) {
|
||||
throw new Error('Subgroup not found');
|
||||
}
|
||||
subgroup.CacheTriggerMeters = Number(triggerMeters);
|
||||
return subgroup;
|
||||
}
|
||||
|
||||
createTeam({ userId, gid, teamName }) {
|
||||
const user = this.getUserById(Number(userId));
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const group = this.getGroupById(Number(gid));
|
||||
if (!group) {
|
||||
throw new Error('Group not found');
|
||||
}
|
||||
if (!this.canJoinTeamForSubgroup(group, user.SGid || 0)) {
|
||||
throw new Error('Teams are disabled or user is in admin subgroup');
|
||||
}
|
||||
|
||||
const tid = this.nextId('team');
|
||||
const team = new TeamModel({
|
||||
Tid: tid,
|
||||
TeamName: teamName,
|
||||
Gid: group.Gid,
|
||||
JoinCode: this.generateUniqueJoinCode(this.db.teams, 'JoinCode'),
|
||||
CreatorUid: user.Uid,
|
||||
SGid: user.SGid,
|
||||
CreatedAt: this.toMinuteIso(),
|
||||
});
|
||||
|
||||
this.db.teams.push(team.toJSON());
|
||||
this.db.team_members.push({
|
||||
Tid: team.Tid,
|
||||
Uid: user.Uid,
|
||||
IsTeamCreator: true,
|
||||
});
|
||||
|
||||
user.TGid = team.Tid;
|
||||
return team.toJSON();
|
||||
}
|
||||
|
||||
joinTeamByCode({ userId, teamCode }) {
|
||||
const user = this.getUserById(Number(userId));
|
||||
const team = this.db.teams.find((t) => t.JoinCode === teamCode.toUpperCase());
|
||||
if (!user || !team) {
|
||||
throw new Error('Invalid user or team code');
|
||||
}
|
||||
|
||||
const group = this.getGroupById(team.Gid);
|
||||
if (!group || !this.canJoinTeamForSubgroup(group, user.SGid || 0)) {
|
||||
throw new Error('Teams are disabled or user is in admin subgroup');
|
||||
}
|
||||
|
||||
if (user.Gid !== team.Gid) {
|
||||
throw new Error('User is not in this game group');
|
||||
}
|
||||
|
||||
const alreadyInTeam = this.db.team_members.some((tm) => tm.Tid === team.Tid && tm.Uid === user.Uid);
|
||||
if (!alreadyInTeam) {
|
||||
this.db.team_members.push({ Tid: team.Tid, Uid: user.Uid, IsTeamCreator: false });
|
||||
}
|
||||
|
||||
user.TGid = team.Tid;
|
||||
return team;
|
||||
}
|
||||
|
||||
getLobby(gid) {
|
||||
const group = this.getGroupById(Number(gid));
|
||||
if (!group) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const members = this.db.subgroup_memberships
|
||||
.filter((membership) => membership.Gid === group.Gid)
|
||||
.map((membership) => this.getUserById(membership.Uid))
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
group,
|
||||
members,
|
||||
subgroups: this.getSubgroups(group.Gid),
|
||||
teams: this.db.teams.filter((team) => team.Gid === group.Gid),
|
||||
};
|
||||
}
|
||||
|
||||
getTeam(tid) {
|
||||
const team = this.db.teams.find((entry) => entry.Tid === Number(tid));
|
||||
if (!team) {
|
||||
return null;
|
||||
}
|
||||
const members = this.db.team_members
|
||||
.filter((member) => member.Tid === team.Tid)
|
||||
.map((member) => this.getUserById(member.Uid))
|
||||
.filter(Boolean);
|
||||
return { team, members };
|
||||
}
|
||||
|
||||
getMapPoints(gid, sgid = null) {
|
||||
const gameData = this.db.game_data.find((entry) => entry.Gid === Number(gid));
|
||||
if (!gameData) {
|
||||
return [];
|
||||
}
|
||||
if (sgid === null || sgid === undefined) {
|
||||
return gameData.GeoCaches;
|
||||
}
|
||||
return gameData.GeoCaches.filter((cache) => cache.SGid === Number(sgid));
|
||||
}
|
||||
|
||||
updateGroupSettings({ gid, teamsEnabled }) {
|
||||
const group = this.getGroupById(Number(gid));
|
||||
if (!group) {
|
||||
throw new Error('Group not found');
|
||||
}
|
||||
group.TeamsEnabled = Boolean(teamsEnabled);
|
||||
return group;
|
||||
}
|
||||
|
||||
getState() {
|
||||
return this.db;
|
||||
}
|
||||
}
|
||||
|
||||
const dbController = new DbController();
|
||||
|
||||
export { DbController };
|
||||
export default dbController;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
class GroupModel {
|
||||
constructor(payload) {
|
||||
this.Gid = payload.Gid;
|
||||
this.GroupName = payload.GroupName;
|
||||
this.BusinessOrSchoolName = payload.BusinessOrSchoolName;
|
||||
this.GameType = payload.GameType;
|
||||
this.AdminJoinCode = payload.AdminJoinCode;
|
||||
this.CreatedByUid = payload.CreatedByUid;
|
||||
this.MaxMemberSubgroups = payload.MaxMemberSubgroups;
|
||||
this.TeamsEnabled = Boolean(payload.TeamsEnabled);
|
||||
this.ApprovedAdmins = payload.ApprovedAdmins || [];
|
||||
this.CreatedAt = payload.CreatedAt;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
Gid: this.Gid,
|
||||
GroupName: this.GroupName,
|
||||
BusinessOrSchoolName: this.BusinessOrSchoolName,
|
||||
GameType: this.GameType,
|
||||
AdminJoinCode: this.AdminJoinCode,
|
||||
CreatedByUid: this.CreatedByUid,
|
||||
MaxMemberSubgroups: this.MaxMemberSubgroups,
|
||||
TeamsEnabled: this.TeamsEnabled,
|
||||
ApprovedAdmins: this.ApprovedAdmins,
|
||||
CreatedAt: this.CreatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default GroupModel;
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
const session = {
|
||||
currentUid: null,
|
||||
currentGid: null,
|
||||
currentSGid: null,
|
||||
currentTGid: null,
|
||||
};
|
||||
|
||||
const setSessionUser = (user) => {
|
||||
session.currentUid = user?.Uid ?? null;
|
||||
session.currentGid = user?.Gid ?? null;
|
||||
session.currentSGid = user?.SGid ?? null;
|
||||
session.currentTGid = user?.TGid ?? null;
|
||||
};
|
||||
|
||||
const setSessionGroup = (gid, sgid = null) => {
|
||||
session.currentGid = gid ?? null;
|
||||
if (sgid !== null && sgid !== undefined) {
|
||||
session.currentSGid = sgid;
|
||||
}
|
||||
};
|
||||
|
||||
const setSessionTeam = (tgid) => {
|
||||
session.currentTGid = tgid ?? null;
|
||||
};
|
||||
|
||||
const getSession = () => ({ ...session });
|
||||
|
||||
export { setSessionUser, setSessionGroup, setSessionTeam, getSession };
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
class TeamModel {
|
||||
constructor(payload) {
|
||||
this.Tid = payload.Tid;
|
||||
this.TeamName = payload.TeamName;
|
||||
this.Gid = payload.Gid;
|
||||
this.JoinCode = payload.JoinCode;
|
||||
this.CreatorUid = payload.CreatorUid;
|
||||
this.SGid = payload.SGid;
|
||||
this.CreatedAt = payload.CreatedAt;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
Tid: this.Tid,
|
||||
TeamName: this.TeamName,
|
||||
Gid: this.Gid,
|
||||
JoinCode: this.JoinCode,
|
||||
CreatorUid: this.CreatorUid,
|
||||
SGid: this.SGid,
|
||||
CreatedAt: this.CreatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default TeamModel;
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
class UserModel {
|
||||
constructor(payload) {
|
||||
this.Uid = payload.Uid;
|
||||
this.username = payload.username;
|
||||
this.email = payload.email;
|
||||
this.passwordHash = payload.passwordHash;
|
||||
this.isBusiness = Boolean(payload.isBusiness);
|
||||
this.IsAcceptedAdmin = Boolean(payload.IsAcceptedAdmin);
|
||||
this.Gid = payload.Gid ?? null;
|
||||
this.SGid = payload.SGid ?? null;
|
||||
this.TGid = payload.TGid ?? null;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
Uid: this.Uid,
|
||||
username: this.username,
|
||||
email: this.email,
|
||||
passwordHash: this.passwordHash,
|
||||
isBusiness: this.isBusiness,
|
||||
IsAcceptedAdmin: this.IsAcceptedAdmin,
|
||||
Gid: this.Gid,
|
||||
SGid: this.SGid,
|
||||
TGid: this.TGid,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default UserModel;
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
import { View, Text, Pressable, StyleSheet } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import AdminFooterNavbar from '../components/AdminFooterNavbar';
|
||||
import useAdminViewModel from '../ViewModel/useAdminViewModel';
|
||||
|
||||
const AdminHubPage = () => {
|
||||
const navigation = useNavigation();
|
||||
const { createGame } = useAdminViewModel();
|
||||
|
||||
const handleCreateGame = async () => {
|
||||
await createGame({
|
||||
groupName: `Admin Game ${Date.now().toString().slice(-4)}`,
|
||||
businessOrSchoolName: 'New Business',
|
||||
gameType: 'private',
|
||||
isBusiness: true,
|
||||
});
|
||||
navigation.navigate('game-lobby');
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.wrapper}>
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Admin Hub</Text>
|
||||
<Pressable style={styles.button} onPress={handleCreateGame}>
|
||||
<Text style={styles.buttonText}>Create a Game</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.button} onPress={() => navigation.navigate('game-lobby')}>
|
||||
<Text style={styles.buttonText}>Manage Existing Game</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.button} onPress={() => navigation.navigate('manage-subgroups')}>
|
||||
<Text style={styles.buttonText}>Manage Subgroups</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<AdminFooterNavbar />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrapper: {
|
||||
flex: 1,
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
gap: 12,
|
||||
},
|
||||
title: {
|
||||
fontSize: 26,
|
||||
fontWeight: '700',
|
||||
color: '#111827',
|
||||
marginBottom: 8,
|
||||
},
|
||||
button: {
|
||||
backgroundColor: '#2563eb',
|
||||
borderRadius: 8,
|
||||
paddingVertical: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
buttonText: {
|
||||
color: '#ffffff',
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
||||
export default AdminHubPage;
|
||||
|
||||
@@ -1,15 +1,105 @@
|
||||
import React from 'react';
|
||||
import { View, Text, Button, TextInput } from 'react-native';
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, Pressable, TextInput, StyleSheet } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import MemberFooterNavbar from '../components/MemberFooterNavbar';
|
||||
import useLobbyViewModel from '../ViewModel/useLobbyViewModel';
|
||||
|
||||
const GameLobbyPage = () => {
|
||||
const navigation = useNavigation();
|
||||
const { teamCode, error, setTeamCode, createLobbyTeam, joinLobbyTeam } = useLobbyViewModel();
|
||||
const [hasTeam, setHasTeam] = useState(false);
|
||||
|
||||
const handleCreateTeam = async () => {
|
||||
const team = await createLobbyTeam({
|
||||
teamName: `Team-${Date.now().toString().slice(-4)}`,
|
||||
});
|
||||
if (!team) {
|
||||
return;
|
||||
}
|
||||
setHasTeam(true);
|
||||
navigation.navigate('team');
|
||||
};
|
||||
|
||||
const handleJoinTeam = async () => {
|
||||
const team = await joinLobbyTeam();
|
||||
if (!team) {
|
||||
return;
|
||||
}
|
||||
setHasTeam(true);
|
||||
navigation.navigate('team');
|
||||
};
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Text>Game Lobby</Text>
|
||||
<Button title="Create Team" onPress={() => {}} />
|
||||
<Text>Join Team Code:</Text>
|
||||
<TextInput placeholder="Enter Team Join Code" />
|
||||
<View style={styles.wrapper}>
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Game Lobby</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Join Team Code"
|
||||
autoCapitalize="characters"
|
||||
maxLength={6}
|
||||
value={teamCode}
|
||||
onChangeText={setTeamCode}
|
||||
/>
|
||||
<Pressable style={styles.secondaryButton} onPress={handleJoinTeam}>
|
||||
<Text style={styles.buttonText}>Join Team</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.primaryButton} onPress={handleCreateTeam}>
|
||||
<Text style={styles.buttonText}>Create Team</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.secondaryButton} onPress={() => navigation.navigate('map')}>
|
||||
<Text style={styles.buttonText}>Open Map</Text>
|
||||
</Pressable>
|
||||
{!!error && <Text style={styles.errorText}>{error}</Text>}
|
||||
</View>
|
||||
<MemberFooterNavbar visible={hasTeam} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrapper: {
|
||||
flex: 1,
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
gap: 12,
|
||||
},
|
||||
title: {
|
||||
fontSize: 26,
|
||||
fontWeight: '700',
|
||||
color: '#111827',
|
||||
marginBottom: 8,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#d1d5db',
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
primaryButton: {
|
||||
backgroundColor: '#2563eb',
|
||||
borderRadius: 8,
|
||||
paddingVertical: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
secondaryButton: {
|
||||
backgroundColor: '#4b5563',
|
||||
borderRadius: 8,
|
||||
paddingVertical: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
buttonText: {
|
||||
color: '#ffffff',
|
||||
fontWeight: '600',
|
||||
},
|
||||
errorText: {
|
||||
color: '#dc2626',
|
||||
},
|
||||
});
|
||||
|
||||
export default GameLobbyPage;
|
||||
@@ -1,14 +1,60 @@
|
||||
import React from 'react';
|
||||
import { View, Button } from 'react-native';
|
||||
import { View, Text, Pressable, StyleSheet } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import useGameTypeViewModel from '../ViewModel/useGameTypeViewModel';
|
||||
|
||||
const GameTypePage = () => {
|
||||
const navigation = useNavigation();
|
||||
const { gameTypes } = useGameTypeViewModel();
|
||||
|
||||
const navigateByType = (label) => {
|
||||
if (label === 'Join Global Game') {
|
||||
navigation.navigate('game-lobby');
|
||||
return;
|
||||
}
|
||||
if (label === 'Join Private Game') {
|
||||
navigation.navigate('join-private-game');
|
||||
return;
|
||||
}
|
||||
navigation.navigate('manage-private-subtask');
|
||||
};
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Button title="Join Global Game" onPress={() => {}} />
|
||||
<Button title="Join Private Game" onPress={() => {}} />
|
||||
<Button title="Manage Private Game" onPress={() => {}} />
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Select Game Type</Text>
|
||||
{gameTypes.map((label) => (
|
||||
<Pressable key={label} style={styles.button} onPress={() => navigateByType(label)}>
|
||||
<Text style={styles.buttonText}>{label}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
gap: 12,
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
title: {
|
||||
fontSize: 26,
|
||||
fontWeight: '700',
|
||||
color: '#111827',
|
||||
marginBottom: 8,
|
||||
},
|
||||
button: {
|
||||
backgroundColor: '#2563eb',
|
||||
borderRadius: 8,
|
||||
paddingVertical: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
buttonText: {
|
||||
color: '#ffffff',
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
||||
export default GameTypePage;
|
||||
@@ -1,19 +1,76 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, TextInput, Button } from 'react-native';
|
||||
import { View, Text, TextInput, Pressable, StyleSheet } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import usePrivateGameViewModel from '../ViewModel/usePrivateGameViewModel';
|
||||
|
||||
const JoinPrivateGamePage = () => {
|
||||
const [joinCode, setJoinCode] = useState('');
|
||||
const navigation = useNavigation();
|
||||
const { joinCode, error, setJoinCode, joinWithCode } = usePrivateGameViewModel();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleJoinGame = () => {
|
||||
// Logic to check join code and redirect to game lobby
|
||||
const handleJoinGame = async () => {
|
||||
setSubmitting(true);
|
||||
const group = await joinWithCode();
|
||||
setSubmitting(false);
|
||||
if (!group) {
|
||||
return;
|
||||
}
|
||||
navigation.navigate('game-lobby');
|
||||
};
|
||||
|
||||
return (
|
||||
<View>
|
||||
<TextInput placeholder="Enter Join Code" value={joinCode} onChangeText={setJoinCode} />
|
||||
<Button title="Join Game" onPress={handleJoinGame} />
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Join Private Game</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Enter Join Code"
|
||||
autoCapitalize="characters"
|
||||
maxLength={6}
|
||||
value={joinCode}
|
||||
onChangeText={setJoinCode}
|
||||
/>
|
||||
<Pressable style={styles.button} onPress={handleJoinGame}>
|
||||
<Text style={styles.buttonText}>{submitting ? 'Joining...' : 'Join Game'}</Text>
|
||||
</Pressable>
|
||||
{!!error && <Text style={styles.errorText}>{error}</Text>}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
gap: 12,
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
title: {
|
||||
fontSize: 26,
|
||||
fontWeight: '700',
|
||||
color: '#111827',
|
||||
marginBottom: 8,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#d1d5db',
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
button: {
|
||||
backgroundColor: '#2563eb',
|
||||
borderRadius: 8,
|
||||
paddingVertical: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
buttonText: {
|
||||
color: '#ffffff',
|
||||
fontWeight: '600',
|
||||
},
|
||||
errorText: {
|
||||
color: '#dc2626',
|
||||
},
|
||||
});
|
||||
|
||||
export default JoinPrivateGamePage;
|
||||
@@ -0,0 +1,114 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, TextInput, Pressable, StyleSheet } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import useAuthViewModel from '../ViewModel/useAuthViewModel';
|
||||
|
||||
const LoginPage = () => {
|
||||
const navigation = useNavigation();
|
||||
const {
|
||||
email,
|
||||
password,
|
||||
error,
|
||||
setEmail,
|
||||
setPassword,
|
||||
login,
|
||||
} = useAuthViewModel();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleLogin = async () => {
|
||||
setSubmitting(true);
|
||||
const user = await login();
|
||||
setSubmitting(false);
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
if (user.SGid === 0) {
|
||||
navigation.navigate('admin-hub');
|
||||
return;
|
||||
}
|
||||
navigation.navigate('game-type');
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Login</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Email"
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
/>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Password"
|
||||
secureTextEntry
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
/>
|
||||
<View style={styles.row}>
|
||||
<Pressable style={styles.leftButton} onPress={() => navigation.navigate('register')}>
|
||||
<Text style={styles.buttonText}>Register</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.rightButton} onPress={handleLogin}>
|
||||
<Text style={styles.buttonText}>{submitting ? 'Logging in...' : 'Login'}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
{!!error && <Text style={styles.errorText}>{error}</Text>}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
justifyContent: 'center',
|
||||
gap: 12,
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: '700',
|
||||
marginBottom: 8,
|
||||
color: '#111827',
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#d1d5db',
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
marginTop: 4,
|
||||
},
|
||||
leftButton: {
|
||||
flex: 1,
|
||||
backgroundColor: '#4b5563',
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
paddingVertical: 12,
|
||||
},
|
||||
rightButton: {
|
||||
flex: 1,
|
||||
backgroundColor: '#2563eb',
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
paddingVertical: 12,
|
||||
},
|
||||
buttonText: {
|
||||
color: '#ffffff',
|
||||
fontWeight: '600',
|
||||
},
|
||||
errorText: {
|
||||
color: '#dc2626',
|
||||
marginTop: 4,
|
||||
},
|
||||
});
|
||||
|
||||
export default LoginPage;
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, TextInput, Pressable, StyleSheet } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import useManagePrivateSubtaskViewModel from '../ViewModel/useManagePrivateSubtaskViewModel';
|
||||
|
||||
const ManagePrivateSubtaskPage = () => {
|
||||
const navigation = useNavigation();
|
||||
const {
|
||||
businessOrSchoolName,
|
||||
error,
|
||||
setBusinessOrSchoolName,
|
||||
createNewPrivateGame,
|
||||
joinAdminForPrivate,
|
||||
} = useManagePrivateSubtaskViewModel();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleCreate = async () => {
|
||||
setSubmitting(true);
|
||||
const group = await createNewPrivateGame();
|
||||
setSubmitting(false);
|
||||
if (!group) {
|
||||
return;
|
||||
}
|
||||
navigation.navigate('admin-hub');
|
||||
};
|
||||
|
||||
const handleJoinAdmin = async () => {
|
||||
setSubmitting(true);
|
||||
const response = await joinAdminForPrivate();
|
||||
setSubmitting(false);
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
navigation.navigate('admin-hub');
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Manage Private Game</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Business/School Name"
|
||||
value={businessOrSchoolName}
|
||||
onChangeText={setBusinessOrSchoolName}
|
||||
/>
|
||||
<Pressable style={styles.primaryButton} onPress={handleCreate}>
|
||||
<Text style={styles.buttonText}>{submitting ? 'Please wait...' : 'Create New Private Game'}</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.secondaryButton} onPress={handleJoinAdmin}>
|
||||
<Text style={styles.buttonText}>Join as Admin for Private Game</Text>
|
||||
</Pressable>
|
||||
{!!error && <Text style={styles.errorText}>{error}</Text>}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
gap: 12,
|
||||
padding: 20,
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
title: {
|
||||
fontSize: 26,
|
||||
fontWeight: '700',
|
||||
color: '#111827',
|
||||
marginBottom: 8,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#d1d5db',
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
primaryButton: {
|
||||
backgroundColor: '#2563eb',
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
paddingVertical: 12,
|
||||
},
|
||||
secondaryButton: {
|
||||
backgroundColor: '#4b5563',
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
paddingVertical: 12,
|
||||
},
|
||||
buttonText: {
|
||||
color: '#ffffff',
|
||||
fontWeight: '600',
|
||||
},
|
||||
errorText: {
|
||||
color: '#dc2626',
|
||||
},
|
||||
});
|
||||
|
||||
export default ManagePrivateSubtaskPage;
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import React from 'react';
|
||||
import { View, Text, TextInput, Pressable, StyleSheet } from 'react-native';
|
||||
import useSubgroupAdminViewModel from '../ViewModel/useSubgroupAdminViewModel';
|
||||
import AdminFooterNavbar from '../components/AdminFooterNavbar';
|
||||
|
||||
const ManageSubgroupsPage = () => {
|
||||
const {
|
||||
subgroups,
|
||||
subgroupName,
|
||||
cacheTriggerMeters,
|
||||
error,
|
||||
setSubgroupName,
|
||||
setCacheTriggerMeters,
|
||||
addSubgroup,
|
||||
updateCacheForSubgroup,
|
||||
} = useSubgroupAdminViewModel();
|
||||
|
||||
return (
|
||||
<View style={styles.wrapper}>
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Manage Subgroups</Text>
|
||||
{subgroups.map((subgroup) => (
|
||||
<View key={`${subgroup.Gid}_${subgroup.SGid}`} style={styles.row}>
|
||||
<Text style={styles.rowText}>
|
||||
SGid {subgroup.SGid} | {subgroup.SubGroupName} | Code: {subgroup.JoinCode || 'ADMIN'} | Trigger: {subgroup.CacheTriggerMeters}m
|
||||
</Text>
|
||||
<Pressable
|
||||
style={styles.inlineButton}
|
||||
onPress={() => updateCacheForSubgroup(subgroup.SGid, (subgroup.CacheTriggerMeters || 20) + 5)}
|
||||
>
|
||||
<Text style={styles.inlineButtonText}>+5m</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
))}
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="New subgroup name"
|
||||
value={subgroupName}
|
||||
onChangeText={setSubgroupName}
|
||||
/>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Cache trigger meters"
|
||||
keyboardType="numeric"
|
||||
value={cacheTriggerMeters}
|
||||
onChangeText={setCacheTriggerMeters}
|
||||
/>
|
||||
<Pressable style={styles.button} onPress={addSubgroup}>
|
||||
<Text style={styles.buttonText}>Create Subgroup</Text>
|
||||
</Pressable>
|
||||
{!!error && <Text style={styles.errorText}>{error}</Text>}
|
||||
</View>
|
||||
<AdminFooterNavbar />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrapper: {
|
||||
flex: 1,
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
gap: 10,
|
||||
padding: 20,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: '700',
|
||||
color: '#111827',
|
||||
},
|
||||
row: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#d1d5db',
|
||||
borderRadius: 8,
|
||||
padding: 10,
|
||||
gap: 8,
|
||||
},
|
||||
rowText: {
|
||||
color: '#111827',
|
||||
fontSize: 13,
|
||||
},
|
||||
inlineButton: {
|
||||
alignSelf: 'flex-start',
|
||||
backgroundColor: '#2563eb',
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 6,
|
||||
},
|
||||
inlineButtonText: {
|
||||
color: '#ffffff',
|
||||
fontWeight: '600',
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#d1d5db',
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
button: {
|
||||
backgroundColor: '#2563eb',
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
paddingVertical: 12,
|
||||
},
|
||||
buttonText: {
|
||||
color: '#ffffff',
|
||||
fontWeight: '600',
|
||||
},
|
||||
errorText: {
|
||||
color: '#dc2626',
|
||||
},
|
||||
});
|
||||
|
||||
export default ManageSubgroupsPage;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import MemberFooterNavbar from '../components/MemberFooterNavbar';
|
||||
import useMapViewModel from '../ViewModel/useMapViewModel';
|
||||
|
||||
const MapPage = () => {
|
||||
const { points } = useMapViewModel();
|
||||
|
||||
return (
|
||||
<View style={styles.wrapper}>
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Map Page</Text>
|
||||
<Text style={styles.body}>Map and hunt markers appear here.</Text>
|
||||
<Text style={styles.body}>Points loaded: {points.length}</Text>
|
||||
</View>
|
||||
<MemberFooterNavbar visible />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrapper: {
|
||||
flex: 1,
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
gap: 6,
|
||||
},
|
||||
title: {
|
||||
fontSize: 26,
|
||||
fontWeight: '700',
|
||||
color: '#111827',
|
||||
},
|
||||
body: {
|
||||
color: '#4b5563',
|
||||
},
|
||||
});
|
||||
|
||||
export default MapPage;
|
||||
|
||||
+132
-19
@@ -1,31 +1,144 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, TextInput, Button, Picker } from 'react-native';
|
||||
import { View, Text, TextInput, Pressable, StyleSheet } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import useAuthViewModel from '../ViewModel/useAuthViewModel';
|
||||
|
||||
const RegisterPage = () => {
|
||||
const [username, setUsername] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [userType, setUserType] = useState('Individual');
|
||||
const navigation = useNavigation();
|
||||
const {
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
confirmPassword,
|
||||
accountType,
|
||||
error,
|
||||
setUsername,
|
||||
setEmail,
|
||||
setPassword,
|
||||
setConfirmPassword,
|
||||
setAccountType,
|
||||
register,
|
||||
} = useAuthViewModel();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleRegister = () => {
|
||||
// Registration logic here
|
||||
const handleRegister = async () => {
|
||||
setSubmitting(true);
|
||||
const user = await register();
|
||||
setSubmitting(false);
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
navigation.navigate(user.SGid === 0 ? 'admin-hub' : 'game-type');
|
||||
};
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Text>Register</Text>
|
||||
<TextInput placeholder="Username" value={username} onChangeText={setUsername} />
|
||||
<TextInput placeholder="Email" value={email} onChangeText={setEmail} />
|
||||
<TextInput placeholder="Password" value={password} onChangeText={setPassword} secureTextEntry />
|
||||
<TextInput placeholder="Confirm Password" value={confirmPassword} onChangeText={setConfirmPassword} secureTextEntry />
|
||||
<Picker selectedValue={userType} onValueChange={(itemValue) => setUserType(itemValue)}>
|
||||
<Picker.Item label="Individual" value="Individual" />
|
||||
<Picker.Item label="Business/School" value="Business/School" />
|
||||
</Picker>
|
||||
<Button title="Register" onPress={handleRegister} />
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Register</Text>
|
||||
<TextInput style={styles.input} placeholder="Username" value={username} onChangeText={setUsername} />
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Email"
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
/>
|
||||
<TextInput style={styles.input} placeholder="Password" value={password} onChangeText={setPassword} secureTextEntry />
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Confirm Password"
|
||||
value={confirmPassword}
|
||||
onChangeText={setConfirmPassword}
|
||||
secureTextEntry
|
||||
/>
|
||||
<View style={styles.typeRow}>
|
||||
<Pressable
|
||||
style={[styles.typeButton, accountType === 'Individual' && styles.typeButtonActive]}
|
||||
onPress={() => setAccountType('Individual')}
|
||||
>
|
||||
<Text style={[styles.typeText, accountType === 'Individual' && styles.typeTextActive]}>Individual</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[styles.typeButton, accountType === 'Business/School' && styles.typeButtonActive]}
|
||||
onPress={() => setAccountType('Business/School')}
|
||||
>
|
||||
<Text style={[styles.typeText, accountType === 'Business/School' && styles.typeTextActive]}>Business/School</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<Pressable style={styles.submitButton} onPress={handleRegister}>
|
||||
<Text style={styles.submitText}>{submitting ? 'Creating...' : 'Create Account'}</Text>
|
||||
</Pressable>
|
||||
<Pressable onPress={() => navigation.navigate('login')}>
|
||||
<Text style={styles.backText}>Back to Login</Text>
|
||||
</Pressable>
|
||||
{!!error && <Text style={styles.errorText}>{error}</Text>}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
justifyContent: 'center',
|
||||
gap: 12,
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: '700',
|
||||
color: '#111827',
|
||||
marginBottom: 8,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#d1d5db',
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
typeRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
},
|
||||
typeButton: {
|
||||
flex: 1,
|
||||
borderWidth: 1,
|
||||
borderColor: '#cbd5e1',
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
paddingVertical: 10,
|
||||
},
|
||||
typeButtonActive: {
|
||||
backgroundColor: '#2563eb',
|
||||
borderColor: '#2563eb',
|
||||
},
|
||||
typeText: {
|
||||
color: '#1f2937',
|
||||
fontWeight: '600',
|
||||
},
|
||||
typeTextActive: {
|
||||
color: '#ffffff',
|
||||
},
|
||||
submitButton: {
|
||||
backgroundColor: '#2563eb',
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
paddingVertical: 12,
|
||||
},
|
||||
submitText: {
|
||||
color: '#ffffff',
|
||||
fontWeight: '600',
|
||||
},
|
||||
backText: {
|
||||
textAlign: 'center',
|
||||
color: '#2563eb',
|
||||
fontWeight: '600',
|
||||
},
|
||||
errorText: {
|
||||
textAlign: 'center',
|
||||
color: '#dc2626',
|
||||
},
|
||||
});
|
||||
|
||||
export default RegisterPage;
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import MemberFooterNavbar from '../components/MemberFooterNavbar';
|
||||
import useTeamViewModel from '../ViewModel/useTeamViewModel';
|
||||
|
||||
const TeamPage = () => {
|
||||
const { team } = useTeamViewModel();
|
||||
|
||||
return (
|
||||
<View style={styles.wrapper}>
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Team Page</Text>
|
||||
<Text style={styles.body}>Team details and members appear here.</Text>
|
||||
<Text style={styles.body}>{team?.team?.TeamName || 'No team joined yet'}</Text>
|
||||
</View>
|
||||
<MemberFooterNavbar visible />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrapper: {
|
||||
flex: 1,
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
gap: 6,
|
||||
},
|
||||
title: {
|
||||
fontSize: 26,
|
||||
fontWeight: '700',
|
||||
color: '#111827',
|
||||
},
|
||||
body: {
|
||||
color: '#4b5563',
|
||||
},
|
||||
});
|
||||
|
||||
export default TeamPage;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
approveAdminAccess,
|
||||
createPrivateGame,
|
||||
requestAdminAccess,
|
||||
updateGroupSettings,
|
||||
} from '../API/API';
|
||||
import { getSession, setSessionGroup } from '../Model/SessionStore';
|
||||
|
||||
const useAdminViewModel = () => {
|
||||
const createGame = async (payload) => {
|
||||
const { currentUid } = getSession();
|
||||
const group = await createPrivateGame({ ...payload, userId: currentUid });
|
||||
setSessionGroup(group.Gid, 0);
|
||||
return group;
|
||||
};
|
||||
const requestAdmin = async (payload) => requestAdminAccess(payload);
|
||||
const approveAdmin = async (payload) => approveAdminAccess(payload);
|
||||
const setTeamsEnabled = async (gid, teamsEnabled) => updateGroupSettings({ gid, teamsEnabled });
|
||||
|
||||
return {
|
||||
createGame,
|
||||
requestAdmin,
|
||||
approveAdmin,
|
||||
setTeamsEnabled,
|
||||
};
|
||||
};
|
||||
|
||||
export default useAdminViewModel;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { loginUser, registerUser } from '../API/API';
|
||||
import { setSessionUser } from '../Model/SessionStore';
|
||||
|
||||
const useAuthViewModel = () => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [username, setUsername] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [accountType, setAccountType] = useState('Individual');
|
||||
const [error, setError] = useState('');
|
||||
const [currentUser, setCurrentUser] = useState(null);
|
||||
|
||||
const canRegister = useMemo(
|
||||
() => Boolean(username && email && password && confirmPassword),
|
||||
[username, email, password, confirmPassword],
|
||||
);
|
||||
|
||||
const login = async () => {
|
||||
setError('');
|
||||
try {
|
||||
const user = await loginUser(email.trim(), password);
|
||||
setCurrentUser(user);
|
||||
setSessionUser(user);
|
||||
return user;
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const register = async () => {
|
||||
setError('');
|
||||
try {
|
||||
const user = await registerUser({
|
||||
username: username.trim(),
|
||||
email: email.trim().toLowerCase(),
|
||||
password,
|
||||
confirmPassword,
|
||||
accountType,
|
||||
});
|
||||
setCurrentUser(user);
|
||||
setSessionUser(user);
|
||||
return user;
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
email,
|
||||
password,
|
||||
username,
|
||||
confirmPassword,
|
||||
accountType,
|
||||
error,
|
||||
currentUser,
|
||||
canRegister,
|
||||
setEmail,
|
||||
setPassword,
|
||||
setUsername,
|
||||
setConfirmPassword,
|
||||
setAccountType,
|
||||
login,
|
||||
register,
|
||||
};
|
||||
};
|
||||
|
||||
export default useAuthViewModel;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getGameTypes } from '../API/API';
|
||||
|
||||
const useGameTypeViewModel = () => {
|
||||
const [gameTypes, setGameTypes] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
getGameTypes().then((types) => {
|
||||
if (mounted) {
|
||||
setGameTypes(types);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { gameTypes };
|
||||
};
|
||||
|
||||
export default useGameTypeViewModel;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useState } from 'react';
|
||||
import { createTeam, getLobby, joinTeam } from '../API/API';
|
||||
import { getSession, setSessionTeam } from '../Model/SessionStore';
|
||||
|
||||
const useLobbyViewModel = () => {
|
||||
const [teamCode, setTeamCode] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const loadLobby = async () => {
|
||||
const { currentGid } = getSession();
|
||||
return getLobby(currentGid);
|
||||
};
|
||||
|
||||
const createLobbyTeam = async ({ teamName }) => {
|
||||
setError('');
|
||||
try {
|
||||
const { currentUid, currentGid } = getSession();
|
||||
const team = await createTeam({ userId: currentUid, gid: currentGid, teamName });
|
||||
setSessionTeam(team.Tid);
|
||||
return team;
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const joinLobbyTeam = async () => {
|
||||
setError('');
|
||||
try {
|
||||
const { currentUid } = getSession();
|
||||
const team = await joinTeam({ userId: currentUid, teamCode: teamCode.trim().toUpperCase() });
|
||||
setSessionTeam(team.Tid);
|
||||
return team;
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
teamCode,
|
||||
error,
|
||||
setTeamCode,
|
||||
loadLobby,
|
||||
createLobbyTeam,
|
||||
joinLobbyTeam,
|
||||
};
|
||||
};
|
||||
|
||||
export default useLobbyViewModel;
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useState } from 'react';
|
||||
import { createPrivateGame, joinAsAdmin } from '../API/API';
|
||||
import { getSession, setSessionGroup } from '../Model/SessionStore';
|
||||
|
||||
const useManagePrivateSubtaskViewModel = () => {
|
||||
const [businessOrSchoolName, setBusinessOrSchoolName] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const createNewPrivateGame = async () => {
|
||||
setError('');
|
||||
const { currentUid } = getSession();
|
||||
try {
|
||||
const group = await createPrivateGame({
|
||||
userId: currentUid,
|
||||
groupName: businessOrSchoolName.trim() || 'New Private Game',
|
||||
businessOrSchoolName: businessOrSchoolName.trim() || 'Unnamed Business',
|
||||
isBusiness: true,
|
||||
});
|
||||
setSessionGroup(group.Gid, 0);
|
||||
return group;
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const joinAdminForPrivate = async () => {
|
||||
setError('');
|
||||
const { currentUid } = getSession();
|
||||
try {
|
||||
const response = await joinAsAdmin({
|
||||
userId: currentUid,
|
||||
businessOrSchoolName: businessOrSchoolName.trim(),
|
||||
});
|
||||
const gid = response?.Gid ?? null;
|
||||
if (gid !== null) {
|
||||
setSessionGroup(gid, 0);
|
||||
}
|
||||
return response;
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
businessOrSchoolName,
|
||||
error,
|
||||
setBusinessOrSchoolName,
|
||||
createNewPrivateGame,
|
||||
joinAdminForPrivate,
|
||||
};
|
||||
};
|
||||
|
||||
export default useManagePrivateSubtaskViewModel;
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getMapPoints } from '../API/API';
|
||||
import { getSession } from '../Model/SessionStore';
|
||||
|
||||
const useMapViewModel = (groupId = null) => {
|
||||
const [points, setPoints] = useState([]);
|
||||
const { currentGid, currentSGid } = getSession();
|
||||
const resolvedGid = groupId ?? currentGid;
|
||||
|
||||
useEffect(() => {
|
||||
if (!resolvedGid) {
|
||||
setPoints([]);
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
getMapPoints(resolvedGid, currentSGid).then((payload) => {
|
||||
if (active) {
|
||||
setPoints(payload);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [resolvedGid, currentSGid]);
|
||||
|
||||
return { points };
|
||||
};
|
||||
|
||||
export default useMapViewModel;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useState } from 'react';
|
||||
import { joinPrivateGame } from '../API/API';
|
||||
import { getSession, setSessionGroup } from '../Model/SessionStore';
|
||||
|
||||
const usePrivateGameViewModel = () => {
|
||||
const [joinCode, setJoinCode] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const joinWithCode = async () => {
|
||||
setError('');
|
||||
try {
|
||||
const { currentUid } = getSession();
|
||||
const group = await joinPrivateGame({ userId: currentUid, joinCode: joinCode.trim().toUpperCase() });
|
||||
setSessionGroup(group.Gid);
|
||||
return group;
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
joinCode,
|
||||
error,
|
||||
setJoinCode,
|
||||
joinWithCode,
|
||||
};
|
||||
};
|
||||
|
||||
export default usePrivateGameViewModel;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { createSubgroup, getSubgroups, updateSubgroupCache } from '../API/API';
|
||||
import { getSession } from '../Model/SessionStore';
|
||||
|
||||
const useSubgroupAdminViewModel = () => {
|
||||
const [subgroups, setSubgroups] = useState([]);
|
||||
const [subgroupName, setSubgroupName] = useState('');
|
||||
const [cacheTriggerMeters, setCacheTriggerMeters] = useState('20');
|
||||
const [error, setError] = useState('');
|
||||
const { currentGid } = getSession();
|
||||
|
||||
const loadSubgroups = useCallback(async () => {
|
||||
if (!currentGid) {
|
||||
return;
|
||||
}
|
||||
const rows = await getSubgroups(currentGid);
|
||||
setSubgroups(rows);
|
||||
}, [currentGid]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSubgroups();
|
||||
}, [loadSubgroups]);
|
||||
|
||||
const addSubgroup = async () => {
|
||||
setError('');
|
||||
try {
|
||||
await createSubgroup({
|
||||
gid: currentGid,
|
||||
subgroupName: subgroupName.trim(),
|
||||
cacheTriggerMeters: Number(cacheTriggerMeters),
|
||||
});
|
||||
setSubgroupName('');
|
||||
await loadSubgroups();
|
||||
return true;
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const updateCacheForSubgroup = async (sgid, triggerMeters) => {
|
||||
setError('');
|
||||
try {
|
||||
await updateSubgroupCache({ gid: currentGid, sgid, triggerMeters });
|
||||
await loadSubgroups();
|
||||
return true;
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
subgroups,
|
||||
subgroupName,
|
||||
cacheTriggerMeters,
|
||||
error,
|
||||
setSubgroupName,
|
||||
setCacheTriggerMeters,
|
||||
addSubgroup,
|
||||
updateCacheForSubgroup,
|
||||
};
|
||||
};
|
||||
|
||||
export default useSubgroupAdminViewModel;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getTeam } from '../API/API';
|
||||
import { getSession } from '../Model/SessionStore';
|
||||
|
||||
const useTeamViewModel = (teamId = null) => {
|
||||
const [team, setTeam] = useState(null);
|
||||
const resolvedTeamId = teamId ?? getSession().currentTGid;
|
||||
|
||||
useEffect(() => {
|
||||
if (!resolvedTeamId) {
|
||||
setTeam(null);
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
getTeam(resolvedTeamId).then((payload) => {
|
||||
if (active) {
|
||||
setTeam(payload);
|
||||
}
|
||||
}).catch(() => {
|
||||
if (active) {
|
||||
setTeam(null);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [resolvedTeamId]);
|
||||
|
||||
return { team };
|
||||
};
|
||||
|
||||
export default useTeamViewModel;
|
||||
|
||||
+15
-1
@@ -1,5 +1,19 @@
|
||||
import { Stack } from "expo-router";
|
||||
|
||||
export default function RootLayout() {
|
||||
return <Stack />;
|
||||
return (
|
||||
<Stack initialRouteName="index">
|
||||
<Stack.Screen name="index" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="login" options={{ title: "Login" }} />
|
||||
<Stack.Screen name="register" options={{ title: "Register" }} />
|
||||
<Stack.Screen name="game-type" options={{ title: "Select Game Type" }} />
|
||||
<Stack.Screen name="join-private-game" options={{ title: "Join Private Game" }} />
|
||||
<Stack.Screen name="manage-private-subtask" options={{ title: "Manage Private Game" }} />
|
||||
<Stack.Screen name="game-lobby" options={{ title: "Game Lobby" }} />
|
||||
<Stack.Screen name="team" options={{ title: "Team" }} />
|
||||
<Stack.Screen name="map" options={{ title: "Map" }} />
|
||||
<Stack.Screen name="admin-hub" options={{ title: "Admin Hub" }} />
|
||||
<Stack.Screen name="manage-subgroups" options={{ title: "Manage Subgroups" }} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import AdminHubPage from './Screens/AdminHubPage';
|
||||
|
||||
export default AdminHubPage;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import { View, Pressable, Text, StyleSheet } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
|
||||
const AdminFooterNavbar = () => {
|
||||
const navigation = useNavigation();
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Pressable style={styles.button} onPress={() => navigation.navigate('admin-hub')}>
|
||||
<Text style={styles.text}>Admin Hub</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.button} onPress={() => navigation.navigate('game-lobby')}>
|
||||
<Text style={styles.text}>Manage Game</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.button} onPress={() => navigation.navigate('team')}>
|
||||
<Text style={styles.text}>Manage Team</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
borderTopWidth: 1,
|
||||
borderColor: '#d4d4d8',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
button: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
},
|
||||
text: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#111827',
|
||||
},
|
||||
});
|
||||
|
||||
export default AdminFooterNavbar;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import { View, Pressable, Text, StyleSheet } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
|
||||
const MemberFooterNavbar = ({ visible = false }) => {
|
||||
const navigation = useNavigation();
|
||||
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Pressable style={styles.button} onPress={() => navigation.navigate('game-lobby')}>
|
||||
<Text style={styles.text}>Lobby</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.button} onPress={() => navigation.navigate('team')}>
|
||||
<Text style={styles.text}>Team</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.button} onPress={() => navigation.navigate('map')}>
|
||||
<Text style={styles.text}>Map</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
borderTopWidth: 1,
|
||||
borderColor: '#d4d4d8',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
button: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
},
|
||||
text: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#111827',
|
||||
},
|
||||
});
|
||||
|
||||
export default MemberFooterNavbar;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import GameLobbyPage from './Screens/GameLobbyPage';
|
||||
|
||||
export default GameLobbyPage;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import GameTypePage from './Screens/GameTypePage';
|
||||
|
||||
export default GameTypePage;
|
||||
|
||||
+2
-12
@@ -1,15 +1,5 @@
|
||||
import { Text, View } from "react-native";
|
||||
import { Redirect } from "expo-router";
|
||||
|
||||
export default function Index() {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Text>Cat testing</Text>
|
||||
</View>
|
||||
);
|
||||
return <Redirect href="/login" />;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import JoinPrivateGamePage from './Screens/JoinPrivateGamePage';
|
||||
|
||||
export default JoinPrivateGamePage;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import LoginPage from './Screens/LoginPage';
|
||||
|
||||
export default LoginPage;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import ManagePrivateSubtaskPage from './Screens/ManagePrivateSubtaskPage';
|
||||
|
||||
export default ManagePrivateSubtaskPage;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import ManageSubgroupsPage from './Screens/ManageSubgroupsPage';
|
||||
|
||||
export default ManageSubgroupsPage;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import MapPage from './Screens/MapPage';
|
||||
|
||||
export default MapPage;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import RegisterPage from './Screens/RegisterPage';
|
||||
|
||||
export default RegisterPage;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import TeamPage from './Screens/TeamPage';
|
||||
|
||||
export default TeamPage;
|
||||
|
||||
Generated
-24
@@ -79,7 +79,6 @@
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -1367,7 +1366,6 @@
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
|
||||
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
@@ -2830,7 +2828,6 @@
|
||||
"version": "7.1.33",
|
||||
"resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.1.33.tgz",
|
||||
"integrity": "sha512-DpFdWGcgLajKZ1TuIvDNQsblN2QaUFWpTQaB8v7WRP9Mix8H/6TFoIrZd93pbymI2hybd6UYrD+lI408eWVcfw==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@react-navigation/core": "^7.16.1",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
@@ -3008,7 +3005,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz",
|
||||
"integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==",
|
||||
"devOptional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
@@ -3073,7 +3069,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.0.tgz",
|
||||
"integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.57.0",
|
||||
"@typescript-eslint/types": "8.57.0",
|
||||
@@ -3622,7 +3617,6 @@
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -4262,7 +4256,6 @@
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -5160,7 +5153,6 @@
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz",
|
||||
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -5348,7 +5340,6 @@
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
|
||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rtsao/scc": "^1.1.0",
|
||||
"array-includes": "^3.1.9",
|
||||
@@ -5572,7 +5563,6 @@
|
||||
"version": "54.0.33",
|
||||
"resolved": "https://registry.npmjs.org/expo/-/expo-54.0.33.tgz",
|
||||
"integrity": "sha512-3yOEfAKqo+gqHcV8vKcnq0uA5zxlohnhA3fu4G43likN8ct5ZZ3LjAh9wDdKteEkoad3tFPvwxmXW711S5OHUw==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.0",
|
||||
"@expo/cli": "54.0.23",
|
||||
@@ -5638,7 +5628,6 @@
|
||||
"version": "18.0.13",
|
||||
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz",
|
||||
"integrity": "sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@expo/config": "~12.0.13",
|
||||
"@expo/env": "~2.0.8"
|
||||
@@ -5661,7 +5650,6 @@
|
||||
"version": "14.0.11",
|
||||
"resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.11.tgz",
|
||||
"integrity": "sha512-ga0q61ny4s/kr4k8JX9hVH69exVSIfcIc19+qZ7gt71Mqtm7xy2c6kwsPTCyhBW2Ro5yXTT8EaZOpuRi35rHbg==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fontfaceobserver": "^2.1.0"
|
||||
},
|
||||
@@ -5708,7 +5696,6 @@
|
||||
"version": "8.0.11",
|
||||
"resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.11.tgz",
|
||||
"integrity": "sha512-+VSaNL5om3kOp/SSKO5qe6cFgfSIWnnQDSbA7XLs3ECkYzXRquk5unxNS3pg7eK5kNUmQ4kgLI7MhTggAEUBLA==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"expo-constants": "~18.0.12",
|
||||
"invariant": "^2.2.4"
|
||||
@@ -9406,7 +9393,6 @@
|
||||
"version": "19.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
||||
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -9424,7 +9410,6 @@
|
||||
"version": "19.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
||||
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.26.0"
|
||||
},
|
||||
@@ -9457,7 +9442,6 @@
|
||||
"version": "0.81.5",
|
||||
"resolved": "https://registry.npmjs.org/react-native/-/react-native-0.81.5.tgz",
|
||||
"integrity": "sha512-1w+/oSjEXZjMqsIvmkCRsOc8UBYv163bTWKTI8+1mxztvQPhCRYGTvZ/PL1w16xXHneIj/SLGfxWg2GWN2uexw==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jest/create-cache-key-function": "^29.7.0",
|
||||
"@react-native/assets-registry": "0.81.5",
|
||||
@@ -9514,7 +9498,6 @@
|
||||
"version": "2.28.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.28.0.tgz",
|
||||
"integrity": "sha512-0msfJ1vRxXKVgTgvL+1ZOoYw3/0z1R+Ked0+udoJhyplC2jbVKIJ8Z1bzWdpQRCV3QcQ87Op0zJVE5DhKK2A0A==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@egjs/hammerjs": "^2.0.17",
|
||||
"hoist-non-react-statics": "^3.3.0",
|
||||
@@ -9538,7 +9521,6 @@
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.6.tgz",
|
||||
"integrity": "sha512-F+ZJBYiok/6Jzp1re75F/9aLzkgoQCOh4yxrnwATa8392RvM3kx+fiXXFvwcgE59v48lMwd9q0nzF1oJLXpfxQ==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"react-native-is-edge-to-edge": "^1.2.1",
|
||||
"semver": "7.7.2"
|
||||
@@ -9565,7 +9547,6 @@
|
||||
"version": "5.6.2",
|
||||
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz",
|
||||
"integrity": "sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg==",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
@@ -9575,7 +9556,6 @@
|
||||
"version": "4.16.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.16.0.tgz",
|
||||
"integrity": "sha512-yIAyh7F/9uWkOzCi1/2FqvNvK6Wb9Y1+Kzn16SuGfN9YFJDTbwlzGRvePCNTOX0recpLQF3kc2FmvMUhyTCH1Q==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"react-freeze": "^1.0.0",
|
||||
"react-native-is-edge-to-edge": "^1.2.1",
|
||||
@@ -9590,7 +9570,6 @@
|
||||
"version": "0.21.2",
|
||||
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz",
|
||||
"integrity": "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.6",
|
||||
"@react-native/normalize-colors": "^0.74.1",
|
||||
@@ -9723,7 +9702,6 @@
|
||||
"version": "0.14.2",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
|
||||
"integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -10914,7 +10892,6 @@
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -11104,7 +11081,6 @@
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
||||
Reference in new issue
Block a user