diff --git a/App.js b/App.js index 542d48d..fb2944e 100644 --- a/App.js +++ b/App.js @@ -9,6 +9,7 @@ import TeamScreen from "./src/components/screens/TeamScreen"; import MapScreen from "./src/components/screens/MapScreen"; import LeaderboardScreen from "./src/components/screens/LeaderboardScreen"; import GameSettingsScreen from "./src/components/screens/GameSettingsScreen"; +import DepartmentSettingsScreen from "./src/components/screens/DepartmentSettingsScreen"; import PlayersScreen from "./src/components/screens/PlayersScreen"; import ExpandedMapScreen from "./src/components/screens/ExpandedMapScreen"; import GameSelectionScreen from "./src/components/screens/GameSelectionScreen"; @@ -105,6 +106,15 @@ const AdminStack = () => ( component={GlobalLeaderboardScreen} /> + + + + + + + + + ); export const App = () => { diff --git a/README.md b/README.md index dee5871..c7483a3 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,32 @@ Treasure Hunt mobile app built with React Native and Expo. ```bash npm i -npx expo start --android +npm start ``` ## Notes - The app accesses game/auth data through the centralized API endpoint via `src/components/API/API.js`. -- Navigation is handled with React Navigation native stack. -- Sometimes you might have to switch the API Endpoint URL. This is just Because of the fact that when I'm using the app I can't access the API endpoint, from the global URL. Therefore, I need my local API Server's IP as the URL -- Just Uncomment the `API_ENDPOINT` variable in `src/components/API/API.js` to use the actual API Server Which is `api.bobby.ip-ddns.com` [Do not add any port thing here as it uses port 80 by default]. +- IF encountering network issues, ensure the API endpoint is set to 'http://api.bobby.ip-ddns.com' and not 'http://192.168.8.100:3000'. +- The reason for 'http://192.168.8.100:3000' is due to the API server being on my (Mudabbir's) home network. I need to use local ip due to loop back restrictions with home network(needed for security). +- Just uncomment line 1 in `src/components/API/API.js` to use the actual API Server Which is `api.bobby.ip-ddns.com` [Do not add any port thing here as it uses port 80 by default]. + + +# DB Structure: + +API endpoint: `http://api.bobby.ip-ddns.com/` + +### Tables: +- `users` +- `teams` +- `team-members` +- `subgroups` +- `subgroup-members` +- `groups` +- `game-types` +- `db` +- `caches` +- `admin-waitlist` + +#### Append Table to API URL to view table's json data on browser. +I.e: `http://api.bobby.ip-ddns.com/users` or `http://api.bobby.ip-ddns.com/subgroups-members` \ No newline at end of file diff --git a/app.json b/app.json index 3f8c7e5..36d53e2 100644 --- a/app.json +++ b/app.json @@ -16,6 +16,9 @@ "supportsTablet": true, "infoPlist": { "NSLocationWhenInUseUsageDescription": "This app needs your location for the treasure hunt." + }, + "config": { + "googleMapsApiKey": "AIzaSyA-G0tSzzcM5z6HyI23s8dzKDLCOrekhbw" } }, "android": { diff --git a/assets/icons/Leaderboard-icon.png b/assets/icons/Leaderboard-icon.png new file mode 100644 index 0000000..e04806b Binary files /dev/null and b/assets/icons/Leaderboard-icon.png differ diff --git a/assets/icons/Map-icon.png b/assets/icons/Map-icon.png new file mode 100644 index 0000000..51d6f8a Binary files /dev/null and b/assets/icons/Map-icon.png differ diff --git a/assets/icons/Teams-icon.png b/assets/icons/Teams-icon.png new file mode 100644 index 0000000..8ca3dfc Binary files /dev/null and b/assets/icons/Teams-icon.png differ diff --git a/assets/icons/settings-icon.png b/assets/icons/settings-icon.png new file mode 100644 index 0000000..94ef7da Binary files /dev/null and b/assets/icons/settings-icon.png differ diff --git a/server/package.json b/server/package.json deleted file mode 100644 index 9ed7c53..0000000 --- a/server/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "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 deleted file mode 100644 index ec7d464..0000000 --- a/server/server.js +++ /dev/null @@ -1,770 +0,0 @@ -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, - CacheTriggerMeters: payload.CacheTriggerMeters || 20, - 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 === String(payload.JoinCode).toUpperCase()); - 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' }); - - const removed = rows[idx]; - rows.splice(idx, 1); - writeJson('subgroup-memberships.json', rows); - - // Cascade: remove from team if in one - const teamMembers = readJson('team-members.json'); - const tmIdx = teamMembers.findIndex((r) => r.Uid === removed.Uid); - if (tmIdx !== -1) { - const tmRemoved = teamMembers[tmIdx]; - teamMembers.splice(tmIdx, 1); - // Transfer leadership to longest-serving member if leader left - if (tmRemoved.IsLeader) { - const remaining = teamMembers.filter((r) => r.Tid === tmRemoved.Tid); - if (remaining.length > 0) { - remaining.sort((a, b) => new Date(a.JoinedAt || 0) - new Date(b.JoinedAt || 0)); - remaining[0].IsLeader = true; - } - } - writeJson('team-members.json', teamMembers); - } - - // Clear user game data - const users = readJson('users.json'); - const uIdx = users.findIndex((u) => u.Uid === removed.Uid); - if (uIdx !== -1) { - users[uIdx].Gid = null; - users[uIdx].SGid = null; - users[uIdx].TGid = null; - users[uIdx].IsAcceptedAdmin = false; - writeJson('users.json', users); - } - - 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 === String(payload.JoinCode).toUpperCase()); - if (!team) return sendJson(res, 400, { message: 'Invalid team join code' }); - payload.Tid = team.Tid; - } - - // Auto-set leader if first member in team - const existing = rows.filter((r) => r.Tid === payload.Tid); - payload.id = nextId(rows, 'id'); - payload.JoinedAt = new Date().toISOString(); - payload.IsLeader = existing.length === 0; - 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' }); - - const removed = rows[idx]; - rows.splice(idx, 1); - - // Transfer leadership to longest-serving member if leader left - if (removed.IsLeader) { - const remaining = rows.filter((r) => r.Tid === removed.Tid); - if (remaining.length > 0) { - remaining.sort((a, b) => new Date(a.JoinedAt || 0) - new Date(b.JoinedAt || 0)); - remaining[0].IsLeader = true; - } - } - - writeJson('team-members.json', rows); - - // Clear user TGid - const users = readJson('users.json'); - const uIdx = users.findIndex((u) => u.Uid === removed.Uid); - if (uIdx !== -1) { - users[uIdx].TGid = null; - writeJson('users.json', users); - } - - 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/UI/Button.js b/src/components/UI/Button.js index 3c39ec2..efa099d 100644 --- a/src/components/UI/Button.js +++ b/src/components/UI/Button.js @@ -1,15 +1,15 @@ import {Pressable, StyleSheet, Text, View} from "react-native"; -export const Button = ({ label, icon, onClick, styleLabel, styleButton }) => { +export const Button = ({ label, icon, onClick, styleLabel, styleButton, disabled }) => { // Initialisation ------------- // State ---------------------- // Handlers ------------------- // View ----------------------- return ( - + {icon ? icon : null} - {label} + {label} ); }; @@ -44,5 +44,12 @@ const styles = StyleSheet.create({ flexDirection: "row", gap: 15, }, + // Greyed-out state for buttons that should not be interactive + buttonDisabled: { + opacity: 0.4, + }, + labelDisabled: { + color: '#9ca3af', + }, }); diff --git a/src/components/UI/Card.js b/src/components/UI/Card.js index 22d6d3f..e248505 100644 --- a/src/components/UI/Card.js +++ b/src/components/UI/Card.js @@ -13,12 +13,12 @@ const Card = ({children, style}) => { const styles = StyleSheet.create({ card: { - backgroundColor: '#ffffff', + backgroundColor: '#313244', borderRadius: 8, padding: 12, marginBottom: 8, borderWidth: 1, - borderColor: '#e5e7eb', + borderColor: '#45475a', }, }); diff --git a/src/components/UI/PendingApprovalView.js b/src/components/UI/PendingApprovalView.js new file mode 100644 index 0000000..28aef23 --- /dev/null +++ b/src/components/UI/PendingApprovalView.js @@ -0,0 +1,16 @@ +import {StyleSheet, Text} from 'react-native'; +import Screen from '../layout/Screen'; + +const PendingApprovalView = ({message = 'Waiting for admin approval. You will get access once approved.'}) => ( + + {message} + +); + +const styles = StyleSheet.create({ + center: {justifyContent: 'center', alignItems: 'center', flex: 1}, + body: {color: '#bac2de', fontSize: 15, textAlign: 'center', paddingHorizontal: 20}, +}); + +export default PendingApprovalView; + diff --git a/src/components/gameplay/CacheCardItem.js b/src/components/gameplay/CacheCardItem.js index f5c9341..97fb74c 100644 --- a/src/components/gameplay/CacheCardItem.js +++ b/src/components/gameplay/CacheCardItem.js @@ -2,29 +2,28 @@ import {StyleSheet, Text, View} from 'react-native'; import Card from '../UI/Card'; import {Button} from '../UI/Button'; -const CacheCardItem = ({cache, isAdmin, onEdit, onDelete, onSelect}) => { -// Initialisation ------------- +const CacheCardItem = ({cache, isAdmin, isClaimed, isSelected, onEdit, onDelete, onSelect, disabled, departmentName}) => { + // Display name fallback for cache without a name + const displayName = cache.name || cache.clue || 'Unnamed Cache'; - const isClaimed = Boolean(cache.ClaimedByUid); - const statusText = isClaimed ? 'Claimed' : 'Available'; - const statusColor = isClaimed ? '#9ca3af' : '#16a34a'; - const displayName = cache.name || cache.clue || 'Unnamed Cache'; - -// State ---------------------- -// Handlers ------------------- -// View ----------------------- - - return ( - - - - {displayName} - {cache.name ? ( - {cache.clue} - ) : null} - - {statusText} - + return ( + + + + {displayName} + {isAdmin && departmentName ? ( + Department: {departmentName} + ) : null} + {cache.name ? ( + {cache.clue} + ) : null} + + {!isAdmin && !disabled && ( + + {isClaimed ? 'Claimed' : 'Available'} + + )} + {isAdmin ? ( <> @@ -41,12 +40,35 @@ const CacheCardItem = ({cache, isAdmin, onEdit, onDelete, onSelect}) => { styleLabel={styles.btnLabel} /> + ) : disabled ? ( +