Add initial JSON data files and implement core page components for game functionality

This commit is contained in:
bobbert committed 2026-03-17 21:49:44 +00:00
1 parent 81847a9188
commit ca27f8d6ed
54 files changed
+2424 -250

No files matched your search

+44
View File
@@ -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`
+2
View File
@@ -0,0 +1,2 @@
[]
+12 -43
View File
@@ -1,45 +1,14 @@
{ {
"users": [ "databaseMode": "split-endpoints",
{ "index": {
"Uid": 1, "users": "users.json",
"username": "admin", "groups": "groups.json",
"password": "admin123", "subgroups": "subgroups.json",
"Gid": null, "subgroup_memberships": "subgroup-memberships.json",
"SGid": 0, "admin_waitlist": "admin-waitlist.json",
"TGid": null "teams": "teams.json",
}, "team_members": "team-members.json",
{ "game_data": "game-data.json",
"Uid": 2, "game_types": "game-types.json"
"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
}
]
} }
+16
View File
@@ -0,0 +1,16 @@
[
{
"Gid": 1,
"GeoCaches": [
{
"CacheId": "C1",
"Title": "Main Entrance",
"Latitude": 0,
"Longitude": 0,
"TriggerMeters": 20,
"SGid": 1
}
]
}
]
+6
View File
@@ -0,0 +1,6 @@
[
"Join Global Game",
"Join Private Game",
"Manage Private Game"
]
+15
View File
@@ -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"
}
]
-27
View File
@@ -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)
);
+15
View File
@@ -0,0 +1,15 @@
[
{
"Uid": 1,
"Gid": 1,
"SGid": 0,
"IsAcceptedAdmin": true
},
{
"Uid": 2,
"Gid": 1,
"SGid": 1,
"IsAcceptedAdmin": false
}
]
+19
View File
@@ -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
}
]
+2
View File
@@ -0,0 +1,2 @@
[]
+2
View File
@@ -0,0 +1,2 @@
[]
+25
View File
@@ -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
}
]
+35 -23
View File
@@ -1,6 +1,4 @@
# Welcome to your Expo app 👋 # Welcome to the mobile application development repository for our Treasure Hunt Game! This project is built using React Native and Expo.
This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app).
## Get started ## Get started
@@ -16,35 +14,49 @@ This is an [Expo](https://expo.dev) project created with [`create-expo-app`](htt
npx expo start 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 - Keep data modular and readable.
npm run reset-project - 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). - `Uid`: unique user ID.
- [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. - `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
View File
@@ -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 setApiConfig = (nextConfig = {}) => {
const APIFetch = async (endpoint, options = {}) => { Object.assign(apiConfig, nextConfig);
try { };
const response = await fetch(`${API_BASE_URL}/${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers,
},
});
if (!response.ok) { const isSameId = (item, id) =>
throw new Error(`API error: ${response.statusText}`); item.id?.toString() === id
} || item.Uid?.toString() === id
|| item.Gid?.toString() === id
|| item.SGid?.toString() === id
|| item.Tid?.toString() === id;
return await response.json(); const normalize = (endpoint) => endpoint.replace(/^\/+/, '').split('/');
} catch (error) {
console.error('Fetch error:', error); const localGet = (endpoint) => {
throw error; 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 localPost = (endpoint, payload) => {
const fetchUserByUsername = async (username) => { const [resource] = normalize(endpoint);
return await APIFetch(`users?username=${username}`);
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 localPut = (endpoint, payload) => {
const fetchGroups = async () => { const [resource, id] = normalize(endpoint);
return await APIFetch('groups'); 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 localDelete = (endpoint) => {
const fetchSubgroupsByGroupId = async (groupId) => { const [resource, id] = normalize(endpoint);
return await APIFetch(`groups/${groupId}/subgroups`); 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 remoteRequest = async (endpoint, options = {}) => {
const fetchItems = async (endpoint) => await APIFetch(endpoint); const response = await fetch(`${apiConfig.baseUrl}/${endpoint.replace(/^\/+/, '')}`, {
const createItem = async (endpoint, data) => await APIFetch(endpoint, { method: 'POST', body: JSON.stringify(data) }); ...options,
const updateItem = async (endpoint, data) => await APIFetch(endpoint, { method: 'PUT', body: JSON.stringify(data) }); headers: {
const deleteItem = async (endpoint) => await APIFetch(endpoint, { method: 'DELETE' }); '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,
};
+21 -48
View File
@@ -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 { useState } from 'react';
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
const useLoginLogic = () => { const useLoginLogic = () => {
const [username, setUsername] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const navigation = useNavigation(); const navigation = useNavigation();
const dispatch = useDispatch();
const handleLogin = async () => { const handleLogin = async () => {
try { if (!email || !password) {
// Replace with your actual API endpoint return false;
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.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;
+502
View File
@@ -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;
+32
View File
@@ -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;
+30
View File
@@ -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 };
+26
View File
@@ -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;
+30
View File
@@ -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;
+70
View File
@@ -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;
+97 -7
View File
@@ -1,15 +1,105 @@
import React from 'react'; import React, { useState } from 'react';
import { View, Text, Button, TextInput } from 'react-native'; 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 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 ( return (
<View> <View style={styles.wrapper}>
<Text>Game Lobby</Text> <View style={styles.container}>
<Button title="Create Team" onPress={() => {}} /> <Text style={styles.title}>Game Lobby</Text>
<Text>Join Team Code:</Text> <TextInput
<TextInput placeholder="Enter Team Join Code" /> 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> </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; export default GameLobbyPage;
+51 -5
View File
@@ -1,14 +1,60 @@
import React from 'react'; 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 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 ( return (
<View> <View style={styles.container}>
<Button title="Join Global Game" onPress={() => {}} /> <Text style={styles.title}>Select Game Type</Text>
<Button title="Join Private Game" onPress={() => {}} /> {gameTypes.map((label) => (
<Button title="Manage Private Game" onPress={() => {}} /> <Pressable key={label} style={styles.button} onPress={() => navigateByType(label)}>
<Text style={styles.buttonText}>{label}</Text>
</Pressable>
))}
</View> </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; export default GameTypePage;
+64 -7
View File
@@ -1,19 +1,76 @@
import React, { useState } from 'react'; 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 JoinPrivateGamePage = () => {
const [joinCode, setJoinCode] = useState(''); const navigation = useNavigation();
const { joinCode, error, setJoinCode, joinWithCode } = usePrivateGameViewModel();
const [submitting, setSubmitting] = useState(false);
const handleJoinGame = () => { const handleJoinGame = async () => {
// Logic to check join code and redirect to game lobby setSubmitting(true);
const group = await joinWithCode();
setSubmitting(false);
if (!group) {
return;
}
navigation.navigate('game-lobby');
}; };
return ( return (
<View> <View style={styles.container}>
<TextInput placeholder="Enter Join Code" value={joinCode} onChangeText={setJoinCode} /> <Text style={styles.title}>Join Private Game</Text>
<Button title="Join Game" onPress={handleJoinGame} /> <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> </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; export default JoinPrivateGamePage;
+114
View File
@@ -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;
+100
View File
@@ -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;
+118
View File
@@ -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;
+44
View File
@@ -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
View File
@@ -1,31 +1,144 @@
import React, { useState } from 'react'; 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 RegisterPage = () => {
const [username, setUsername] = useState(''); const navigation = useNavigation();
const [email, setEmail] = useState(''); const {
const [password, setPassword] = useState(''); username,
const [confirmPassword, setConfirmPassword] = useState(''); email,
const [userType, setUserType] = useState('Individual'); password,
confirmPassword,
accountType,
error,
setUsername,
setEmail,
setPassword,
setConfirmPassword,
setAccountType,
register,
} = useAuthViewModel();
const [submitting, setSubmitting] = useState(false);
const handleRegister = () => { const handleRegister = async () => {
// Registration logic here setSubmitting(true);
const user = await register();
setSubmitting(false);
if (!user) {
return;
}
navigation.navigate(user.SGid === 0 ? 'admin-hub' : 'game-type');
}; };
return ( return (
<View> <View style={styles.container}>
<Text>Register</Text> <Text style={styles.title}>Register</Text>
<TextInput placeholder="Username" value={username} onChangeText={setUsername} /> <TextInput style={styles.input} placeholder="Username" value={username} onChangeText={setUsername} />
<TextInput placeholder="Email" value={email} onChangeText={setEmail} /> <TextInput
<TextInput placeholder="Password" value={password} onChangeText={setPassword} secureTextEntry /> style={styles.input}
<TextInput placeholder="Confirm Password" value={confirmPassword} onChangeText={setConfirmPassword} secureTextEntry /> placeholder="Email"
<Picker selectedValue={userType} onValueChange={(itemValue) => setUserType(itemValue)}> autoCapitalize="none"
<Picker.Item label="Individual" value="Individual" /> keyboardType="email-address"
<Picker.Item label="Business/School" value="Business/School" /> value={email}
</Picker> onChangeText={setEmail}
<Button title="Register" onPress={handleRegister} /> />
<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> </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; export default RegisterPage;
+44
View File
@@ -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;
+29
View File
@@ -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;
+71
View File
@@ -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;
+23
View File
@@ -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;
+51
View File
@@ -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;
+30
View File
@@ -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;
+31
View File
@@ -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;
+33
View File
@@ -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
View File
@@ -1,5 +1,19 @@
import { Stack } from "expo-router"; import { Stack } from "expo-router";
export default function RootLayout() { 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>
);
} }
+4
View File
@@ -0,0 +1,4 @@
import AdminHubPage from './Screens/AdminHubPage';
export default AdminHubPage;
+45
View File
@@ -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;
+49
View File
@@ -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;
+4
View File
@@ -0,0 +1,4 @@
import GameLobbyPage from './Screens/GameLobbyPage';
export default GameLobbyPage;
+4
View File
@@ -0,0 +1,4 @@
import GameTypePage from './Screens/GameTypePage';
export default GameTypePage;
+2 -12
View File
@@ -1,15 +1,5 @@
import { Text, View } from "react-native"; import { Redirect } from "expo-router";
export default function Index() { export default function Index() {
return ( return <Redirect href="/login" />;
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
}}
>
<Text>Cat testing</Text>
</View>
);
} }
+4
View File
@@ -0,0 +1,4 @@
import JoinPrivateGamePage from './Screens/JoinPrivateGamePage';
export default JoinPrivateGamePage;
+4
View File
@@ -0,0 +1,4 @@
import LoginPage from './Screens/LoginPage';
export default LoginPage;
+4
View File
@@ -0,0 +1,4 @@
import ManagePrivateSubtaskPage from './Screens/ManagePrivateSubtaskPage';
export default ManagePrivateSubtaskPage;
+4
View File
@@ -0,0 +1,4 @@
import ManageSubgroupsPage from './Screens/ManageSubgroupsPage';
export default ManageSubgroupsPage;
+4
View File
@@ -0,0 +1,4 @@
import MapPage from './Screens/MapPage';
export default MapPage;
+4
View File
@@ -0,0 +1,4 @@
import RegisterPage from './Screens/RegisterPage';
export default RegisterPage;
+4
View File
@@ -0,0 +1,4 @@
import TeamPage from './Screens/TeamPage';
export default TeamPage;
-24
View File
@@ -79,7 +79,6 @@
"version": "7.29.0", "version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.0", "@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0", "@babel/generator": "^7.29.0",
@@ -1367,7 +1366,6 @@
"version": "7.28.6", "version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
"peer": true,
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
@@ -2830,7 +2828,6 @@
"version": "7.1.33", "version": "7.1.33",
"resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.1.33.tgz", "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.1.33.tgz",
"integrity": "sha512-DpFdWGcgLajKZ1TuIvDNQsblN2QaUFWpTQaB8v7WRP9Mix8H/6TFoIrZd93pbymI2hybd6UYrD+lI408eWVcfw==", "integrity": "sha512-DpFdWGcgLajKZ1TuIvDNQsblN2QaUFWpTQaB8v7WRP9Mix8H/6TFoIrZd93pbymI2hybd6UYrD+lI408eWVcfw==",
"peer": true,
"dependencies": { "dependencies": {
"@react-navigation/core": "^7.16.1", "@react-navigation/core": "^7.16.1",
"escape-string-regexp": "^4.0.0", "escape-string-regexp": "^4.0.0",
@@ -3008,7 +3005,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz",
"integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==", "integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==",
"devOptional": true, "devOptional": true,
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.0.2" "csstype": "^3.0.2"
} }
@@ -3073,7 +3069,6 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.0.tgz",
"integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==", "integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.57.0", "@typescript-eslint/scope-manager": "8.57.0",
"@typescript-eslint/types": "8.57.0", "@typescript-eslint/types": "8.57.0",
@@ -3622,7 +3617,6 @@
"version": "8.16.0", "version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -4262,7 +4256,6 @@
"url": "https://github.com/sponsors/ai" "url": "https://github.com/sponsors/ai"
} }
], ],
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@@ -5160,7 +5153,6 @@
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz",
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@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", "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"@rtsao/scc": "^1.1.0", "@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9", "array-includes": "^3.1.9",
@@ -5572,7 +5563,6 @@
"version": "54.0.33", "version": "54.0.33",
"resolved": "https://registry.npmjs.org/expo/-/expo-54.0.33.tgz", "resolved": "https://registry.npmjs.org/expo/-/expo-54.0.33.tgz",
"integrity": "sha512-3yOEfAKqo+gqHcV8vKcnq0uA5zxlohnhA3fu4G43likN8ct5ZZ3LjAh9wDdKteEkoad3tFPvwxmXW711S5OHUw==", "integrity": "sha512-3yOEfAKqo+gqHcV8vKcnq0uA5zxlohnhA3fu4G43likN8ct5ZZ3LjAh9wDdKteEkoad3tFPvwxmXW711S5OHUw==",
"peer": true,
"dependencies": { "dependencies": {
"@babel/runtime": "^7.20.0", "@babel/runtime": "^7.20.0",
"@expo/cli": "54.0.23", "@expo/cli": "54.0.23",
@@ -5638,7 +5628,6 @@
"version": "18.0.13", "version": "18.0.13",
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz",
"integrity": "sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ==", "integrity": "sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ==",
"peer": true,
"dependencies": { "dependencies": {
"@expo/config": "~12.0.13", "@expo/config": "~12.0.13",
"@expo/env": "~2.0.8" "@expo/env": "~2.0.8"
@@ -5661,7 +5650,6 @@
"version": "14.0.11", "version": "14.0.11",
"resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.11.tgz", "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.11.tgz",
"integrity": "sha512-ga0q61ny4s/kr4k8JX9hVH69exVSIfcIc19+qZ7gt71Mqtm7xy2c6kwsPTCyhBW2Ro5yXTT8EaZOpuRi35rHbg==", "integrity": "sha512-ga0q61ny4s/kr4k8JX9hVH69exVSIfcIc19+qZ7gt71Mqtm7xy2c6kwsPTCyhBW2Ro5yXTT8EaZOpuRi35rHbg==",
"peer": true,
"dependencies": { "dependencies": {
"fontfaceobserver": "^2.1.0" "fontfaceobserver": "^2.1.0"
}, },
@@ -5708,7 +5696,6 @@
"version": "8.0.11", "version": "8.0.11",
"resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.11.tgz", "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.11.tgz",
"integrity": "sha512-+VSaNL5om3kOp/SSKO5qe6cFgfSIWnnQDSbA7XLs3ECkYzXRquk5unxNS3pg7eK5kNUmQ4kgLI7MhTggAEUBLA==", "integrity": "sha512-+VSaNL5om3kOp/SSKO5qe6cFgfSIWnnQDSbA7XLs3ECkYzXRquk5unxNS3pg7eK5kNUmQ4kgLI7MhTggAEUBLA==",
"peer": true,
"dependencies": { "dependencies": {
"expo-constants": "~18.0.12", "expo-constants": "~18.0.12",
"invariant": "^2.2.4" "invariant": "^2.2.4"
@@ -9406,7 +9393,6 @@
"version": "19.1.0", "version": "19.1.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -9424,7 +9410,6 @@
"version": "19.1.0", "version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.26.0" "scheduler": "^0.26.0"
}, },
@@ -9457,7 +9442,6 @@
"version": "0.81.5", "version": "0.81.5",
"resolved": "https://registry.npmjs.org/react-native/-/react-native-0.81.5.tgz", "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.81.5.tgz",
"integrity": "sha512-1w+/oSjEXZjMqsIvmkCRsOc8UBYv163bTWKTI8+1mxztvQPhCRYGTvZ/PL1w16xXHneIj/SLGfxWg2GWN2uexw==", "integrity": "sha512-1w+/oSjEXZjMqsIvmkCRsOc8UBYv163bTWKTI8+1mxztvQPhCRYGTvZ/PL1w16xXHneIj/SLGfxWg2GWN2uexw==",
"peer": true,
"dependencies": { "dependencies": {
"@jest/create-cache-key-function": "^29.7.0", "@jest/create-cache-key-function": "^29.7.0",
"@react-native/assets-registry": "0.81.5", "@react-native/assets-registry": "0.81.5",
@@ -9514,7 +9498,6 @@
"version": "2.28.0", "version": "2.28.0",
"resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.28.0.tgz", "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.28.0.tgz",
"integrity": "sha512-0msfJ1vRxXKVgTgvL+1ZOoYw3/0z1R+Ked0+udoJhyplC2jbVKIJ8Z1bzWdpQRCV3QcQ87Op0zJVE5DhKK2A0A==", "integrity": "sha512-0msfJ1vRxXKVgTgvL+1ZOoYw3/0z1R+Ked0+udoJhyplC2jbVKIJ8Z1bzWdpQRCV3QcQ87Op0zJVE5DhKK2A0A==",
"peer": true,
"dependencies": { "dependencies": {
"@egjs/hammerjs": "^2.0.17", "@egjs/hammerjs": "^2.0.17",
"hoist-non-react-statics": "^3.3.0", "hoist-non-react-statics": "^3.3.0",
@@ -9538,7 +9521,6 @@
"version": "4.1.6", "version": "4.1.6",
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.6.tgz", "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.6.tgz",
"integrity": "sha512-F+ZJBYiok/6Jzp1re75F/9aLzkgoQCOh4yxrnwATa8392RvM3kx+fiXXFvwcgE59v48lMwd9q0nzF1oJLXpfxQ==", "integrity": "sha512-F+ZJBYiok/6Jzp1re75F/9aLzkgoQCOh4yxrnwATa8392RvM3kx+fiXXFvwcgE59v48lMwd9q0nzF1oJLXpfxQ==",
"peer": true,
"dependencies": { "dependencies": {
"react-native-is-edge-to-edge": "^1.2.1", "react-native-is-edge-to-edge": "^1.2.1",
"semver": "7.7.2" "semver": "7.7.2"
@@ -9565,7 +9547,6 @@
"version": "5.6.2", "version": "5.6.2",
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz", "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz",
"integrity": "sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg==", "integrity": "sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg==",
"peer": true,
"peerDependencies": { "peerDependencies": {
"react": "*", "react": "*",
"react-native": "*" "react-native": "*"
@@ -9575,7 +9556,6 @@
"version": "4.16.0", "version": "4.16.0",
"resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.16.0.tgz", "resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.16.0.tgz",
"integrity": "sha512-yIAyh7F/9uWkOzCi1/2FqvNvK6Wb9Y1+Kzn16SuGfN9YFJDTbwlzGRvePCNTOX0recpLQF3kc2FmvMUhyTCH1Q==", "integrity": "sha512-yIAyh7F/9uWkOzCi1/2FqvNvK6Wb9Y1+Kzn16SuGfN9YFJDTbwlzGRvePCNTOX0recpLQF3kc2FmvMUhyTCH1Q==",
"peer": true,
"dependencies": { "dependencies": {
"react-freeze": "^1.0.0", "react-freeze": "^1.0.0",
"react-native-is-edge-to-edge": "^1.2.1", "react-native-is-edge-to-edge": "^1.2.1",
@@ -9590,7 +9570,6 @@
"version": "0.21.2", "version": "0.21.2",
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz", "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz",
"integrity": "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==", "integrity": "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==",
"peer": true,
"dependencies": { "dependencies": {
"@babel/runtime": "^7.18.6", "@babel/runtime": "^7.18.6",
"@react-native/normalize-colors": "^0.74.1", "@react-native/normalize-colors": "^0.74.1",
@@ -9723,7 +9702,6 @@
"version": "0.14.2", "version": "0.14.2",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
"integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -10914,7 +10892,6 @@
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -11104,7 +11081,6 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "dev": true,
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"