32 Commits

Author SHA1 Message Date
dcd1f522ad Merge branch 'main' into external-links-and-app-messages 2025-12-10 20:21:24 -06:00
22c909cfa5 increase logging in deploy script
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m24s
for some reason it seems to not be fully updating the local repo, so increase logging and double pull
2025-12-10 20:20:06 -06:00
50b9924627 deployment versioning
Some checks failed
Continuous Integration / Update Development (push) Successful in 1m58s
Continuous Deployment / Update Deployment (push) Failing after 2m12s
port development versioning to deployment, but using tags instead of short sha
2025-12-10 11:50:07 -06:00
d44d490905 version parsing fix
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m13s
attempt parse fix because i forgot this isn't running on windows
2025-12-10 11:43:40 -06:00
114857cceb debug workflow
Some checks failed
Continuous Integration / Update Development (push) Failing after 1m49s
is sed really not included?
2025-12-10 11:35:58 -06:00
0245cb38f4 enable development app versioning
Some checks failed
Continuous Integration / Update Development (push) Failing after 2m16s
parse git commit short sha and use as app version in development
2025-12-10 11:30:13 -06:00
4e443cf46d Fixed glitchtip enable/disable to behave as expected and added support for ENV versioning
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m27s
2025-12-10 00:18:09 -05:00
be53fce4a5 Merge pull request 'Calendar-Improvements' (#57) from Calendar-Improvements into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m23s
Reviewed-on: #57
2025-12-09 21:21:24 -06:00
2ac66877eb Merge branch 'main' into Calendar-Improvements 2025-12-09 21:21:03 -06:00
760965de19 Add .gitea/workflows/ci-deploy.yaml
Some checks failed
Continuous Integration / Update Development (push) Failing after 1m28s
create CI script based on CD script to run on every push to main and deploy on development site
2025-12-09 21:11:03 -06:00
37f204137b Update .gitea/workflows/cd-deploy.yaml
fix run, apparently on push triggers are not cumulatively conditional
2025-12-09 21:06:45 -06:00
58431891ba Update ecosystem.config.js
All checks were successful
Continuous Deployment / Update Deployment (push) Successful in 2m15s
allow automatic naming of started process based on environment
2025-12-09 20:55:41 -06:00
a95bf3318d Update .gitea/workflows/cd-deploy.yaml
All checks were successful
Continuous Deployment / Update Deployment (push) Successful in 2m5s
update the CD script to only run on tags signifying a usable release
2025-12-09 20:53:33 -06:00
6d544225d9 Update .gitea/workflows/cd-deploy.yaml
All checks were successful
Continuous Deployment / Update Deployment (push) Successful in 3m20s
fix syntax
2025-12-09 20:31:38 -06:00
ec854c6800 Update .gitea/workflows/cd-deploy.yaml
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m17s
update node version to 20 from built in 18
2025-12-09 20:26:13 -06:00
8089ee4268 Update .gitea/workflows/cd-deploy.yaml
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m48s
stupidly work out permissions error due to sudo not copying environment variables properly
2025-12-09 20:22:08 -06:00
468fd30514 Update .gitea/workflows/cd-deploy.yaml
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m27s
attempt to preserve existing environment to fix node issues
2025-12-09 14:55:40 -06:00
1f52a2c4f7 Update .gitea/workflows/cd-deploy.yaml
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m27s
forcefully override npm path for execution
2025-12-09 14:48:19 -06:00
dccdaddd20 Update .gitea/workflows/cd-deploy.yaml
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m28s
another deployment attempt
2025-12-09 14:45:24 -06:00
1a80bfc543 Update .gitea/workflows/cd-deploy.yaml
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m33s
continue to debug workflow
2025-12-09 14:37:16 -06:00
2185ffc746 Update .gitea/workflows/cd-deploy.yaml
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m32s
debug workflow action
2025-12-09 14:27:42 -06:00
9720ee6ada Updated GUILDED references and links.
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m22s
2025-12-08 17:55:33 -06:00
8c1b132f8d Merge branch 'Calendar-Improvements' of https://gitea.iceberg-gaming.com/17th-Ranger-Battalion-ORG/milsim-site-v4 into Calendar-Improvements
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m17s
2025-12-05 16:47:27 -05:00
4e69b419f0 Merge branch 'main' into Calendar-Improvements
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m18s
2025-12-05 15:43:49 -06:00
a233b15ad4 Switched clock icon to something more readable at that size 2025-12-05 16:34:00 -05:00
94b5bfcff7 Added support for public read only calendar #53
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m18s
2025-12-05 16:28:42 -05:00
09d20fd18f prevented header wiggle when editable value changes
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m18s
2025-12-05 16:16:51 -05:00
89280dd3a7 Added (hidden) attendees summary
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m18s
2025-12-05 16:12:25 -05:00
575455a0fc overhauled attendance visualization to be more useful for planning
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m22s
2025-12-05 16:04:32 -05:00
ccb132b9b0 Improvements to event details UI to address #41
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m30s
2025-12-05 12:53:32 -05:00
e550f862bc event details area initial redesign 2025-12-04 23:41:55 -05:00
6d6789c4a6 Disabled attendance buttons if event end time is past current time to address #55
Some checks failed
Continuous Deployment / Update Deployment (push) Failing after 1m13s
2025-12-04 22:55:57 -05:00
24 changed files with 558 additions and 716 deletions

View File

@@ -1,6 +1,8 @@
name: Continuous Deployment name: Continuous Deployment
on: on:
push: push:
tags:
- '*'
jobs: jobs:
Deploy: Deploy:
@@ -8,20 +10,29 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
volumes: volumes:
- /var/www/html/milsim-site-v4:/var/www/html/milsim-site-v4:rw - /var/www/html/milsim-site-v4:/var/www/html/milsim-site-v4:z
steps: steps:
- name: Setup Local Environment - name: Setup Local Environment
run: | run: |
groupadd -g 989 nginx || true groupadd -g 989 nginx || true
useradd nginx -u 990 -g nginx -m || true useradd nginx -u 990 -g nginx -m || true
- name: Verify Node Environment - name: Update Node Environment
uses: actions/setup-node@v6
with:
node-version: 20.19
- name: Verify Local Environment
run: | run: |
which npm
npm -v npm -v
which node
node -v node -v
which sed
sed --version
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v5
with: with:
fetch-depth: 0 fetch-depth: 0
ref: 'main' ref: 'main'
@@ -32,31 +43,53 @@ jobs:
cp /workspace/17th-Ranger-Battalion-ORG/milsim-site-v4/.git/config .git/config cp /workspace/17th-Ranger-Battalion-ORG/milsim-site-v4/.git/config .git/config
chown nginx:nginx .git/config chown nginx:nginx .git/config
- name: Fix File Permissions
run: |
sudo chown -R nginx:nginx /var/www/html/milsim-site-v4
sudo chmod -R u+w /var/www/html/milsim-site-v4
- name: Update Application Code - name: Update Application Code
run: | run: |
sudo -u nginx bash -c "cd /var/www/html/milsim-site-v4 && git reset --hard && git pull origin main" cd /var/www/html/milsim-site-v4
version=`git log -1 --format=%H`
echo "Current Revision: $version"
echo "Updating to: ${{ github.sha }}
sudo -u nginx git reset --hard
sudo -u nginx git pull origin main
sudo -u nginx git pull origin main
new_version=`git log -1 --format=%H`
echo "Sucessfully updated to: $new_version
- name: Update Shared Dependencies - name: Update Shared Dependencies and Fix Permissions
run: | run: |
sudo -u nginx bash -c "cd /var/www/html/milsim-site-v4/shared && npm install" cd /var/www/html/milsim-site-v4/shared
npm install
chown -R nginx:nginx .
- name: Update UI Dependencies - name: Update UI Dependencies and Fix Permissions
run: | run: |
sudo -u nginx bash -c "cd /var/www/html/milsim-site-v4/ui && npm install" cd /var/www/html/milsim-site-v4/ui
npm install
chown -R nginx:nginx .
- name: Update API Dependencies - name: Update API Dependencies and Fix Permissions
run: | run: |
sudo -u nginx bash -c "cd /var/www/html/milsim-site-v4/api && npm install" cd /var/www/html/milsim-site-v4/api
npm install
chown -R nginx:nginx .
- name: Build UI - name: Build UI / Update Version / Fix Permissions
run: | run: |
sudo -u nginx bash -c "cd /var/www/html/milsim-site-v4/ui && npm run build" cd /var/www/html/milsim-site-v4/ui
npm run build
version=`git describe --abbrev=0 --tags`
sed -i "s/VITE_APPLICATION_VERSION=.*/VITE_APPLICATION_VERSION=$version/" .env
chown -R nginx:nginx .
- name: Build API - name: Build API / Update Version / Fix Permissions
run: | run: |
sudo -u nginx bash -c "cd /var/www/html/milsim-site-v4/api && npm run build" cd /var/www/html/milsim-site-v4/api
npm run build
version=`git describe --abbrev=0 --tags`
sed -i "s/APPLICATION_VERSION=.*/APPLICATION_VERSION=$version/" .env
chown -R nginx:nginx .
- name: Reset File Permissions
run: |
sudo chown -R nginx:nginx /var/www/html/milsim-site-v4
sudo chmod -R u+w /var/www/html/milsim-site-v4

View File

@@ -0,0 +1,89 @@
name: Continuous Integration
on:
push:
branches:
- main
jobs:
Deploy:
name: Update Development
runs-on: ubuntu-latest
container:
volumes:
- /var/www/html/milsim-site-v4-dev:/var/www/html/milsim-site-v4:z
steps:
- name: Setup Local Environment
run: |
groupadd -g 989 nginx || true
useradd nginx -u 990 -g nginx -m || true
- name: Update Node Environment
uses: actions/setup-node@v6
with:
node-version: 20.19
- name: Verify Local Environment
run: |
which npm
npm -v
which node
node -v
which sed
sed --version
- name: Checkout
uses: actions/checkout@v5
with:
fetch-depth: 0
ref: 'main'
- name: Token Copy
run: |
cd /var/www/html/milsim-site-v4
cp /workspace/17th-Ranger-Battalion-ORG/milsim-site-v4/.git/config .git/config
chown nginx:nginx .git/config
- name: Update Application Code
run: |
cd /var/www/html/milsim-site-v4
sudo -u nginx git reset --hard
sudo -u nginx git pull origin main
- name: Update Shared Dependencies and Fix Permissions
run: |
cd /var/www/html/milsim-site-v4/shared
npm install
chown -R nginx:nginx .
- name: Update UI Dependencies and Fix Permissions
run: |
cd /var/www/html/milsim-site-v4/ui
npm install
chown -R nginx:nginx .
- name: Update API Dependencies and Fix Permissions
run: |
cd /var/www/html/milsim-site-v4/api
npm install
chown -R nginx:nginx .
- name: Build UI / Update Version / Fix Permissions
run: |
cd /var/www/html/milsim-site-v4/ui
npm run build
version=`git rev-parse --short=10 HEAD`
sed -i "s/VITE_APPLICATION_VERSION=.*/VITE_APPLICATION_VERSION=$version/" .env
chown -R nginx:nginx .
- name: Build API / Update Version / Fix Permissions
run: |
cd /var/www/html/milsim-site-v4/api
npm run build
version=`git rev-parse --short=10 HEAD`
sed -i "s/APPLICATION_VERSION=.*/APPLICATION_VERSION=$version/" .env
chown -R nginx:nginx .
- name: Reset File Permissions
run: |
sudo chown -R nginx:nginx /var/www/html/milsim-site-v4
sudo chmod -R u+w /var/www/html/milsim-site-v4

View File

@@ -19,6 +19,8 @@ AUTH_END_SESSION_URI=
SERVER_PORT=3000 SERVER_PORT=3000
CLIENT_URL= # This is whatever URL the client web app is served on CLIENT_URL= # This is whatever URL the client web app is served on
CLIENT_DOMAIN= #whatever.com CLIENT_DOMAIN= #whatever.com
APPLICATION_VERSION= # Should match release tag
APPLICATION_ENVIRONMENT= # dev / prod
# Glitchtip # Glitchtip
GLITCHTIP_DSN= GLITCHTIP_DSN=

View File

@@ -20,11 +20,14 @@ const port = process.env.SERVER_PORT;
//glitchtip setup //glitchtip setup
const sentry = require('@sentry/node'); const sentry = require('@sentry/node');
if (process.env.DISABLE_GLITCHTIP) { if (process.env.DISABLE_GLITCHTIP === "true") {
console.log("Glitchtip disabled") console.log("Glitchtip disabled")
} else { } else {
let dsn = process.env.GLITCHTIP_DSN; let dsn = process.env.GLITCHTIP_DSN;
sentry.init({ dsn: dsn }); let release = process.env.APPLICATION_VERSION;
let environment = process.env.APPLICATION_ENVIRONMENT;
console.log(release, environment)
sentry.init({ dsn: dsn, release: release, environment: environment });
console.log("Glitchtip initialized"); console.log("Glitchtip initialized");
} }
@@ -58,6 +61,7 @@ const { roles, memberRoles } = require('./routes/roles');
const { courseRouter, eventRouter } = require('./routes/course'); const { courseRouter, eventRouter } = require('./routes/course');
const { calendarRouter } = require('./routes/calendar') const { calendarRouter } = require('./routes/calendar')
const morgan = require('morgan'); const morgan = require('morgan');
const { env } = require('process');
app.use('/application', applicationsRouter); app.use('/application', applicationsRouter);
app.use('/ranks', ranks); app.use('/ranks', ranks);

View File

@@ -2,13 +2,11 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
import pool from '../db'; import pool from '../db';
import { approveApplication, createApplication, denyApplication, getAllMemberApplications, getApplicationByID, getApplicationComments, getApplicationList, getMemberApplication } from '../services/applicationService'; import { approveApplication, createApplication, getApplicationByID, getApplicationComments, getApplicationList, getMemberApplication } from '../services/applicationService';
import { MemberState, setUserState } from '../services/memberService'; import { MemberState, setUserState } from '../services/memberService';
import { getRankByName, insertMemberRank } from '../services/rankService'; import { getRankByName, insertMemberRank } from '../services/rankService';
import { ApplicationFull, CommentRow } from "@app/shared/types/application" import { ApplicationFull, CommentRow } from "@app/shared/types/application"
import { assignUserToStatus } from '../services/statusService'; import { assignUserToStatus } from '../services/statusService';
import { Request, Response } from 'express';
import { getUserRoles } from '../services/rolesService';
// POST /application // POST /application
router.post('/', async (req, res) => { router.post('/', async (req, res) => {
@@ -18,13 +16,13 @@ router.post('/', async (req, res) => {
const appVersion = 1; const appVersion = 1;
await createApplication(memberID, appVersion, JSON.stringify(App)) createApplication(memberID, appVersion, JSON.stringify(App))
await setUserState(memberID, MemberState.Applicant); setUserState(memberID, MemberState.Applicant);
res.sendStatus(201); res.sendStatus(201);
} catch (err) { } catch (err) {
console.error('Failed to create application: \n', err); console.error('Insert failed:', err);
res.status(500).json({ error: 'Failed to create application' }); res.status(500).json({ error: 'Failed to save application' });
} }
}); });
@@ -39,20 +37,6 @@ router.get('/all', async (req, res) => {
} }
}); });
router.get('/meList', async (req, res) => {
let userID = req.user.id;
try {
let application = await getAllMemberApplications(userID);
return res.status(200).json(application);
} catch (error) {
console.error('Failed to load applications: \n', error);
return res.status(500).json(error);
}
})
router.get('/me', async (req, res) => { router.get('/me', async (req, res) => {
let userID = req.user.id; let userID = req.user.id;
@@ -78,17 +62,13 @@ router.get('/me', async (req, res) => {
}) })
// GET /application/:id // GET /application/:id
router.get('/me/:id', async (req: Request, res: Response) => { router.get('/:id', async (req, res) => {
let appID = Number(req.params.id); let appID = req.params.id;
let member = req.user.id; console.log("HELLO")
try { try {
const application = await getApplicationByID(appID); const application = await getApplicationByID(appID);
if (application === undefined) if (application === undefined)
return res.sendStatus(204); return res.sendStatus(204);
console.log(application.member_id, member)
if (application.member_id != member) {
return res.sendStatus(403);
}
const comments: CommentRow[] = await getApplicationComments(appID); const comments: CommentRow[] = await getApplicationComments(appID);
@@ -104,42 +84,6 @@ router.get('/me/:id', async (req: Request, res: Response) => {
} }
}); });
// GET /application/:id
router.get('/:id', async (req: Request, res: Response) => {
let appID = Number(req.params.id);
let asAdmin = !!req.query.admin || false;
let user = req.user.id;
//TODO: Replace this with bigger authorization system eventually
if (asAdmin) {
let allowed = (await getUserRoles(user)).some((role) =>
role.name.toLowerCase() === 'dev' ||
role.name.toLowerCase() === 'recruiter' ||
role.name.toLowerCase() === 'administrator')
console.log(allowed)
if (!allowed) {
return res.sendStatus(403)
}
}
try {
const application = await getApplicationByID(appID);
if (application === undefined)
return res.sendStatus(204);
const comments: CommentRow[] = await getApplicationComments(appID, asAdmin);
const output: ApplicationFull = {
application,
comments,
}
return res.status(200).json(output);
}
catch (err) {
console.error('Query failed:', err);
return res.status(500).json({ error: 'Failed to load application' });
}
});
// POST /application/approve/:id // POST /application/approve/:id
router.post('/approve/:id', async (req, res) => { router.post('/approve/:id', async (req, res) => {
const appID = req.params.id; const appID = req.params.id;
@@ -148,6 +92,9 @@ router.post('/approve/:id', async (req, res) => {
const app = await getApplicationByID(appID); const app = await getApplicationByID(appID);
const result = await approveApplication(appID); const result = await approveApplication(appID);
console.log("START");
console.log(app, result);
//guard against failures //guard against failures
if (result.affectedRows != 1) { if (result.affectedRows != 1) {
throw new Error("Something went wrong approving the application"); throw new Error("Something went wrong approving the application");
@@ -172,11 +119,26 @@ router.post('/approve/:id', async (req, res) => {
router.post('/deny/:id', async (req, res) => { router.post('/deny/:id', async (req, res) => {
const appID = req.params.id; const appID = req.params.id;
const sql = `
UPDATE applications
SET denied_at = NOW()
WHERE id = ?
AND approved_at IS NULL
AND denied_at IS NULL
`;
try { try {
const app = await getApplicationByID(appID); const result = await pool.execute(sql, appID);
await denyApplication(appID);
await setUserState(app.member_id, MemberState.Denied); console.log(result);
res.sendStatus(200);
if (result.affectedRows === 0) {
res.status(400).json('Something went wrong denying the application');
}
if (result.affectedRows == 1) {
res.sendStatus(200);
}
} catch (err) { } catch (err) {
console.error('Approve failed:', err); console.error('Approve failed:', err);
res.status(500).json({ error: 'Failed to deny application' }); res.status(500).json({ error: 'Failed to deny application' });
@@ -184,12 +146,10 @@ router.post('/deny/:id', async (req, res) => {
}); });
// POST /application/:id/comment // POST /application/:id/comment
router.post('/:id/comment', async (req: Request, res: Response) => { router.post('/:id/comment', async (req, res) => {
const appID = req.params.id; const appID = req.params.id;
const data = req.body.message; const data = req.body.message;
const user = req.user; const user = 1;
console.log(user)
const sql = `INSERT INTO application_comments( const sql = `INSERT INTO application_comments(
application_id, application_id,
@@ -201,7 +161,7 @@ VALUES(?, ?, ?);`
try { try {
const conn = await pool.getConnection(); const conn = await pool.getConnection();
const result = await conn.query(sql, [appID, user.id, data]) const result = await conn.query(sql, [appID, user, data])
console.log(result) console.log(result)
if (result.affectedRows !== 1) { if (result.affectedRows !== 1) {
conn.release(); conn.release();
@@ -226,60 +186,4 @@ VALUES(?, ?, ?);`
} }
}); });
// POST /application/:id/comment
router.post('/:id/adminComment', async (req: Request, res: Response) => {
const appID = req.params.id;
const data = req.body.message;
const user = req.user;
console.log(user)
const sql = `INSERT INTO application_comments(
application_id,
poster_id,
post_content,
admin_only
)
VALUES(?, ?, ?, 1);`
try {
const conn = await pool.getConnection();
const result = await conn.query(sql, [appID, user.id, data])
console.log(result)
if (result.affectedRows !== 1) {
conn.release();
throw new Error("Insert Failure")
}
const getSQL = `SELECT app.id AS comment_id,
app.post_content,
app.poster_id,
app.post_time,
app.last_modified,
app.admin_only,
member.name AS poster_name
FROM application_comments AS app
INNER JOIN members AS member ON member.id = app.poster_id
WHERE app.id = ?; `;
const comment = await conn.query(getSQL, [result.insertId])
res.status(201).json(comment[0]);
} catch (err) {
console.error('Comment failed:', err);
res.status(500).json({ error: 'Could not post comment' });
}
});
router.post('/restart', async (req: Request, res: Response) => {
const user = req.user.id;
try {
await setUserState(user, MemberState.Guest);
res.sendStatus(200);
} catch (error) {
console.error('Comment failed:', error);
res.status(500).json({ error: 'Could not rester application' });
}
})
module.exports = router; module.exports = router;

View File

@@ -21,12 +21,13 @@ passport.use(new OpenIDConnectStrategy({
scope: ['openid', 'profile'] scope: ['openid', 'profile']
}, async function verify(issuer, sub, profile, jwtClaims, accessToken, refreshToken, params, cb) { }, async function verify(issuer, sub, profile, jwtClaims, accessToken, refreshToken, params, cb) {
// console.log('--- OIDC verify() called ---'); console.log('--- OIDC verify() called ---');
// console.log('issuer:', issuer); console.log('issuer:', issuer);
// console.log('sub:', sub); console.log('sub:', sub);
// console.log('profile:', JSON.stringify(profile, null, 2)); // console.log('profile:', JSON.stringify(profile, null, 2));
// console.log('id_token claims:', JSON.stringify(jwtClaims, null, 2)); console.log('profile:', profile);
// console.log('preferred_username:', jwtClaims?.preferred_username); console.log('id_token claims:', JSON.stringify(jwtClaims, null, 2));
console.log('preferred_username:', jwtClaims?.preferred_username);
const con = await pool.getConnection(); const con = await pool.getConnection();
try { try {

View File

@@ -1,6 +1,5 @@
import { ApplicationListRow, ApplicationRow, CommentRow } from "@app/shared/types/application"; import { ApplicationListRow, ApplicationRow, CommentRow } from "@app/shared/types/application";
import pool from "../db"; import pool from "../db";
import { error } from "console";
export async function createApplication(memberID: number, appVersion: number, app: string) { export async function createApplication(memberID: number, appVersion: number, app: string) {
const sql = `INSERT INTO applications (member_id, app_version, app_data) VALUES (?, ?, ?);`; const sql = `INSERT INTO applications (member_id, app_version, app_data) VALUES (?, ?, ?);`;
@@ -13,13 +12,12 @@ export async function getMemberApplication(memberID: number): Promise<Applicatio
member.name AS member_name member.name AS member_name
FROM applications AS app FROM applications AS app
INNER JOIN members AS member ON member.id = app.member_id INNER JOIN members AS member ON member.id = app.member_id
WHERE app.member_id = ? ORDER BY submitted_at DESC LIMIT 1;`; WHERE app.member_id = ?;`;
let app: ApplicationRow[] = await pool.query(sql, [memberID]); let app: ApplicationRow[] = await pool.query(sql, [memberID]);
return app[0]; return app[0];
} }
export async function getApplicationByID(appID: number): Promise<ApplicationRow> { export async function getApplicationByID(appID: number): Promise<ApplicationRow> {
const sql = const sql =
`SELECT app.*, `SELECT app.*,
@@ -46,20 +44,7 @@ export async function getApplicationList(): Promise<ApplicationListRow[]> {
return rows; return rows;
} }
export async function getAllMemberApplications(memberID: number): Promise<ApplicationListRow[]> { export async function approveApplication(id) {
const sql = `SELECT
app.id,
app.member_id,
app.submitted_at,
app.app_status
FROM applications AS app WHERE app.member_id = ? ORDER BY submitted_at DESC;`;
const rows: ApplicationListRow[] = await pool.query(sql, [memberID])
return rows;
}
export async function approveApplication(id: number) {
const sql = ` const sql = `
UPDATE applications UPDATE applications
SET approved_at = NOW() SET approved_at = NOW()
@@ -72,38 +57,15 @@ export async function approveApplication(id: number) {
return result; return result;
} }
export async function denyApplication(id: number) { export async function getApplicationComments(appID: number): Promise<CommentRow[]> {
const sql = `
UPDATE applications
SET denied_at = NOW()
WHERE id = ?
AND approved_at IS NULL
AND denied_at IS NULL
`;
const result = await pool.execute(sql, id);
if (result.affectedRows == 1) {
return
} else {
throw new Error(`"Something went wrong denying application with ID ${id}`);
}
}
export async function getApplicationComments(appID: number, admin: boolean = false): Promise<CommentRow[]> {
const excludeAdmin = ' AND app.admin_only = false';
const whereClause = `WHERE app.application_id = ?${!admin ? excludeAdmin : ''}`;
return await pool.query(`SELECT app.id AS comment_id, return await pool.query(`SELECT app.id AS comment_id,
app.post_content, app.post_content,
app.poster_id, app.poster_id,
app.post_time, app.post_time,
app.last_modified, app.last_modified,
app.admin_only,
member.name AS poster_name member.name AS poster_name
FROM application_comments AS app FROM application_comments AS app
INNER JOIN members AS member ON member.id = app.poster_id INNER JOIN members AS member ON member.id = app.poster_id
${whereClause}`, WHERE app.application_id = ?;`,
[appID]); [appID]);
} }

View File

@@ -123,15 +123,9 @@ export async function setAttendanceStatus(memberID: number, eventID: number, sta
} }
export async function getEventAttendance(eventID: number): Promise<CalendarSignup[]> { export async function getEventAttendance(eventID: number): Promise<CalendarSignup[]> {
const sql = `
SELECT
s.member_id,
s.status,
m.name AS member_name
FROM calendar_events_signups s
LEFT JOIN members m ON s.member_id = m.id
WHERE s.event_id = ?
`;
return await pool.query(sql, [eventID]); const sql = "CALL `sp_GetCalendarEventSignups`(?)"
const res = await pool.query(sql, [eventID]);
console.log(res[0]);
return res[0];
} }

View File

@@ -5,6 +5,7 @@ module.exports = {
script: 'built/api/src/index.js', script: 'built/api/src/index.js',
watch: ['.env', 'built'], watch: ['.env', 'built'],
ignore_watch: ['.gitignore', '\.json', 'src', '\.db', 'node_modules'], ignore_watch: ['.gitignore', '\.json', 'src', '\.db', 'node_modules'],
appendEnvToName: true,
watch_options: { watch_options: {
usePolling: true, usePolling: true,
interval: 10000 interval: 10000

View File

@@ -40,7 +40,6 @@ export interface CommentRow {
post_time: string; post_time: string;
last_modified: string | null; last_modified: string | null;
poster_name: string; poster_name: string;
admin_only: boolean;
} }
export interface ApplicationFull { export interface ApplicationFull {

View File

@@ -26,6 +26,7 @@ export interface CalendarSignup {
eventID: number; eventID: number;
status: CalendarAttendance; status: CalendarAttendance;
member_name?: string; member_name?: string;
member_unit?: string;
} }
export interface CalendarEventShort { export interface CalendarEventShort {

View File

@@ -1,6 +1,8 @@
# SITE SETTINGS # SITE SETTINGS
VITE_APIHOST= VITE_APIHOST=
VITE_ENVIRONMENT= # dev / prod VITE_ENVIRONMENT= # dev / prod
VITE_APPLICATION_VERSION= # Should match release tag
# Glitchtip # Glitchtip
VITE_GLITCHTIP_DSN= VITE_GLITCHTIP_DSN=

View File

@@ -1,11 +1,80 @@
import { ApplicationFull } from "@shared/types/application"; export type ApplicationDto = Partial<{
age: number | string
name: string
playtime: number | string
hobbies: string
military: boolean
communities: string
joinReason: string
milsimAttraction: string
referral: string
steamProfile: string
timezone: string
canAttendSaturday: boolean
interests: string
aknowledgeRules: boolean
}>
export interface ApplicationData {
dob: string;
name: string;
playtime: number;
hobbies: string;
military: boolean;
communities: string;
joinReason: string;
milsimAttraction: string;
referral: string;
steamProfile: string;
timezone: string;
canAttendSaturday: boolean;
interests: string;
aknowledgeRules: boolean;
}
//reflects how applications are stored in the database
export interface ApplicationRow {
id: number;
member_id: number;
app_version: number;
app_data: ApplicationData;
submitted_at: string; // ISO datetime from DB (e.g., "2025-08-25T18:04:29.000Z")
updated_at: string | null;
approved_at: string | null;
denied_at: string | null;
app_status: ApplicationStatus; // generated column
decision_at: string | null; // generated column
// present when you join members (e.g., SELECT a.*, m.name AS member_name)
member_name: string;
}
export interface CommentRow {
comment_id: number;
post_content: string;
poster_id: number;
post_time: string;
last_modified: string | null;
poster_name: string;
}
export interface ApplicationFull {
application: ApplicationRow;
comments: CommentRow[];
}
export enum ApplicationStatus {
Pending = "Pending",
Accepted = "Accepted",
Denied = "Denied",
}
// @ts-ignore // @ts-ignore
const addr = import.meta.env.VITE_APIHOST; const addr = import.meta.env.VITE_APIHOST;
export async function loadApplication(id: number | string, asAdmin: boolean = false): Promise<ApplicationFull | null> { export async function loadApplication(id: number | string): Promise<ApplicationFull | null> {
const res = await fetch(`${addr}/application/${id}?admin=${asAdmin}`, { credentials: 'include' }) const res = await fetch(`${addr}/application/${id}`, { credentials: 'include' })
if (res.status === 204) return null if (res.status === 204) return null
if (!res.ok) throw new Error('Failed to load application') if (!res.ok) throw new Error('Failed to load application')
const json = await res.json() const json = await res.json()
@@ -35,22 +104,6 @@ export async function postChatMessage(message: any, post_id: number) {
const response = await fetch(`${addr}/application/${post_id}/comment`, { const response = await fetch(`${addr}/application/${post_id}/comment`, {
method: 'POST', method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(out),
})
return await response.json();
}
export async function postAdminChatMessage(message: any, post_id: number) {
const out = {
message: message
}
const response = await fetch(`${addr}/application/${post_id}/adminComment`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(out), body: JSON.stringify(out),
}) })
@@ -68,26 +121,6 @@ export async function getAllApplications(): Promise<ApplicationFull> {
} }
} }
export async function loadMyApplications(): Promise<ApplicationFull> {
const res = await fetch(`${addr}/application/meList`, { credentials: 'include' })
if (res.ok) {
return res.json()
} else {
console.error("Something went wrong approving the application")
}
}
export async function getMyApplication(id: number): Promise<ApplicationFull> {
const res = await fetch(`${addr}/application/me/${id}`, { credentials: 'include' })
if (res.status === 204) return null
if (res.status === 403) throw new Error("Unauthorized");
if (!res.ok) throw new Error('Failed to load application')
const json = await res.json()
// Accept either the object at root or under `application`
return json;
}
export async function approveApplication(id: Number) { export async function approveApplication(id: Number) {
const res = await fetch(`${addr}/application/approve/${id}`, { method: 'POST' }) const res = await fetch(`${addr}/application/approve/${id}`, { method: 'POST' })
@@ -102,15 +135,4 @@ export async function denyApplication(id: Number) {
if (!res.ok) { if (!res.ok) {
console.error("Something went wrong denying the application") console.error("Something went wrong denying the application")
} }
}
export async function restartApplication() {
const res = await fetch(`${addr}/application/restart`, {
method: 'POST',
credentials: 'include'
})
if (!res.ok) {
console.error("Something went wrong restarting your application")
}
} }

View File

@@ -156,6 +156,12 @@ function blurAfter() {
<RouterLink to="/join" @click="blurAfter">Join</RouterLink> <RouterLink to="/join" @click="blurAfter">Join</RouterLink>
</NavigationMenuLink> </NavigationMenuLink>
</NavigationMenuItem> </NavigationMenuItem>
<!-- Calendar -->
<NavigationMenuItem>
<NavigationMenuLink as-child :class="navigationMenuTriggerStyle()">
<RouterLink to="/calendar" @click="blurAfter">Calendar</RouterLink>
</NavigationMenuLink>
</NavigationMenuItem>
</NavigationMenuList> </NavigationMenuList>
</NavigationMenu> </NavigationMenu>
</div> </div>
@@ -170,7 +176,6 @@ function blurAfter() {
<!-- <DropdownMenuItem>My Profile</DropdownMenuItem> --> <!-- <DropdownMenuItem>My Profile</DropdownMenuItem> -->
<!-- <DropdownMenuItem>Settings</DropdownMenuItem> --> <!-- <DropdownMenuItem>Settings</DropdownMenuItem> -->
<DropdownMenuItem @click="$router.push('/join')">My Application</DropdownMenuItem> <DropdownMenuItem @click="$router.push('/join')">My Application</DropdownMenuItem>
<DropdownMenuItem @click="$router.push('/applications')">Application History</DropdownMenuItem>
<DropdownMenuItem :variant="'destructive'" @click="logout()">Logout</DropdownMenuItem> <DropdownMenuItem :variant="'destructive'" @click="logout()">Logout</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View File

@@ -11,18 +11,13 @@ import {
import Textarea from '@/components/ui/textarea/Textarea.vue' import Textarea from '@/components/ui/textarea/Textarea.vue'
import { toTypedSchema } from '@vee-validate/zod' import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod' import * as z from 'zod'
import { useAuth } from '@/composables/useAuth'
import { CommentRow } from '@shared/types/application'
import { Dot } from 'lucide-vue-next'
import { ref } from 'vue'
const props = defineProps<{ const props = defineProps<{
messages: CommentRow[] messages: Array<Record<string, any>>
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'post', text: string): void (e: 'post', text: string): void
(e: 'postInternal', text: string): void
}>() }>()
const commentSchema = toTypedSchema( const commentSchema = toTypedSchema(
@@ -31,14 +26,9 @@ const commentSchema = toTypedSchema(
}) })
) )
const submitMode = ref("public");
// vee-validate passes (values, actions) to @submit // vee-validate passes (values, actions) to @submit
function onSubmit(values: { text: string }, { resetForm }: { resetForm: () => void }) { function onSubmit(values: { text: string }, { resetForm }: { resetForm: () => void }) {
if (submitMode.value === "internal") emit('post', values.text.trim())
emit('postInternal', values.text.trim())
else
emit('post', values.text.trim())
resetForm() resetForm()
} }
</script> </script>
@@ -58,31 +48,25 @@ function onSubmit(values: { text: string }, { resetForm }: { resetForm: () => vo
</FormField> </FormField>
<!-- Button below, right-aligned --> <!-- Button below, right-aligned -->
<div class="mt-2 flex justify-end gap-2"> <div class="mt-2 flex justify-end">
<Button type="submit" @click="submitMode = 'internal'" variant="outline">Post (Internal)</Button> <Button type="submit">Post</Button>
<Button type="submit" @click="submitMode = 'public'">Post (Public)</Button>
</div> </div>
</Form> </Form>
<!-- Existing posts --> <!-- Existing posts -->
<div class="space-y-3"> <div class="space-y-3">
<div v-for="(message, i) in props.messages" :key="message.comment_id ?? i" class="rounded-md border p-3 space-y-5" <div v-for="(message, i) in props.messages" :key="message.id ?? i"
:class="message.admin_only ? 'border-amber-300/70' : 'border-neutral-800'"> class="rounded-md border border-neutral-800 p-3 space-y-5">
<!-- Comment header --> <!-- Comment header -->
<div class="flex justify-between"> <div class="flex justify-between">
<div class="flex"> <p>{{ message.poster_name }}</p>
<p>{{ message.poster_name }}</p>
<p v-if="message.admin_only" class="flex">
<Dot /><span class="text-amber-300">Internal</span>
</p>
</div>
<p class="text-muted-foreground">{{ new Date(message.post_time).toLocaleString("EN-us", { <p class="text-muted-foreground">{{ new Date(message.post_time).toLocaleString("EN-us", {
year: "numeric", year: "numeric",
month: "long", month: "long",
day: "numeric", day: "numeric",
hour: "2-digit", hour: "2-digit",
minute: "2-digit" minute: "2-digit"
}) }}</p> }) }}</p>
</div> </div>
<p>{{ message.post_content }}</p> <p>{{ message.post_content }}</p>
</div> </div>

View File

@@ -16,27 +16,23 @@ import { Form } from 'vee-validate';
import { onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
import * as z from 'zod'; import * as z from 'zod';
import DateInput from '../form/DateInput.vue'; import DateInput from '../form/DateInput.vue';
import { ApplicationData } from '@shared/types/application'; import { ApplicationData } from '@/api/application';
const regexA = /^https?:\/\/steamcommunity\.com\/id\/[A-Za-z0-9_]+\/?$/;
const regexB = /^https?:\/\/steamcommunity\.com\/profiles\/\d+\/?$/;
const formSchema = toTypedSchema(z.object({ const formSchema = toTypedSchema(z.object({
dob: z.string().refine(v => v, { message: "A date of birth is required." }), dob: z.string().refine(v => v, { message: "A date of birth is required." }),
name: z.string().nonempty(), name: z.string(),
playtime: z.coerce.number({ invalid_type_error: "Must be a number", }).min(0, "Cannot be less than 0"), playtime: z.coerce.number({ invalid_type_error: "Must be a number", }).min(0, "Cannot be less than 0"),
hobbies: z.string().nonempty(), hobbies: z.string(),
military: z.boolean(), military: z.boolean(),
communities: z.string().nonempty(), communities: z.string(),
joinReason: z.string().nonempty(), joinReason: z.string(),
milsimAttraction: z.string().nonempty(), milsimAttraction: z.string(),
referral: z.string().nonempty(), referral: z.string(),
steamProfile: z.string().nonempty().refine((val) => regexA.test(val) || regexB.test(val), { message: "Invalid Steam profile URL." }), steamProfile: z.string(),
timezone: z.string().nonempty(), timezone: z.string(),
canAttendSaturday: z.boolean(), canAttendSaturday: z.boolean(),
interests: z.string().nonempty(), interests: z.string(),
acknowledgeRules: z.literal(true, { aknowledgeRules: z.literal(true, {
errorMap: () => ({ message: "Required" }) errorMap: () => ({ message: "Required" })
}), }),
})) }))
@@ -45,7 +41,7 @@ const formSchema = toTypedSchema(z.object({
const fallbackInitials = { const fallbackInitials = {
military: false, military: false,
canAttendSaturday: false, canAttendSaturday: false,
acknowledgeRules: false, aknowledgeRules: false,
} }
const props = defineProps<{ const props = defineProps<{
@@ -86,9 +82,7 @@ onMounted(() => {
<FormControl> <FormControl>
<DateInput :model-value="(value as string) ?? ''" :disabled="readOnly" @update:model-value="handleChange" /> <DateInput :model-value="(value as string) ?? ''" :disabled="readOnly" @update:model-value="handleChange" />
</FormControl> </FormControl>
<div class="h-4"> <FormMessage />
<FormMessage class="text-destructive" />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
@@ -100,9 +94,7 @@ onMounted(() => {
<FormControl> <FormControl>
<Input :model-value="value" @update:model-value="handleChange" :disabled="readOnly" /> <Input :model-value="value" @update:model-value="handleChange" :disabled="readOnly" />
</FormControl> </FormControl>
<div class="h-4"> <FormMessage />
<FormMessage class="text-destructive" />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
@@ -113,9 +105,7 @@ onMounted(() => {
<FormControl> <FormControl>
<Input type="number" :model-value="value" @update:model-value="handleChange" :disabled="readOnly" /> <Input type="number" :model-value="value" @update:model-value="handleChange" :disabled="readOnly" />
</FormControl> </FormControl>
<div class="h-4"> <FormMessage />
<FormMessage class="text-destructive" />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
@@ -127,9 +117,7 @@ onMounted(() => {
<Textarea rows="4" class="resize-none" :model-value="value" @update:model-value="handleChange" <Textarea rows="4" class="resize-none" :model-value="value" @update:model-value="handleChange"
:disabled="readOnly" /> :disabled="readOnly" />
</FormControl> </FormControl>
<div class="h-4"> <FormMessage />
<FormMessage class="text-destructive" />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
@@ -143,9 +131,7 @@ onMounted(() => {
<span>Yes (checked) / No (unchecked)</span> <span>Yes (checked) / No (unchecked)</span>
</div> </div>
</FormControl> </FormControl>
<div class="h-4"> <FormMessage />
<FormMessage class="text-destructive" />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
@@ -156,9 +142,7 @@ onMounted(() => {
<FormControl> <FormControl>
<Input :model-value="value" @update:model-value="handleChange" :disabled="readOnly" /> <Input :model-value="value" @update:model-value="handleChange" :disabled="readOnly" />
</FormControl> </FormControl>
<div class="h-4"> <FormMessage />
<FormMessage class="text-destructive" />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
@@ -170,9 +154,7 @@ onMounted(() => {
<Textarea rows="4" class="resize-none" :model-value="value" @update:model-value="handleChange" <Textarea rows="4" class="resize-none" :model-value="value" @update:model-value="handleChange"
:disabled="readOnly" /> :disabled="readOnly" />
</FormControl> </FormControl>
<div class="h-4"> <FormMessage />
<FormMessage class="text-destructive" />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
@@ -184,9 +166,7 @@ onMounted(() => {
<Textarea rows="4" class="resize-none" :model-value="value" @update:model-value="handleChange" <Textarea rows="4" class="resize-none" :model-value="value" @update:model-value="handleChange"
:disabled="readOnly" /> :disabled="readOnly" />
</FormControl> </FormControl>
<div class="h-4"> <FormMessage />
<FormMessage class="text-destructive" />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
@@ -198,9 +178,7 @@ onMounted(() => {
<Input placeholder="e.g., Reddit / Member: Alice" :model-value="value" @update:model-value="handleChange" <Input placeholder="e.g., Reddit / Member: Alice" :model-value="value" @update:model-value="handleChange"
:disabled="readOnly" /> :disabled="readOnly" />
</FormControl> </FormControl>
<div class="h-4"> <FormMessage />
<FormMessage class="text-destructive" />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
@@ -216,9 +194,7 @@ onMounted(() => {
<Input type="url" placeholder="https://steamcommunity.com/profiles/7656119..." :model-value="value" <Input type="url" placeholder="https://steamcommunity.com/profiles/7656119..." :model-value="value"
@update:model-value="handleChange" :disabled="readOnly" /> @update:model-value="handleChange" :disabled="readOnly" />
</FormControl> </FormControl>
<div class="h-4"> <FormMessage />
<FormMessage class="text-destructive" />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
@@ -230,9 +206,7 @@ onMounted(() => {
<Input placeholder="e.g., AEST, EST, UTC+10" :model-value="value" @update:model-value="handleChange" <Input placeholder="e.g., AEST, EST, UTC+10" :model-value="value" @update:model-value="handleChange"
:disabled="readOnly" /> :disabled="readOnly" />
</FormControl> </FormControl>
<div class="h-4"> <FormMessage />
<FormMessage class="text-destructive" />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
@@ -246,9 +220,7 @@ onMounted(() => {
<span>Yes (checked) / No (unchecked)</span> <span>Yes (checked) / No (unchecked)</span>
</div> </div>
</FormControl> </FormControl>
<div class="h-4"> <FormMessage />
<FormMessage class="text-destructive" />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
@@ -260,26 +232,22 @@ onMounted(() => {
<Input placeholder="e.g., Rifleman; Medic; Pilot" :model-value="value" @update:model-value="handleChange" <Input placeholder="e.g., Rifleman; Medic; Pilot" :model-value="value" @update:model-value="handleChange"
:disabled="readOnly" /> :disabled="readOnly" />
</FormControl> </FormControl>
<div class="h-4"> <FormMessage />
<FormMessage class="text-destructive" />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
<!-- Code of Conduct (boolean, field name kept as-is) --> <!-- Code of Conduct (boolean, field name kept as-is) -->
<FormField name="acknowledgeRules" v-slot="{ value, handleChange }"> <FormField name="aknowledgeRules" v-slot="{ value, handleChange }">
<FormItem> <FormItem>
<FormLabel>Community Code of Conduct</FormLabel> <FormLabel>Community Code of Conduct</FormLabel>
<FormControl> <FormControl>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Checkbox :model-value="value" @update:model-value="handleChange" :disabled="readOnly" /> <Checkbox :model-value="value" @update:model-value="handleChange" :disabled="readOnly" />
<span>By checking this box, you accept the <Button variant="link" class="p-0 h-min">Code of <span>By checking this box, you accept the <Button variant="link" class="p-0">Code of
Conduct</Button>.</span> Conduct</Button>.</span>
</div> </div>
</FormControl> </FormControl>
<div class="h-4"> <FormMessage />
<FormMessage class="text-destructive" />
</div>
</FormItem> </FormItem>
</FormField> </FormField>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { CalendarEvent, CalendarSignup } from '@shared/types/calendar' import type { CalendarEvent, CalendarSignup } from '@shared/types/calendar'
import { CircleAlert, Clock, EllipsisVertical, MapPin, User, X } from 'lucide-vue-next'; import { CircleAlert, Clock4, EllipsisVertical, MapPin, User, X } from 'lucide-vue-next';
import { computed, onMounted, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import ButtonGroup from '../ui/button-group/ButtonGroup.vue'; import ButtonGroup from '../ui/button-group/ButtonGroup.vue';
import Button from '../ui/button/Button.vue'; import Button from '../ui/button/Button.vue';
import { CalendarAttendance, getCalendarEvent, setCalendarEventAttendance, setCancelCalendarEvent } from '@/api/calendar'; import { CalendarAttendance, getCalendarEvent, setCalendarEventAttendance, setCancelCalendarEvent } from '@/api/calendar';
@@ -11,24 +11,13 @@ import DropdownMenu from '../ui/dropdown-menu/DropdownMenu.vue';
import DropdownMenuTrigger from '../ui/dropdown-menu/DropdownMenuTrigger.vue'; import DropdownMenuTrigger from '../ui/dropdown-menu/DropdownMenuTrigger.vue';
import DropdownMenuContent from '../ui/dropdown-menu/DropdownMenuContent.vue'; import DropdownMenuContent from '../ui/dropdown-menu/DropdownMenuContent.vue';
import DropdownMenuItem from '../ui/dropdown-menu/DropdownMenuItem.vue'; import DropdownMenuItem from '../ui/dropdown-menu/DropdownMenuItem.vue';
import { Calendar } from 'lucide-vue-next';
const route = useRoute(); const route = useRoute();
// const eventID = computed(() => {
// const id = route.params.id;
// if (typeof id === 'string') return id;
// return undefined;
// });
const loaded = ref<boolean>(false); const loaded = ref<boolean>(false);
const activeEvent = ref<CalendarEvent | null>(null); const activeEvent = ref<CalendarEvent | null>(null);
// onMounted(async () => {
// let eventID = route.params.id;
// console.log(eventID);
// activeEvent.value = await getCalendarEvent(Number(eventID));
// loaded.value = true;
// });
watch( watch(
() => route.params.id, () => route.params.id,
async (id) => { async (id) => {
@@ -45,23 +34,27 @@ const emit = defineEmits<{
(e: 'edit', event: CalendarEvent): void (e: 'edit', event: CalendarEvent): void
}>() }>()
// const activeEvent = computed(() => props.event) const dateFmt = new Intl.DateTimeFormat(undefined, {
weekday: 'long', month: 'short', day: 'numeric',
const startFmt = new Intl.DateTimeFormat(undefined, {
weekday: 'short', year: 'numeric', month: 'short', day: 'numeric',
hour: 'numeric', minute: '2-digit'
}) })
const endFmt = new Intl.DateTimeFormat(undefined, {
const timeFmt = new Intl.DateTimeFormat(undefined, {
hour: 'numeric', minute: '2-digit' hour: 'numeric', minute: '2-digit'
}) })
const whenText = computed(() => { const dateText = computed(() => {
if (!activeEvent.value?.start) return '' let start = dateFmt.format(new Date(activeEvent.value.start));
const s = new Date(activeEvent.value.start) let end = dateFmt.format(new Date(activeEvent.value.end));
const e = activeEvent.value?.end ? new Date(activeEvent.value.end) : null if (start === end)
return e return start;
? `${startFmt.format(s)} ${endFmt.format(e)}` else
: `${startFmt.format(s)}` return `${start} - ${end}`;
})
const timeText = computed(() => {
let startTime = timeFmt.format(new Date(activeEvent.value.start))
let endTime = timeFmt.format(new Date(activeEvent.value.end))
return [startTime, endTime]
}) })
const attending = computed<CalendarSignup[]>(() => { return activeEvent.value.eventSignups.filter((s) => s.status == CalendarAttendance.Attending) }) const attending = computed<CalendarSignup[]>(() => { return activeEvent.value.eventSignups.filter((s) => s.status == CalendarAttendance.Attending) })
@@ -71,6 +64,7 @@ const viewedState = ref<CalendarAttendance>(CalendarAttendance.Attending);
let user = useUserStore(); let user = useUserStore();
const myAttendance = computed<CalendarSignup | null>(() => { const myAttendance = computed<CalendarSignup | null>(() => {
if (!user.isLoggedIn) return null;
return activeEvent.value.eventSignups.find( return activeEvent.value.eventSignups.find(
(s) => s.member_id === user.user.id (s) => s.member_id === user.user.id
) || null; ) || null;
@@ -83,6 +77,7 @@ async function setAttendance(state: CalendarAttendance) {
} }
const canEditEvent = computed(() => { const canEditEvent = computed(() => {
if (!user.isLoggedIn) return false;
if (user.user.id == activeEvent.value.creator_id) if (user.user.id == activeEvent.value.creator_id)
return true; return true;
}); });
@@ -97,17 +92,94 @@ async function forceReload() {
activeEvent.value = await getCalendarEvent(activeEvent.value.id); activeEvent.value = await getCalendarEvent(activeEvent.value.id);
} }
defineExpose({forceReload}) const isPast = computed(() => {
const end = new Date(activeEvent.value.end)
// is current date later than end date
return new Date() < end;
})
const attendanceTab = ref<"Alpha" | "Echo" | "Other">("Alpha");
const attendanceList = computed<CalendarSignup[]>(() => {
let out: CalendarSignup[] = [];
if (attendanceTab.value === 'Alpha') {
out = activeEvent.value.eventSignups?.filter((s) => s.member_unit === 'Alpha Company');
} else if (attendanceTab.value === 'Echo') {
out = activeEvent.value.eventSignups?.filter((s) => s.member_unit === 'Echo Company')
} else {
out = activeEvent.value.eventSignups?.filter((s) => s.member_unit != 'Alpha Company' && s.member_unit != 'Echo Company')
}
const statusOrder: Record<CalendarAttendance, number> = {
[CalendarAttendance.Attending]: 1,
[CalendarAttendance.Maybe]: 2,
[CalendarAttendance.NotAttending]: 3,
};
out.sort((a, b) => statusOrder[a.status] - statusOrder[b.status]);
return out;
})
const attendanceCountsByGroup = computed(() => {
const signups = activeEvent.value.eventSignups ?? [];
return {
Alpha: signups.filter(s => s.member_unit === "Alpha Company").length,
Echo: signups.filter(s => s.member_unit === "Echo Company").length,
Other: signups.filter(s =>
s.member_unit !== "Alpha Company" &&
s.member_unit !== "Echo Company"
).length,
};
});
const attendanceStatusSummary = computed(() => {
const signups = activeEvent.value.eventSignups ?? [];
return {
attending: signups.filter(s => s.status === CalendarAttendance.Attending).length,
maybe: signups.filter(s => s.status === CalendarAttendance.Maybe).length,
notAttending: signups.filter(s => s.status === CalendarAttendance.NotAttending).length,
};
});
const statusColor = (status: CalendarAttendance) => {
switch (status) {
case CalendarAttendance.Attending:
return "text-success";
case CalendarAttendance.Maybe:
return "text-yellow-600";
case CalendarAttendance.NotAttending:
return "text-destructive";
default:
return "";
}
};
const displayStatus = (status: CalendarAttendance) => {
switch (status) {
case CalendarAttendance.Attending:
return "Attending";
case CalendarAttendance.Maybe:
return "Maybe";
case CalendarAttendance.NotAttending:
return "Declined";
default:
return status;
}
};
defineExpose({ forceReload })
</script> </script>
<template> <template>
<div v-if="loaded"> <div v-if="loaded">
<!-- Header --> <!-- Header -->
<div class="flex items-center justify-between gap-3 border-b px-4 py-3"> <div class="flex items-center justify-between gap-3 border-b px-4 py-3 h-14">
<h2 class="text-lg font-semibold break-all"> <h2 class="text-lg font-semibold break-all">
{{ activeEvent?.name || 'Event' }} {{ activeEvent?.name || 'Event' }}
</h2> </h2>
<div class="flex gap-4"> <div class="flex gap-4 items-center">
<DropdownMenu v-if="canEditEvent"> <DropdownMenu v-if="canEditEvent">
<DropdownMenuTrigger> <DropdownMenuTrigger>
<button <button
@@ -119,8 +191,7 @@ defineExpose({forceReload})
<DropdownMenuItem @click="emit('edit', activeEvent)"> <DropdownMenuItem @click="emit('edit', activeEvent)">
Edit Edit
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem v-if="activeEvent.cancelled" <DropdownMenuItem v-if="activeEvent.cancelled" @click="setCancel(false)">
@click="setCancel(false)">
Un-Cancel Un-Cancel
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem v-else @click="setCancel(true)"> <DropdownMenuItem v-else @click="setCancel(true)">
@@ -142,7 +213,7 @@ defineExpose({forceReload})
<CircleAlert></CircleAlert> This event has been cancelled <CircleAlert></CircleAlert> This event has been cancelled
</div> </div>
</section> </section>
<section class="w-full"> <section v-if="isPast && user.isLoggedIn" class="w-full">
<ButtonGroup class="flex w-full"> <ButtonGroup class="flex w-full">
<Button variant="outline" <Button variant="outline"
:class="myAttendance?.status === CalendarAttendance.Attending ? 'border-2 border-primary text-primary' : ''" :class="myAttendance?.status === CalendarAttendance.Attending ? 'border-2 border-primary text-primary' : ''"
@@ -155,24 +226,21 @@ defineExpose({forceReload})
@click="setAttendance(CalendarAttendance.NotAttending)">Declined</Button> @click="setAttendance(CalendarAttendance.NotAttending)">Declined</Button>
</ButtonGroup> </ButtonGroup>
</section> </section>
<!-- When --> <!-- Meta -->
<section v-if="whenText" class="space-y-2 w-full"> <section class="space-y-3 w-full">
<div class="inline-flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-sm"> <p class="text-lg font-semibold">Details</p>
<Clock class="size-4 opacity-80" /> <div class="text-foreground/80 flex gap-3 items-center">
<span class="font-medium">{{ whenText }}</span> <Calendar :size="20"></Calendar> {{ dateText }}
</div>
<div class="text-foreground/80 flex gap-3 items-center">
<Clock4 :size="20"></Clock4> {{ timeText[0] }} - {{ timeText[1] }}
</div>
<div class="text-foreground/80 flex gap-3 items-center">
<MapPin :size="20"></MapPin> {{ activeEvent.location || "Unknown" }}
</div>
<div class="text-foreground/80 flex gap-3 items-center">
<User :size="20"></User> {{ activeEvent.creator_name || "Unknown User" }}
</div> </div>
</section>
<!-- Quick meta chips -->
<section class="flex flex-wrap gap-2 w-full">
<span class="inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs">
<MapPin class="size-3.5 opacity-80" />
<span class="font-medium">{{ activeEvent.location || "Unknown" }}</span>
</span>
<span class="inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs">
<User class="size-3.5 opacity-80" />
<span class="font-medium">Created by: {{ activeEvent.creator_name || "Unknown User"
}}</span>
</span>
</section> </section>
<!-- Description --> <!-- Description -->
<section class="space-y-2 w-full"> <section class="space-y-2 w-full">
@@ -181,46 +249,41 @@ defineExpose({forceReload})
{{ activeEvent.description }} {{ activeEvent.description }}
</p> </p>
</section> </section>
<!-- Attendance --> <!-- attendance -->
<section class="space-y-2 w-full"> <section class="space-y-2 w-full">
<p class="text-lg font-semibold">Attendance</p> <div class="flex items-center gap-5">
<p class="text-lg font-semibold">Attendance</p>
<!-- <div class="text-muted-foreground flex gap-6">
<p>Going <span class="ml-1">{{ attendanceStatusSummary.attending }}</span></p>
<p>Maybe <span class="ml-1">{{ attendanceStatusSummary.maybe }}</span></p>
<p>Declined <span class="ml-1">{{ attendanceStatusSummary.notAttending }}</span></p>
</div> -->
</div>
<div class="flex flex-col border bg-muted/50 rounded-lg min-h-24 my-2"> <div class="flex flex-col border bg-muted/50 rounded-lg min-h-24 my-2">
<div class="flex w-full pt-2 border-b *:w-full *:text-center *:pb-1 *:cursor-pointer"> <div class="flex w-full pt-2 border-b *:w-full *:text-center *:pb-1 *:cursor-pointer">
<label <label :class="attendanceTab === 'Alpha' ? 'border-b-3 border-foreground' : 'mb-[2px]'"
:class="viewedState === CalendarAttendance.Attending ? 'border-b-3 border-foreground' : 'mb-[2px]'" @click="attendanceTab = 'Alpha'">Alpha {{ attendanceCountsByGroup.Alpha }}</label>
@click="viewedState = CalendarAttendance.Attending">Going {{ attending.length }}</label> <label :class="attendanceTab === 'Echo' ? 'border-b-3 border-foreground' : 'mb-[2px]'"
<label @click="attendanceTab = 'Echo'">Echo {{ attendanceCountsByGroup.Echo }}</label>
:class="viewedState === CalendarAttendance.Maybe ? 'border-b-3 border-foreground' : 'mb-[2px]'" <label :class="attendanceTab === 'Other' ? 'border-b-3 border-foreground' : 'mb-[2px]'"
@click="viewedState = CalendarAttendance.Maybe">Maybe {{ maybe.length }}</label> @click="attendanceTab = 'Other'">Other {{ attendanceCountsByGroup.Other }}</label>
<label
:class="viewedState === CalendarAttendance.NotAttending ? 'border-b-3 border-foreground' : 'mb-[2px]'"
@click="viewedState = CalendarAttendance.NotAttending">Declined {{ declined.length
}}</label>
</div> </div>
<div class="px-5 py-4 min-h-28"> <div class="pb-1 min-h-48">
<div v-if="viewedState === CalendarAttendance.Attending" v-for="person in attending"> <div class="grid grid-cols-2 font-semibold text-muted-foreground border-b py-1 px-3 mb-2">
<p>{{ person.member_name }}</p> <p>Name</p>
<p class="text-right">Status</p>
</div> </div>
<div v-if="viewedState === CalendarAttendance.Maybe" v-for="person in maybe">
<p>{{ person.member_name }}</p> <div v-for="person in attendanceList" :key="person.member_id"
</div> class="grid grid-cols-2 py-1 *:px-3 hover:bg-muted">
<div v-if="viewedState === CalendarAttendance.NotAttending" v-for="person in declined">
<p>{{ person.member_name }}</p> <p>{{ person.member_name }}</p>
<p :class="statusColor(person.status)" class="text-right">
{{ displayStatus(person.status) }}
</p>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
</div> </div>
<!-- Footer (optional actions) -->
<!-- <div class="border-t px-4 py-3 flex items-center justify-end gap-2">
<button class="rounded-md border px-3 py-1.5 text-sm hover:bg-muted/40 transition">
Edit
</button>
<button
class="rounded-md bg-primary text-primary-foreground px-3 py-1.5 text-sm hover:opacity-90 transition">
Open details
</button>
</div> -->
</div> </div>
</template> </template>

View File

@@ -20,10 +20,12 @@ const app = createApp(App)
app.use(createPinia()) app.use(createPinia())
app.use(router) app.use(router)
if (!import.meta.env.VITE_DISABLE_GLITCHTIP) { if (import.meta.env.VITE_DISABLE_GLITCHTIP === "true") {
console.log("Glitchtip disabled");
} else {
let dsn = import.meta.env.VITE_GLITCHTIP_DSN; let dsn = import.meta.env.VITE_GLITCHTIP_DSN;
let environment = import.meta.env.VITE_ENVIRONMENT; let environment = import.meta.env.VITE_ENVIRONMENT;
let release = import.meta.env.VITE_APPLICATION_VERSION;
Sentry.init({ Sentry.init({
app, app,
dsn: dsn, dsn: dsn,
@@ -32,7 +34,7 @@ if (!import.meta.env.VITE_DISABLE_GLITCHTIP) {
], ],
tracesSampleRate: 0.01, tracesSampleRate: 0.01,
environment: environment, environment: environment,
release: 'release tag' release: release
}); });
} }

View File

@@ -2,16 +2,14 @@
import ApplicationChat from '@/components/application/ApplicationChat.vue'; import ApplicationChat from '@/components/application/ApplicationChat.vue';
import ApplicationForm from '@/components/application/ApplicationForm.vue'; import ApplicationForm from '@/components/application/ApplicationForm.vue';
import { onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
import { approveApplication, denyApplication, loadApplication, postApplication, postChatMessage, getMyApplication, postAdminChatMessage } from '@/api/application'; import { ApplicationData, approveApplication, denyApplication, loadApplication, postApplication, postChatMessage, ApplicationStatus } from '@/api/application';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import Button from '@/components/ui/button/Button.vue'; import Button from '@/components/ui/button/Button.vue';
import { CheckIcon, XIcon } from 'lucide-vue-next'; import { CheckIcon, XIcon } from 'lucide-vue-next';
import Unauthorized from './Unauthorized.vue';
import { ApplicationData, ApplicationFull, ApplicationStatus, CommentRow } from '@shared/types/application';
const appData = ref<ApplicationData>(null); const appData = ref<ApplicationData>(null);
const appID = ref<number | null>(null); const appID = ref<number | null>(null);
const chatData = ref<CommentRow[]>([]) const chatData = ref<object[]>([])
const readOnly = ref<boolean>(false); const readOnly = ref<boolean>(false);
const newApp = ref<boolean>(null); const newApp = ref<boolean>(null);
const status = ref<ApplicationStatus>(null); const status = ref<ApplicationStatus>(null);
@@ -21,12 +19,13 @@ const loading = ref<boolean>(true);
const member_name = ref<string>(); const member_name = ref<string>();
const props = defineProps<{ const props = defineProps<{
mode?: "create" | "view-self" | "view-recruiter" | "view-self-id" mode?: "create" | "view-self" | "view-recruiter"
}>() }>()
const finalMode = ref<"create" | "view-self" | "view-recruiter" | "view-self-id">("create"); const finalMode = ref<"create" | "view-self" | "view-recruiter">("create");
function loadData(raw: ApplicationFull) { async function loadByID(id: number | string) {
const raw = await loadApplication(id);
const data = raw.application; const data = raw.application;
@@ -41,20 +40,20 @@ function loadData(raw: ApplicationFull) {
readOnly.value = true; readOnly.value = true;
} }
const route = useRoute(); const router = useRoute();
const unauthorized = ref(false);
onMounted(async () => { onMounted(async () => {
//recruiter mode //recruiter mode
if (props.mode === 'view-recruiter') { if (props.mode === 'view-recruiter') {
finalMode.value = 'view-recruiter'; finalMode.value = 'view-recruiter';
loadData(await loadApplication(Number(route.params.id), true)) await loadByID(Number(router.params.id));
} }
//viewer mode //viewer mode
if (props.mode === 'view-self') { if (props.mode === 'view-self') {
finalMode.value = 'view-self'; finalMode.value = 'view-self';
loadData(await loadApplication("me")) await loadByID('me');
} }
//creator mode //creator mode
@@ -65,33 +64,40 @@ onMounted(async () => {
newApp.value = true; newApp.value = true;
} }
if (props.mode === 'view-self-id') {
finalMode.value = 'view-self-id';
try {
let raw = await getMyApplication(Number(route.params.id))
loadData(raw);
unauthorized.value = false;
} catch (error) {
if (error.message === "Unauthorized") {
unauthorized.value = true;
} else {
console.error(error);
}
}
}
loading.value = false; loading.value = false;
// try {
// //get app ID from URL param
// if (appIDRaw === undefined) {
// //new app
// appData.value = null
// readOnly.value = false;
// newApp.value = true;
// } else {
// //load app
// const raw = await loadApplication(appIDRaw.toString());
// const data = raw.application;
// appID.value = data.id;
// appData.value = data.app_data;
// chatData.value = raw.comments;
// status.value = data.app_status;
// decisionDate.value = new Date(data.decision_at);
// submitDate.value = data.submitted_at ? new Date(data.submitted_at) : null;
// member_name.value = data.member_name;
// newApp.value = false;
// readOnly.value = true;
// }
// } catch (e) {
// console.error(e);
// }
}) })
async function postComment(comment) { async function postComment(comment) {
chatData.value.push(await postChatMessage(comment, appID.value)); chatData.value.push(await postChatMessage(comment, appID.value));
} }
async function postCommentInternal(comment) {
chatData.value.push(await postAdminChatMessage(comment, appID.value));
}
const emit = defineEmits(['submit']); const emit = defineEmits(['submit']);
async function postApp(appData) { async function postApp(appData) {
@@ -101,7 +107,7 @@ async function postApp(appData) {
newApp.value = false; newApp.value = false;
emit('submit'); emit('submit');
} }
// TODO: Handle fail to post // TODO: Handle fail to post
} }
async function handleApprove(id) { async function handleApprove(id) {
@@ -116,59 +122,52 @@ async function handleDeny(id) {
<template> <template>
<div v-if="!loading" class="w-full h-20"> <div v-if="!loading" class="w-full h-20">
<div v-if="unauthorized" class="flex justify-center w-full my-10"> <div v-if="!newApp" class="flex flex-row justify-between items-center py-2 mb-8">
You do not have permission to view this application. <!-- Application header -->
</div> <div>
<div v-else> <h3 class="scroll-m-20 text-2xl font-semibold tracking-tight">{{ member_name }}</h3>
<div v-if="!newApp" class="flex flex-row justify-between items-center py-2 mb-8"> <p class="text-muted-foreground">Submitted: {{ submitDate.toLocaleString("en-US", {
<!-- Application header --> year: "numeric",
<div> month: "long",
<h3 class="scroll-m-20 text-2xl font-semibold tracking-tight">{{ member_name }}</h3> day: "numeric",
<p class="text-muted-foreground">Submitted: {{ submitDate.toLocaleString("en-US", { hour: "2-digit",
minute: "2-digit"
}) }}</p>
</div>
<div>
<h3 class="text-right" :class="[
'font-semibold',
status === ApplicationStatus.Pending && 'text-yellow-500',
status === ApplicationStatus.Accepted && 'text-green-500',
status === ApplicationStatus.Denied && 'text-red-500'
]">{{ status }}</h3>
<p v-if="status != ApplicationStatus.Pending" class="text-muted-foreground">{{ status }}: {{
decisionDate.toLocaleString("en-US", {
year: "numeric", year: "numeric",
month: "long", month: "long",
day: "numeric", day: "numeric",
hour: "2-digit", hour: "2-digit",
minute: "2-digit" minute: "2-digit"
}) }}</p> }) }}</p>
<div class="mt-2" v-else-if="finalMode === 'view-recruiter'">
<Button variant="success" class="mr-2" :onclick="() => { handleApprove(appID) }">
<CheckIcon></CheckIcon>
</Button>
<Button variant="destructive" :onClick="() => { handleDeny(appID) }">
<XIcon></XIcon>
</Button>
</div> </div>
<div>
<h3 class="text-right" :class="[
'font-semibold',
status === ApplicationStatus.Pending && 'text-yellow-500',
status === ApplicationStatus.Accepted && 'text-green-500',
status === ApplicationStatus.Denied && 'text-red-500'
]">{{ status }}</h3>
<p v-if="status != ApplicationStatus.Pending" class="text-muted-foreground">{{ status }}: {{
decisionDate.toLocaleString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit"
}) }}</p>
<div class="mt-2" v-else-if="finalMode === 'view-recruiter'">
<Button variant="success" class="mr-2" :onclick="() => { handleApprove(appID) }">
<CheckIcon></CheckIcon>
</Button>
<Button variant="destructive" :onClick="() => { handleDeny(appID) }">
<XIcon></XIcon>
</Button>
</div>
</div>
</div>
<div v-else class="flex flex-row justify-between items-center py-2 mb-8">
<h3 class="scroll-m-20 text-2xl font-semibold tracking-tight">Apply to join the 17th Rangers</h3>
</div>
<ApplicationForm :read-only="readOnly" :data="appData" @submit="(e) => { postApp(e) }" class="mb-7">
</ApplicationForm>
<div v-if="!newApp" class="pb-15">
<h3 class="scroll-m-20 text-2xl font-semibold tracking-tight mb-4">Discussion</h3>
<ApplicationChat :messages="chatData" @post="postComment" @post-internal="postCommentInternal">
</ApplicationChat>
</div> </div>
</div> </div>
<div v-else class="flex flex-row justify-between items-center py-2 mb-8">
<h3 class="scroll-m-20 text-2xl font-semibold tracking-tight">Apply to join the 17th Rangers</h3>
</div>
<ApplicationForm :read-only="readOnly" :data="appData" @submit="(e) => { postApp(e) }" class="mb-7">
</ApplicationForm>
<div v-if="!newApp">
<h3 class="scroll-m-20 text-2xl font-semibold tracking-tight mb-4">Discussion</h3>
<ApplicationChat :messages="chatData" @post="postComment"></ApplicationChat>
</div>
</div> </div>
<!-- TODO: Implement some kinda loading screen --> <!-- TODO: Implement some kinda loading screen -->
<div v-else class="flex items-center justify-center h-full">Loading</div> <div v-else class="flex items-center justify-center h-full">Loading</div>

View File

@@ -3,15 +3,14 @@ import { ref, watch, nextTick, computed, onMounted } from 'vue'
import FullCalendar from '@fullcalendar/vue3' import FullCalendar from '@fullcalendar/vue3'
import dayGridPlugin from '@fullcalendar/daygrid' import dayGridPlugin from '@fullcalendar/daygrid'
import interactionPlugin from '@fullcalendar/interaction' import interactionPlugin from '@fullcalendar/interaction'
import { X, Clock, MapPin, User, ListTodo, ChevronLeft, ChevronRight, Plus } from 'lucide-vue-next' import { ChevronLeft, ChevronRight, Plus } from 'lucide-vue-next'
import CreateCalendarEvent from '@/components/calendar/CreateCalendarEvent.vue' import CreateCalendarEvent from '@/components/calendar/CreateCalendarEvent.vue'
import { getCalendarEvent, getMonthCalendarEvents } from '@/api/calendar' import { CalendarEvent } from '@shared/types/calendar'
import { CalendarEvent, CalendarEventShort } from '@shared/types/calendar'
import { Calendar } from '@fullcalendar/core'
import ViewCalendarEvent from '@/components/calendar/ViewCalendarEvent.vue' import ViewCalendarEvent from '@/components/calendar/ViewCalendarEvent.vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { useCalendarEvents } from '@/composables/useCalendarEvents' import { useCalendarEvents } from '@/composables/useCalendarEvents'
import { useCalendarNavigation } from '@/composables/useCalendarNavigation' import { useCalendarNavigation } from '@/composables/useCalendarNavigation'
import { useUserStore } from '@/stores/user'
const monthLabels = [ const monthLabels = [
'January', 'February', 'March', 'April', 'May', 'June', 'January', 'February', 'March', 'April', 'May', 'June',
@@ -24,13 +23,14 @@ function api() {
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
const userStore = useUserStore();
function buildFullDate(month: number, year: number): Date { function buildFullDate(month: number, year: number): Date {
return new Date(year, month, 1); //default to first of month return new Date(year, month, 1); //default to first of month
} }
const { selectedMonth, selectedYear, years, goPrev, goNext, goToday, onDatesSet, goToSelectedDate } = useCalendarNavigation(api) const { selectedMonth, selectedYear, years, goPrev, goNext, goToday, onDatesSet, goToSelectedDate } = useCalendarNavigation(api)
const { events, loadEvents} = useCalendarEvents(selectedMonth, selectedYear); const { events, loadEvents } = useCalendarEvents(selectedMonth, selectedYear);
const panelOpen = ref(false) const panelOpen = ref(false)
const activeEvent = ref<CalendarEvent | null>(null) const activeEvent = ref<CalendarEvent | null>(null)
@@ -48,6 +48,7 @@ const dialogRef = ref<any>(null)
// NEW: handle day/time slot clicks to start creating an event // NEW: handle day/time slot clicks to start creating an event
function onDateClick(arg: { dateStr: string }) { function onDateClick(arg: { dateStr: string }) {
if (!userStore.isLoggedIn) return;
dialogRef.value?.openDialog(arg.dateStr); dialogRef.value?.openDialog(arg.dateStr);
// For now, just open the panel with a draft payload. // For now, just open the panel with a draft payload.
// activeEvent.value = { // activeEvent.value = {
@@ -202,7 +203,7 @@ onMounted(() => {
@click="goToday"> @click="goToday">
Today Today
</button> </button>
<button <button v-if="userStore.isLoggedIn"
class="cursor-pointer ml-1 inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:opacity-90" class="cursor-pointer ml-1 inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:opacity-90"
@click="onCreateEvent"> @click="onCreateEvent">
<Plus class="h-4 w-4" /> <Plus class="h-4 w-4" />
@@ -216,7 +217,8 @@ onMounted(() => {
<aside v-if="panelOpen" <aside v-if="panelOpen"
class="3xl:w-lg 2xl:w-md border-l bg-card text-foreground flex flex-col overflow-auto scrollbar-themed" class="3xl:w-lg 2xl:w-md border-l bg-card text-foreground flex flex-col overflow-auto scrollbar-themed"
:style="{ height: 'calc(100vh - 61px)', position: 'sticky', top: '64px' }"> :style="{ height: 'calc(100vh - 61px)', position: 'sticky', top: '64px' }">
<ViewCalendarEvent ref="eventViewRef" @close="() => { router.push('/calendar'); }" @reload="loadEvents()" @edit="(val) => {dialogRef.openDialog(null, 'edit', val)}"> <ViewCalendarEvent ref="eventViewRef" @close="() => { router.push('/calendar'); }"
@reload="loadEvents()" @edit="(val) => { dialogRef.openDialog(null, 'edit', val) }">
</ViewCalendarEvent> </ViewCalendarEvent>
</aside> </aside>
</div> </div>

View File

@@ -14,7 +14,6 @@ import { useUserStore } from '@/stores/user';
import { Check, Circle, Dot, Users, X } from 'lucide-vue-next' import { Check, Circle, Dot, Users, X } from 'lucide-vue-next'
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import Application from './Application.vue'; import Application from './Application.vue';
import { restartApplication } from '@/api/application';
function goToLogin() { function goToLogin() {
const redirectUrl = encodeURIComponent(window.location.origin + '/join') const redirectUrl = encodeURIComponent(window.location.origin + '/join')
@@ -68,25 +67,14 @@ const currentStep = computed<number>(() => {
case "denied": case "denied":
return 5; return 5;
break; break;
case "retired":
return 5;
break;
} }
}) })
const finalPanel = ref<'app' | 'message'>('message'); const finalPanel = ref<'app' | 'message'>('message');
const reloadKey = ref(0);
async function restartApp() {
await restartApplication();
await userStore.loadUser();
reloadKey.value++;
}
</script> </script>
<template> <template>
<div class="flex flex-col items-center mt-10 w-full" :key="reloadKey"> <div class="flex flex-col items-center mt-10 w-full">
<!-- Stepper Container --> <!-- Stepper Container -->
<div class="w-full flex justify-center"> <div class="w-full flex justify-center">
@@ -183,9 +171,9 @@ async function restartApp() {
<li>When prompted, choose <em>“Yes”</em> to download all associated mods.</li> <li>When prompted, choose <em>“Yes”</em> to download all associated mods.</li>
</ul> </ul>
<p> <p>
<a href="https://www.guilded.gg/Iceberg-gaming/groups/v3j2vAP3/channels/6979335e-60f7-4ab9-9590-66df69367d1e/docs/2013948655" <a href="https://docs.iceberg-gaming.com/books/member-guides/page/new-member-setup-onboarding"
class="text-primary underline" target="_blank"> class="text-primary underline" target="_blank">
Click here for the full installation guide Click here for the full installation guide (Requires Sign-in)
</a> </a>
</p> </p>
<!-- CONTACT SECTION --> <!-- CONTACT SECTION -->
@@ -223,7 +211,7 @@ async function restartApp() {
our forums and introduce yourself. our forums and introduce yourself.
</p> </p>
<p> <p>
If you have any questions, feel free to reach out on TeamSpeak, Discord, or Guilded, If you have any questions, feel free to reach out on TeamSpeak or Discord
someone someone
will always be around to help. will always be around to help.
</p> </p>
@@ -231,8 +219,8 @@ async function restartApp() {
</div> </div>
<!-- Denied message --> <!-- Denied message -->
<div v-else-if="userStore.state === 'denied'"> <div v-else-if="userStore.state === 'denied'">
<div class="w-full max-w-2xl flex flex-col gap-8"> <div class="w-full max-w-2xl p-8">
<h1 class="text-3xl sm:text-4xl font-bold text-left"> <h1 class="text-3xl sm:text-4xl font-bold mb-4 text-left text-destructive">
Application Not Approved Application Not Approved
</h1> </h1>
<div class="space-y-4 text-muted-foreground text-left leading-relaxed"> <div class="space-y-4 text-muted-foreground text-left leading-relaxed">
@@ -258,39 +246,6 @@ async function restartApp() {
Team</span> Team</span>
</p> </p>
</div> </div>
<Button class="w-min" @click="restartApp">New Application</Button>
</div>
</div>
<div v-else-if="userStore.state === 'retired'">
<div class="w-full max-w-2xl flex flex-col gap-8">
<h1 class="text-3xl sm:text-4xl font-bold text-left">
You have retired from the 17th Ranger Battalion
</h1>
<div class="space-y-4 text-muted-foreground text-left leading-relaxed">
<p>
Thank you for your service and participation in the <strong>17th Ranger
Battalion</strong>.
Your time with us has been sincerely appreciated.
</p>
<p>
Should you ever wish to return, you are welcome to <strong>reach out to our
leadership
team</strong>
for guidance on the reinstatement process or to stay connected with the community.
</p>
<p>
We recognize that circumstances change, and you will always have a place to
reconnect with
us
should the opportunity arise in the future.
</p>
<p>
All the best,<br />
<span class="text-foreground font-medium">The 17th Ranger Battalion Leadership
Team</span>
</p>
</div>
<Button class="w-min" @click="restartApp">New Application</Button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,5 @@
<script setup> <script setup>
import { getAllApplications, approveApplication, denyApplication } from '@/api/application'; import { getAllApplications, approveApplication, denyApplication, ApplicationStatus } from '@/api/application';
import { ApplicationStatus } from '@shared/types/application'
import { import {
Table, Table,
TableBody, TableBody,

View File

@@ -1,148 +0,0 @@
<script setup>
import { loadMyApplications } from '@/api/application';
import { ApplicationStatus } from '@shared/types/application';
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import Button from '@/components/ui/button/Button.vue';
import { onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { CheckIcon, XIcon } from 'lucide-vue-next';
import Application from './Application.vue';
const appList = ref([]);
const now = Date.now();
// relative time formatter (uses user locale)
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
// exact date/time for tooltip
const exactFmt = new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium', timeStyle: 'short', timeZone: 'America/Toronto'
})
function formatAgo(iso) {
const d = new Date(iso)
if (isNaN(d)) return ''
let diff = (d.getTime() - now) / 1000 // seconds relative to page load
const divisions = [
{ amount: 60, name: 'second' },
{ amount: 60, name: 'minute' },
{ amount: 24, name: 'hour' },
{ amount: 7, name: 'day' },
{ amount: 4.34524, name: 'week' }, // avg weeks per month
{ amount: 12, name: 'month' },
{ amount: Infinity, name: 'year' },
]
for (const div of divisions) {
if (Math.abs(diff) < div.amount) {
return rtf.format(Math.round(diff), div.name)
}
diff /= div.amount
}
}
function formatExact(iso) {
const d = new Date(iso)
return isNaN(d) ? '' : exactFmt.format(d)
}
const router = useRouter();
function openApplication(id) {
router.push(`/applications/${id}`)
openPanel.value = true;
}
const route = useRoute();
watch(() => route.params.id, (newId) => {
if (newId === undefined) {
openPanel.value = false;
}
})
const openPanel = ref(false);
onMounted(async () => {
appList.value = await loadMyApplications();
//preload application
if (route.params.id != undefined) {
openApplication(route.params.id)
} else {
}
})
</script>
<template>
<div class="px-20 mx-auto max-w-[100rem] w-full flex mt-5 h-52 min-h-0 overflow-hidden">
<!-- application list -->
<div :class="openPanel == false ? 'w-full' : 'w-2/5'" class="pr-9">
<h1 class="scroll-m-20 text-2xl font-semibold tracking-tight mb-5">My Applications</h1>
<Table>
<TableHeader>
<TableRow>
<TableHead>Date Submitted</TableHead>
<TableHead class="text-right">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody class="overflow-y-auto scrollbar-themed">
<TableRow v-for="app in appList" :key="app.id" class="cursor-pointer"
:onClick="() => { openApplication(app.id) }">
<TableCell :title="formatExact(app.submitted_at)">
{{ formatAgo(app.submitted_at) }}
</TableCell>
<TableCell class="text-right font-semibold" :class="[
,
app.app_status === ApplicationStatus.Pending && 'text-yellow-500',
app.app_status === ApplicationStatus.Accepted && 'text-green-500',
app.app_status === ApplicationStatus.Denied && 'text-destructive'
]">{{ app.app_status }}</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<div v-if="openPanel" class="pl-9 border-l w-3/5" :key="$route.params.id">
<div class="mb-5 flex justify-between">
<p class="scroll-m-20 text-2xl font-semibold tracking-tight"> Application</p>
</div>
<div class="overflow-y-auto max-h-[80vh] h-full mt-5 scrollbar-themed">
<Application :mode="'view-self-id'"></Application>
</div>
</div>
</div>
</template>
<style scoped>
/* Firefox */
.scrollbar-themed {
scrollbar-width: thin;
scrollbar-color: #555 #1f1f1f;
padding-right: 6px;
}
/* Chrome, Edge, Safari */
.scrollbar-themed::-webkit-scrollbar {
width: 10px;
/* slightly wider to allow padding look */
}
.scrollbar-themed::-webkit-scrollbar-track {
background: #1f1f1f;
margin-left: 6px;
/* ❗ adds space between content + scrollbar */
}
.scrollbar-themed::-webkit-scrollbar-thumb {
background: #555;
border-radius: 9999px;
}
.scrollbar-themed::-webkit-scrollbar-thumb:hover {
background: #777;
}
</style>

View File

@@ -6,20 +6,19 @@ const router = createRouter({
routes: [ routes: [
// PUBLIC // PUBLIC
{ path: '/join', component: () => import('@/pages/Join.vue') }, { path: '/join', component: () => import('@/pages/Join.vue') },
{ path: '/applications', component: () => import('@/pages/MyApplications.vue'), meta: { requiresAuth: true } },
{ path: '/applications/:id', component: () => import('@/pages/MyApplications.vue'), meta: { requiresAuth: true } },
// AUTH REQUIRED // AUTH REQUIRED
{ path: '/apply', component: () => import('@/pages/Application.vue'), meta: { requiresAuth: true } },
{ path: '/', component: () => import('@/pages/Homepage.vue') }, { path: '/', component: () => import('@/pages/Homepage.vue') },
// MEMBER ROUTES // MEMBER ROUTES
{ path: '/members', component: () => import('@/pages/memberList.vue'), meta: { requiresAuth: true, memberOnly: true } }, { path: '/members', component: () => import('@/pages/memberList.vue'), meta: { requiresAuth: true, memberOnly: true } },
{ path: '/loa', component: () => import('@/pages/SubmitLOA.vue'), meta: { requiresAuth: true, memberOnly: true } }, { path: '/loa', component: () => import('@/pages/SubmitLOA.vue'), meta: { requiresAuth: true, memberOnly: true } },
{ path: '/transfer', component: () => import('@/pages/Transfer.vue'), meta: { requiresAuth: true, memberOnly: true } }, { path: '/transfer', component: () => import('@/pages/Transfer.vue'), meta: { requiresAuth: true, memberOnly: true } },
{ path: '/calendar', component: () => import('@/pages/Calendar.vue'), meta: { requiresAuth: true, memberOnly: true }, }, { path: '/calendar', component: () => import('@/pages/Calendar.vue'), meta: { }, },
{ path: '/calendar/event/:id', component: () => import('@/pages/Calendar.vue'), meta: { requiresAuth: true, memberOnly: true }, }, { path: '/calendar/event/:id', component: () => import('@/pages/Calendar.vue'), meta: { }, },
{ path: '/trainingReport', component: () => import('@/pages/TrainingReport.vue'), meta: { requiresAuth: true, memberOnly: true } }, { path: '/trainingReport', component: () => import('@/pages/TrainingReport.vue'), meta: { requiresAuth: true, memberOnly: true } },
{ path: '/trainingReport/new', component: () => import('@/pages/TrainingReport.vue'), meta: { requiresAuth: true, memberOnly: true } }, { path: '/trainingReport/new', component: () => import('@/pages/TrainingReport.vue'), meta: { requiresAuth: true, memberOnly: true } },
{ path: '/trainingReport/:id', component: () => import('@/pages/TrainingReport.vue'), meta: { requiresAuth: true, memberOnly: true } }, { path: '/trainingReport/:id', component: () => import('@/pages/TrainingReport.vue'), meta: { requiresAuth: true, memberOnly: true } },