diff --git a/App.js b/App.js index 6ba295b..77a2d4f 100644 --- a/App.js +++ b/App.js @@ -1,5 +1,6 @@ import {NavigationContainer} from "@react-navigation/native"; import {createNativeStackNavigator} from "@react-navigation/native-stack"; +import {SafeAreaProvider} from "react-native-safe-area-context"; import LoginScreen from "./src/components/screens/LoginScreen"; import RegisterScreen from "./src/components/screens/RegisterScreen"; import GameTypeScreen from "./src/components/screens/GameTypeScreen"; @@ -14,58 +15,63 @@ import LeaderboardScreen from "./src/components/screens/LeaderboardScreen"; import AdminHubScreen from "./src/components/screens/AdminHubScreen"; import ManageSubgroupsScreen from "./src/components/screens/ManageSubgroupsScreen"; -const Stack = createNativeStackNavigator(); +const RootStack = createNativeStackNavigator(); +const AuthStackNav = createNativeStackNavigator(); +const GameStackNav = createNativeStackNavigator(); +const AdminStackNav = createNativeStackNavigator(); const AuthStack = () => ( - - - - + + + ); const GameStack = () => ( - - - - - - - - - - - + + + + + + + + + + ); const AdminStack = () => ( - - - - - - - - - + + + + + + + + ); export const App = () => { return ( - - - - - - - + + + + + + + + + ); }; diff --git a/DB/admin-waitlist.json b/DB/admin-waitlist.json deleted file mode 100644 index 7dd4387..0000000 --- a/DB/admin-waitlist.json +++ /dev/null @@ -1,2 +0,0 @@ -[] - diff --git a/DB/db.json b/DB/db.json deleted file mode 100644 index 9494e23..0000000 --- a/DB/db.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "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" - } -} diff --git a/DB/game-data.json b/DB/game-data.json deleted file mode 100644 index e333c4d..0000000 --- a/DB/game-data.json +++ /dev/null @@ -1,16 +0,0 @@ -[ - { - "Gid": 1, - "GeoCaches": [ - { - "CacheId": "C1", - "Title": "Main Entrance", - "Latitude": 0, - "Longitude": 0, - "TriggerMeters": 20, - "SGid": 1 - } - ] - } -] - diff --git a/DB/game-types.json b/DB/game-types.json deleted file mode 100644 index 9b3ab44..0000000 --- a/DB/game-types.json +++ /dev/null @@ -1,6 +0,0 @@ -[ - "Join Global Game", - "Join Private Game", - "Manage Private Game" -] - diff --git a/DB/groups.json b/DB/groups.json deleted file mode 100644 index 1909370..0000000 --- a/DB/groups.json +++ /dev/null @@ -1,15 +0,0 @@ -[ - { - "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" - } -] - diff --git a/DB/subgroup-memberships.json b/DB/subgroup-memberships.json deleted file mode 100644 index 7119cff..0000000 --- a/DB/subgroup-memberships.json +++ /dev/null @@ -1,15 +0,0 @@ -[ - { - "Uid": 1, - "Gid": 1, - "SGid": 0, - "IsAcceptedAdmin": true - }, - { - "Uid": 2, - "Gid": 1, - "SGid": 1, - "IsAcceptedAdmin": false - } -] - diff --git a/DB/subgroups.json b/DB/subgroups.json deleted file mode 100644 index baa5636..0000000 --- a/DB/subgroups.json +++ /dev/null @@ -1,19 +0,0 @@ -[ - { - "SGid": 0, - "SubGroupName": "Admins", - "Gid": 1, - "JoinCode": null, - "IsAdminGroup": true, - "CacheTriggerMeters": 20 - }, - { - "SGid": 1, - "SubGroupName": "Members", - "Gid": 1, - "JoinCode": "M4P8R", - "IsAdminGroup": false, - "CacheTriggerMeters": 20 - } -] - diff --git a/DB/team-members.json b/DB/team-members.json deleted file mode 100644 index 7dd4387..0000000 --- a/DB/team-members.json +++ /dev/null @@ -1,2 +0,0 @@ -[] - diff --git a/DB/teams.json b/DB/teams.json deleted file mode 100644 index 7dd4387..0000000 --- a/DB/teams.json +++ /dev/null @@ -1,2 +0,0 @@ -[] - diff --git a/DB/users.json b/DB/users.json deleted file mode 100644 index eb2715d..0000000 --- a/DB/users.json +++ /dev/null @@ -1,25 +0,0 @@ -[ - { - "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 - } -] - diff --git a/app.json b/app.json index be4b335..4a8162a 100644 --- a/app.json +++ b/app.json @@ -20,7 +20,7 @@ "foregroundImage": "./assets/images/android-icon-monochrome.png", "backgroundColor": "#ffffff" }, - "edgeToEdgeEnabled": true + "edgeToEdgeEnabled": false }, "web": { "favicon": "./assets/favicon.png" diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..9ed7c53 --- /dev/null +++ b/server/package.json @@ -0,0 +1,14 @@ +{ + "name": "mad-treasure-hunt-server", + "version": "1.0.0", + "description": "RESTful CRUDL API server for MAD Treasure Hunt", + "private": true, + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "node --watch server.js" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/server/server.js b/server/server.js new file mode 100644 index 0000000..1653266 --- /dev/null +++ b/server/server.js @@ -0,0 +1,713 @@ +const http = require('http'); +const fs = require('fs'); +const path = require('path'); + +// --- Configuration --- + +const PORT = process.env.PORT || 3000; +const DATA_DIR = path.join(__dirname, 'data'); + +// --- Data Helpers --- + +const readJson = (fileName) => JSON.parse(fs.readFileSync(path.join(DATA_DIR, fileName), 'utf-8')); +const writeJson = (fileName, data) => fs.writeFileSync(path.join(DATA_DIR, fileName), JSON.stringify(data, null, 2)); + +// --- Utility Helpers --- + +const 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)}`; +}; + +const nextId = (rows, key) => rows.reduce((max, row) => { + const val = Number(row[key] || 0); + return val > max ? val : max; +}, 0) + 1; + +const generateCode = (len = 5) => { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + let code = ''; + for (let i = 0; i < len; i++) code += chars.charAt(Math.floor(Math.random() * chars.length)); + return code; +}; + +// --- Request / Response Helpers --- + +const parseBody = (req) => new Promise((resolve, reject) => { + let body = ''; + req.on('data', (chunk) => { body += chunk.toString(); }); + req.on('end', () => { + try { resolve(body ? JSON.parse(body) : {}); } + catch { reject(new Error('Invalid JSON body')); } + }); + req.on('error', reject); +}); + +const sendJson = (res, statusCode, payload) => { + const headers = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + }; + if (statusCode === 204) { + res.writeHead(204, headers); + res.end(); + return; + } + headers['Content-Type'] = 'application/json'; + res.writeHead(statusCode, headers); + res.end(JSON.stringify(payload)); +}; + +// --- Simple Router --- + +const routes = []; + +const addRoute = (method, pattern, handler) => { + const keys = []; + const regexStr = pattern.replace(/:([a-zA-Z]+)/g, (_, key) => { + keys.push(key); + return '([^/]+)'; + }); + routes.push({ method, regex: new RegExp(`^${regexStr}$`), keys, handler }); +}; + +const matchRoute = (method, pathname) => { + for (const r of routes) { + if (r.method !== method) continue; + const match = pathname.match(r.regex); + if (match) { + const params = {}; + r.keys.forEach((key, i) => { params[key] = match[i + 1]; }); + return { handler: r.handler, params }; + } + } + return null; +}; + +// ====================================================================== +// AUTH ROUTES +// ====================================================================== + +addRoute('POST', '/auth/login', async (req, res) => { + const payload = await parseBody(req); + const users = readJson('users.json'); + const user = users.find((u) => u.email.toLowerCase() === String(payload.email || '').toLowerCase()); + if (!user) return sendJson(res, 400, { message: 'Email not found' }); + if (user.passwordHash !== hashPassword(String(payload.password || ''))) + return sendJson(res, 400, { message: 'Invalid password' }); + sendJson(res, 200, user); +}); + +addRoute('POST', '/auth/register', async (req, res) => { + const payload = await parseBody(req); + const username = String(payload.username || '').trim(); + const email = String(payload.email || '').trim().toLowerCase(); + const password = String(payload.password || ''); + const confirmPassword = String(payload.confirmPassword || ''); + const accountType = String(payload.accountType || 'Individual'); + + if (!username || !email || !password || !confirmPassword) + return sendJson(res, 400, { message: 'Missing required registration fields' }); + if (password !== confirmPassword) + return sendJson(res, 400, { message: 'Passwords do not match' }); + + const users = readJson('users.json'); + if (users.some((u) => u.email.toLowerCase() === email)) + return sendJson(res, 400, { message: 'Email already in use' }); + + const user = { + Uid: nextId(users, 'Uid'), + username, + email, + passwordHash: hashPassword(password), + isBusiness: accountType === 'Business/School', + IsAcceptedAdmin: false, + Gid: null, + SGid: null, + TGid: null, + }; + + users.push(user); + writeJson('users.json', users); + sendJson(res, 201, user); +}); + +// ====================================================================== +// USERS CRUDL +// ====================================================================== + +addRoute('GET', '/users', (req, res) => { + sendJson(res, 200, readJson('users.json')); +}); + +addRoute('GET', '/users/:id', (req, res, params) => { + const users = readJson('users.json'); + const user = users.find((u) => String(u.Uid) === params.id); + if (!user) return sendJson(res, 404, { message: 'User not found' }); + sendJson(res, 200, user); +}); + +addRoute('PUT', '/users/:id', async (req, res, params) => { + const payload = await parseBody(req); + const users = readJson('users.json'); + const idx = users.findIndex((u) => String(u.Uid) === params.id); + if (idx === -1) return sendJson(res, 404, { message: 'User not found' }); + users[idx] = { ...users[idx], ...payload, Uid: users[idx].Uid }; + writeJson('users.json', users); + sendJson(res, 200, users[idx]); +}); + +addRoute('DELETE', '/users/:id', (req, res, params) => { + const users = readJson('users.json'); + const idx = users.findIndex((u) => String(u.Uid) === params.id); + if (idx === -1) return sendJson(res, 404, { message: 'User not found' }); + users.splice(idx, 1); + writeJson('users.json', users); + sendJson(res, 204, null); +}); + +// ====================================================================== +// GAME TYPES (read-only list) +// ====================================================================== + +addRoute('GET', '/game-types', (req, res) => { + sendJson(res, 200, readJson('game-types.json')); +}); + +// ====================================================================== +// GROUPS CRUDL +// ====================================================================== + +addRoute('GET', '/groups', (req, res, params, query) => { + let groups = readJson('groups.json'); + if (query.CreatedByUid) groups = groups.filter((g) => String(g.CreatedByUid) === query.CreatedByUid); + sendJson(res, 200, groups); +}); + +addRoute('GET', '/groups/:gid', (req, res, params) => { + const groups = readJson('groups.json'); + const group = groups.find((g) => String(g.Gid) === params.gid); + if (!group) return sendJson(res, 404, { message: 'Group not found' }); + sendJson(res, 200, group); +}); + +addRoute('POST', '/groups', async (req, res) => { + const payload = await parseBody(req); + const groups = readJson('groups.json'); + + const group = { + Gid: nextId(groups, 'Gid'), + GroupName: payload.GroupName || 'Unnamed Group', + BusinessOrSchoolName: payload.BusinessOrSchoolName || null, + GameType: payload.GameType || 'private', + AdminJoinCode: generateCode(), + CreatedByUid: payload.CreatedByUid, + MaxMemberSubgroups: payload.MaxMemberSubgroups || 1, + TeamsEnabled: payload.TeamsEnabled !== undefined ? payload.TeamsEnabled : true, + ApprovedAdmins: [payload.CreatedByUid], + CreatedAt: new Date().toISOString(), + }; + groups.push(group); + writeJson('groups.json', groups); + + // Create default admin and member subgroups + const subgroups = readJson('subgroups.json'); + const adminSg = { + SGid: nextId(subgroups, 'SGid'), + SubGroupName: 'Admins', + Gid: group.Gid, + JoinCode: null, + IsAdminGroup: true, + CacheTriggerMeters: 20, + }; + subgroups.push(adminSg); + + const memberSg = { + SGid: nextId(subgroups, 'SGid'), + SubGroupName: 'Members', + Gid: group.Gid, + JoinCode: generateCode(), + IsAdminGroup: false, + CacheTriggerMeters: 20, + }; + subgroups.push(memberSg); + writeJson('subgroups.json', subgroups); + + // Add creator as admin membership + const memberships = readJson('subgroup-memberships.json'); + memberships.push({ + id: nextId(memberships, 'id'), + Uid: payload.CreatedByUid, + Gid: group.Gid, + SGid: adminSg.SGid, + IsAcceptedAdmin: true, + }); + writeJson('subgroup-memberships.json', memberships); + + // Initialise empty game-data entry + const gameData = readJson('game-data.json'); + gameData.push({ Gid: group.Gid, GeoCaches: [] }); + writeJson('game-data.json', gameData); + + // Update the creating user + const users = readJson('users.json'); + const uIdx = users.findIndex((u) => u.Uid === payload.CreatedByUid); + if (uIdx !== -1) { + users[uIdx].Gid = group.Gid; + users[uIdx].SGid = adminSg.SGid; + users[uIdx].IsAcceptedAdmin = true; + writeJson('users.json', users); + } + + sendJson(res, 201, group); +}); + +addRoute('PUT', '/groups/:gid', async (req, res, params) => { + const payload = await parseBody(req); + const groups = readJson('groups.json'); + const idx = groups.findIndex((g) => String(g.Gid) === params.gid); + if (idx === -1) return sendJson(res, 404, { message: 'Group not found' }); + groups[idx] = { ...groups[idx], ...payload, Gid: groups[idx].Gid }; + writeJson('groups.json', groups); + sendJson(res, 200, groups[idx]); +}); + +addRoute('DELETE', '/groups/:gid', (req, res, params) => { + const groups = readJson('groups.json'); + const idx = groups.findIndex((g) => String(g.Gid) === params.gid); + if (idx === -1) return sendJson(res, 404, { message: 'Group not found' }); + groups.splice(idx, 1); + writeJson('groups.json', groups); + sendJson(res, 204, null); +}); + +// ====================================================================== +// SUBGROUPS CRUDL +// ====================================================================== + +addRoute('GET', '/subgroups', (req, res, params, query) => { + let rows = readJson('subgroups.json'); + if (query.Gid) rows = rows.filter((r) => String(r.Gid) === query.Gid); + sendJson(res, 200, rows); +}); + +addRoute('GET', '/subgroups/:id', (req, res, params) => { + const rows = readJson('subgroups.json'); + const row = rows.find((r) => String(r.SGid) === params.id); + if (!row) return sendJson(res, 404, { message: 'Subgroup not found' }); + sendJson(res, 200, row); +}); + +addRoute('POST', '/subgroups', async (req, res) => { + const payload = await parseBody(req); + const rows = readJson('subgroups.json'); + payload.SGid = nextId(rows, 'SGid'); + if (!payload.JoinCode) payload.JoinCode = generateCode(); + rows.push(payload); + writeJson('subgroups.json', rows); + sendJson(res, 201, payload); +}); + +addRoute('PUT', '/subgroups/:id', async (req, res, params) => { + const payload = await parseBody(req); + const rows = readJson('subgroups.json'); + const idx = rows.findIndex((r) => String(r.SGid) === params.id); + if (idx === -1) return sendJson(res, 404, { message: 'Subgroup not found' }); + rows[idx] = { ...rows[idx], ...payload, SGid: rows[idx].SGid }; + writeJson('subgroups.json', rows); + sendJson(res, 200, rows[idx]); +}); + +addRoute('DELETE', '/subgroups/:id', (req, res, params) => { + const rows = readJson('subgroups.json'); + const idx = rows.findIndex((r) => String(r.SGid) === params.id); + if (idx === -1) return sendJson(res, 404, { message: 'Subgroup not found' }); + rows.splice(idx, 1); + writeJson('subgroups.json', rows); + sendJson(res, 204, null); +}); + +// ====================================================================== +// SUBGROUP MEMBERSHIPS CRUDL +// ====================================================================== + +addRoute('GET', '/subgroup-memberships', (req, res, params, query) => { + let rows = readJson('subgroup-memberships.json'); + if (query.Gid) rows = rows.filter((r) => String(r.Gid) === query.Gid); + if (query.Uid) rows = rows.filter((r) => String(r.Uid) === query.Uid); + if (query.SGid) rows = rows.filter((r) => String(r.SGid) === query.SGid); + sendJson(res, 200, rows); +}); + +addRoute('GET', '/subgroup-memberships/:id', (req, res, params) => { + const rows = readJson('subgroup-memberships.json'); + const row = rows.find((r) => String(r.id) === params.id); + if (!row) return sendJson(res, 404, { message: 'Membership not found' }); + sendJson(res, 200, row); +}); + +addRoute('POST', '/subgroup-memberships', async (req, res) => { + const payload = await parseBody(req); + const rows = readJson('subgroup-memberships.json'); + + // If a JoinCode was supplied, look up the matching subgroup + if (payload.JoinCode) { + const subgroups = readJson('subgroups.json'); + const sg = subgroups.find((s) => s.JoinCode === payload.JoinCode); + if (!sg) return sendJson(res, 400, { message: 'Invalid join code' }); + payload.Gid = sg.Gid; + payload.SGid = sg.SGid; + } + + payload.id = nextId(rows, 'id'); + payload.IsAcceptedAdmin = payload.IsAcceptedAdmin || false; + rows.push(payload); + writeJson('subgroup-memberships.json', rows); + + // Update user record with group / subgroup + const users = readJson('users.json'); + const uIdx = users.findIndex((u) => u.Uid === payload.Uid); + if (uIdx !== -1) { + users[uIdx].Gid = payload.Gid; + users[uIdx].SGid = payload.SGid; + writeJson('users.json', users); + } + + sendJson(res, 201, payload); +}); + +addRoute('PUT', '/subgroup-memberships/:id', async (req, res, params) => { + const payload = await parseBody(req); + const rows = readJson('subgroup-memberships.json'); + const idx = rows.findIndex((r) => String(r.id) === params.id); + if (idx === -1) return sendJson(res, 404, { message: 'Membership not found' }); + rows[idx] = { ...rows[idx], ...payload, id: rows[idx].id }; + writeJson('subgroup-memberships.json', rows); + sendJson(res, 200, rows[idx]); +}); + +addRoute('DELETE', '/subgroup-memberships/:id', (req, res, params) => { + const rows = readJson('subgroup-memberships.json'); + const idx = rows.findIndex((r) => String(r.id) === params.id); + if (idx === -1) return sendJson(res, 404, { message: 'Membership not found' }); + rows.splice(idx, 1); + writeJson('subgroup-memberships.json', rows); + sendJson(res, 204, null); +}); + +// ====================================================================== +// TEAMS CRUDL +// ====================================================================== + +addRoute('GET', '/teams', (req, res, params, query) => { + let rows = readJson('teams.json'); + if (query.Gid) rows = rows.filter((r) => String(r.Gid) === query.Gid); + sendJson(res, 200, rows); +}); + +addRoute('GET', '/teams/:id', (req, res, params) => { + const rows = readJson('teams.json'); + const row = rows.find((r) => String(r.Tid) === params.id); + if (!row) return sendJson(res, 404, { message: 'Team not found' }); + sendJson(res, 200, row); +}); + +addRoute('POST', '/teams', async (req, res) => { + const payload = await parseBody(req); + const rows = readJson('teams.json'); + payload.Tid = nextId(rows, 'Tid'); + if (!payload.JoinCode) payload.JoinCode = generateCode(); + rows.push(payload); + writeJson('teams.json', rows); + sendJson(res, 201, payload); +}); + +addRoute('PUT', '/teams/:id', async (req, res, params) => { + const payload = await parseBody(req); + const rows = readJson('teams.json'); + const idx = rows.findIndex((r) => String(r.Tid) === params.id); + if (idx === -1) return sendJson(res, 404, { message: 'Team not found' }); + rows[idx] = { ...rows[idx], ...payload, Tid: rows[idx].Tid }; + writeJson('teams.json', rows); + sendJson(res, 200, rows[idx]); +}); + +addRoute('DELETE', '/teams/:id', (req, res, params) => { + const rows = readJson('teams.json'); + const idx = rows.findIndex((r) => String(r.Tid) === params.id); + if (idx === -1) return sendJson(res, 404, { message: 'Team not found' }); + rows.splice(idx, 1); + writeJson('teams.json', rows); + sendJson(res, 204, null); +}); + +// ====================================================================== +// TEAM MEMBERS CRUDL +// ====================================================================== + +addRoute('GET', '/team-members', (req, res, params, query) => { + let rows = readJson('team-members.json'); + if (query.Tid) rows = rows.filter((r) => String(r.Tid) === query.Tid); + if (query.Uid) rows = rows.filter((r) => String(r.Uid) === query.Uid); + sendJson(res, 200, rows); +}); + +addRoute('GET', '/team-members/:id', (req, res, params) => { + const rows = readJson('team-members.json'); + const row = rows.find((r) => String(r.id) === params.id); + if (!row) return sendJson(res, 404, { message: 'Team member not found' }); + sendJson(res, 200, row); +}); + +addRoute('POST', '/team-members', async (req, res) => { + const payload = await parseBody(req); + const rows = readJson('team-members.json'); + + // If a JoinCode was supplied, look up the matching team + if (payload.JoinCode) { + const teams = readJson('teams.json'); + const team = teams.find((t) => t.JoinCode === payload.JoinCode); + if (!team) return sendJson(res, 400, { message: 'Invalid team join code' }); + payload.Tid = team.Tid; + } + + payload.id = nextId(rows, 'id'); + rows.push(payload); + writeJson('team-members.json', rows); + + // Update user TGid + const users = readJson('users.json'); + const uIdx = users.findIndex((u) => u.Uid === payload.Uid); + if (uIdx !== -1) { + users[uIdx].TGid = payload.Tid; + writeJson('users.json', users); + } + + sendJson(res, 201, payload); +}); + +addRoute('PUT', '/team-members/:id', async (req, res, params) => { + const payload = await parseBody(req); + const rows = readJson('team-members.json'); + const idx = rows.findIndex((r) => String(r.id) === params.id); + if (idx === -1) return sendJson(res, 404, { message: 'Team member not found' }); + rows[idx] = { ...rows[idx], ...payload, id: rows[idx].id }; + writeJson('team-members.json', rows); + sendJson(res, 200, rows[idx]); +}); + +addRoute('DELETE', '/team-members/:id', (req, res, params) => { + const rows = readJson('team-members.json'); + const idx = rows.findIndex((r) => String(r.id) === params.id); + if (idx === -1) return sendJson(res, 404, { message: 'Team member not found' }); + rows.splice(idx, 1); + writeJson('team-members.json', rows); + sendJson(res, 204, null); +}); + +// ====================================================================== +// ADMIN WAITLIST CRUDL +// ====================================================================== + +addRoute('GET', '/admin-waitlist', (req, res, params, query) => { + let rows = readJson('admin-waitlist.json'); + if (query.Gid) rows = rows.filter((r) => String(r.Gid) === query.Gid); + sendJson(res, 200, rows); +}); + +addRoute('POST', '/admin-waitlist', async (req, res) => { + const payload = await parseBody(req); + const rows = readJson('admin-waitlist.json'); + payload.id = nextId(rows, 'id'); + rows.push(payload); + writeJson('admin-waitlist.json', rows); + sendJson(res, 201, payload); +}); + +addRoute('DELETE', '/admin-waitlist/:id', (req, res, params) => { + const rows = readJson('admin-waitlist.json'); + const idx = rows.findIndex((r) => String(r.id) === params.id); + if (idx === -1) return sendJson(res, 404, { message: 'Waitlist entry not found' }); + rows.splice(idx, 1); + writeJson('admin-waitlist.json', rows); + sendJson(res, 204, null); +}); + +// ====================================================================== +// GAME DATA CRUDL +// ====================================================================== + +addRoute('GET', '/game-data', (req, res) => { + sendJson(res, 200, readJson('game-data.json')); +}); + +addRoute('GET', '/game-data/:gid', (req, res, params) => { + const rows = readJson('game-data.json'); + const row = rows.find((r) => String(r.Gid) === params.gid); + if (!row) return sendJson(res, 404, { message: 'Game data not found' }); + sendJson(res, 200, row); +}); + +// ====================================================================== +// CACHES (nested under game-data) CRUDL +// ====================================================================== + +// Helper: convert storage cache to API response format +const toCacheApi = (cache, gid) => ({ + id: cache.CacheId, + coordinates: { latitude: cache.Latitude, longitude: cache.Longitude }, + radius: cache.TriggerMeters, + clue: cache.Title, + groupId: Number(gid), + subgroupId: cache.SGid, + ClaimedByUid: cache.ClaimedByUid || null, + ClaimedByTid: cache.ClaimedByTid || null, +}); + +// Helper: convert API payload to storage cache format +const toCacheStorage = (data, cacheId) => ({ + CacheId: cacheId, + Title: data.clue || data.Title || '', + Latitude: data.latitude != null ? data.latitude : (data.Latitude || 0), + Longitude: data.longitude != null ? data.longitude : (data.Longitude || 0), + TriggerMeters: data.radius || data.TriggerMeters || 20, + SGid: data.subgroupId != null ? data.subgroupId : (data.SGid || null), + ClaimedByUid: data.ClaimedByUid || null, + ClaimedByTid: data.ClaimedByTid || null, +}); + +// GET /game-data/:gid/caches — list caches for a group +addRoute('GET', '/game-data/:gid/caches', (req, res, params, query) => { + const rows = readJson('game-data.json'); + const entry = rows.find((r) => String(r.Gid) === params.gid); + if (!entry) return sendJson(res, 404, { message: 'Game data not found' }); + + let caches = (entry.GeoCaches || []).map((c) => toCacheApi(c, params.gid)); + if (query.SGid) caches = caches.filter((c) => String(c.subgroupId) === query.SGid); + sendJson(res, 200, caches); +}); + +// GET /game-data/:gid/caches/:cacheId — read single cache +addRoute('GET', '/game-data/:gid/caches/:cacheId', (req, res, params) => { + const rows = readJson('game-data.json'); + const entry = rows.find((r) => String(r.Gid) === params.gid); + if (!entry) return sendJson(res, 404, { message: 'Game data not found' }); + + const cache = (entry.GeoCaches || []).find((c) => c.CacheId === params.cacheId); + if (!cache) return sendJson(res, 404, { message: 'Cache not found' }); + sendJson(res, 200, toCacheApi(cache, params.gid)); +}); + +// POST /game-data/:gid/caches — create a new cache +addRoute('POST', '/game-data/:gid/caches', async (req, res, params) => { + const payload = await parseBody(req); + const rows = readJson('game-data.json'); + const entry = rows.find((r) => String(r.Gid) === params.gid); + if (!entry) return sendJson(res, 404, { message: 'Game data not found' }); + + const existingIds = (entry.GeoCaches || []).map((c) => { + const num = parseInt(String(c.CacheId).replace(/\D/g, ''), 10); + return isNaN(num) ? 0 : num; + }); + const nextNum = existingIds.length > 0 ? Math.max(...existingIds) + 1 : 1; + const cacheId = `C${nextNum}`; + + const stored = toCacheStorage(payload, cacheId); + if (!entry.GeoCaches) entry.GeoCaches = []; + entry.GeoCaches.push(stored); + writeJson('game-data.json', rows); + sendJson(res, 201, toCacheApi(stored, params.gid)); +}); + +// PUT /game-data/:gid/caches/:cacheId — update a cache +addRoute('PUT', '/game-data/:gid/caches/:cacheId', async (req, res, params) => { + const payload = await parseBody(req); + const rows = readJson('game-data.json'); + const entry = rows.find((r) => String(r.Gid) === params.gid); + if (!entry) return sendJson(res, 404, { message: 'Game data not found' }); + + const idx = (entry.GeoCaches || []).findIndex((c) => c.CacheId === params.cacheId); + if (idx === -1) return sendJson(res, 404, { message: 'Cache not found' }); + + const existing = entry.GeoCaches[idx]; + const updated = { ...existing, ...toCacheStorage(payload, params.cacheId) }; + entry.GeoCaches[idx] = updated; + writeJson('game-data.json', rows); + sendJson(res, 200, toCacheApi(updated, params.gid)); +}); + +// DELETE /game-data/:gid/caches/:cacheId — delete a cache +addRoute('DELETE', '/game-data/:gid/caches/:cacheId', (req, res, params) => { + const rows = readJson('game-data.json'); + const entry = rows.find((r) => String(r.Gid) === params.gid); + if (!entry) return sendJson(res, 404, { message: 'Game data not found' }); + + const idx = (entry.GeoCaches || []).findIndex((c) => c.CacheId === params.cacheId); + if (idx === -1) return sendJson(res, 404, { message: 'Cache not found' }); + + entry.GeoCaches.splice(idx, 1); + writeJson('game-data.json', rows); + sendJson(res, 204, null); +}); + +// ====================================================================== +// HEALTH CHECK +// ====================================================================== + +addRoute('GET', '/health', (req, res) => { + sendJson(res, 200, { ok: true }); +}); + +// ====================================================================== +// SERVER +// ====================================================================== + +const server = http.createServer(async (req, res) => { + // CORS preflight + if (req.method === 'OPTIONS') return sendJson(res, 200, { ok: true }); + + const url = new URL(req.url, 'http://localhost'); + const pathname = url.pathname.replace(/\/+$/, '') || '/'; + const query = Object.fromEntries(url.searchParams); + + const matched = matchRoute(req.method, pathname); + if (matched) { + try { + await matched.handler(req, res, matched.params, query); + } catch (err) { + sendJson(res, 500, { message: err.message }); + } + return; + } + + sendJson(res, 404, { message: 'Not found' }); +}); + +server.listen(PORT, '0.0.0.0', () => { + process.stdout.write(`Server running on http://0.0.0.0:${PORT}\n`); + process.stdout.write(` -> Local: http://localhost:${PORT}\n`); + // Log the LAN IP so users know what to point to from other devices + const nets = require('os').networkInterfaces(); + for (const name of Object.keys(nets)) { + for (const net of nets[name]) { + if (net.family === 'IPv4' && !net.internal) { + process.stdout.write(` -> Network: http://${net.address}:${PORT}\n`); + } + } + } +}); + +server.on('error', (err) => { + if (err.code === 'EADDRINUSE') { + process.stderr.write(`ERROR: Port ${PORT} is already in use.\n`); + } else { + process.stderr.write(`ERROR: ${err.message}\n`); + } + process.exit(1); +}); + diff --git a/src/components/API/API.js b/src/components/API/API.js index 050895c..3e701b0 100644 --- a/src/components/API/API.js +++ b/src/components/API/API.js @@ -1,4 +1,18 @@ -export const API_BASE_URL = 'http://localhost:3000'; +import Constants from 'expo-constants'; + +const getBaseUrl = () => { + // In Expo Go the debuggerHost is "192.168.x.x:8081" — grab the IP + const debuggerHost = Constants.expoGoConfig?.debuggerHost; + if (debuggerHost) { + const ip = debuggerHost.split(':')[0]; + return `http://${ip}:3000`; + } + // Fallback for web / production + return 'http://localhost:3000'; +}; + +export const API_BASE_URL = getBaseUrl(); +//export const API_BASE_URL = 'http://api.bobby.ip-ddns.com'; // Production server URL const API = {}; diff --git a/src/components/layout/BottomNavbar.js b/src/components/layout/BottomNavbar.js index 54473df..ebb3446 100644 --- a/src/components/layout/BottomNavbar.js +++ b/src/components/layout/BottomNavbar.js @@ -1,8 +1,11 @@ import {Pressable, StyleSheet, Text, View} from 'react-native'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; const BottomNavbar = ({ navigation, routeName }) => { // Initialisation ------------ + const insets = useSafeAreaInsets(); + const tabs = [ { label: 'Map', @@ -26,7 +29,7 @@ const BottomNavbar = ({ navigation, routeName }) => { // View ----------------------- return ( - + {tabs.map((tab) => { const isActive = routeName === tab.route; return ( @@ -47,8 +50,8 @@ const styles = StyleSheet.create({ container: { minHeight: 64, borderTopWidth: 1, - borderTopColor: '#e5e7eb', - backgroundColor: '#ffffff', + borderTopColor: '#555555', + backgroundColor: '#4a4a4a', flexDirection: 'row', alignItems: 'center', justifyContent: 'space-around', @@ -63,16 +66,16 @@ const styles = StyleSheet.create({ marginHorizontal: 4, }, tabActive: { - backgroundColor: '#eff6ff', + backgroundColor: '#5c5c5c', }, tabText: { - color: '#374151', + color: '#d1d5db', fontSize: 12, fontWeight: '600', textAlign: 'center', }, tabTextActive: { - color: '#2563eb', + color: '#ffffff', }, }); diff --git a/src/components/layout/Screen.js b/src/components/layout/Screen.js index 3382560..d82d9c3 100644 --- a/src/components/layout/Screen.js +++ b/src/components/layout/Screen.js @@ -1,6 +1,7 @@ import {StatusBar} from "expo-status-bar"; import {StyleSheet, View} from "react-native"; import {useNavigation, useRoute} from "@react-navigation/native"; +import {useSafeAreaInsets} from "react-native-safe-area-context"; import TopNavbar from "./TopNavbar"; import BottomNavbar from "./BottomNavbar"; @@ -9,6 +10,7 @@ const Screen = ({ children, style }) => { const navigation = useNavigation(); const route = useRoute(); + const insets = useSafeAreaInsets(); const titleMap = { GameTypeScreen: "Game Type", @@ -35,9 +37,9 @@ const Screen = ({ children, style }) => { // View ----------------------- return ( - + {!isAuthScreen && } - {children} + {children} {!isAuthScreen && } diff --git a/src/components/layout/TopNavbar.js b/src/components/layout/TopNavbar.js index fc70bb6..751094d 100644 --- a/src/components/layout/TopNavbar.js +++ b/src/components/layout/TopNavbar.js @@ -1,8 +1,12 @@ import {Pressable, StyleSheet, Text, View} from 'react-native'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; import {clearSession} from '../../hooks/SessionStore'; const TopNavbar = ({title, showBack, navigation}) => { // Initialisation ------------ + + const insets = useSafeAreaInsets(); + // State ---------------------- // Handlers ------------------- @@ -21,7 +25,7 @@ const TopNavbar = ({title, showBack, navigation}) => { // View ----------------------- return ( - + {showBack ? ( @@ -43,12 +47,12 @@ const styles = StyleSheet.create({ container: { minHeight: 56, borderBottomWidth: 1, - borderBottomColor: '#e5e7eb', + borderBottomColor: '#333333', flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 12, - backgroundColor: '#ffffff', + backgroundColor: '#000000', }, leftSlot: { width: 72, @@ -64,7 +68,7 @@ const styles = StyleSheet.create({ }, iconText: { fontSize: 20, - color: '#111827', + color: '#ffffff', fontWeight: '700', }, title: { @@ -72,14 +76,14 @@ const styles = StyleSheet.create({ textAlign: 'center', fontSize: 18, fontWeight: '700', - color: '#111827', + color: '#ffffff', }, logoutButton: { paddingVertical: 6, paddingHorizontal: 8, }, logoutText: { - color: '#2563eb', + color: '#ffffff', fontSize: 13, fontWeight: '600', },