5 Commits

116 changed files with 5199 additions and 5996 deletions

View File

@@ -1,62 +0,0 @@
name: Continuous Deployment
on:
push:
jobs:
Deploy:
name: Update Deployment
runs-on: ubuntu-latest
container:
volumes:
- /var/www/html/milsim-site-v4:/var/www/html/milsim-site-v4:rw
steps:
- name: Setup Local Environment
run: |
groupadd -g 989 nginx || true
useradd nginx -u 990 -g nginx -m || true
- name: Verify Node Environment
run: |
npm -v
node -v
- name: Checkout
uses: actions/checkout@v4
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: 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
run: |
sudo -u nginx bash -c "cd /var/www/html/milsim-site-v4 && git reset --hard && git pull origin main"
- name: Update Shared Dependencies
run: |
sudo -u nginx bash -c "cd /var/www/html/milsim-site-v4/shared && npm install"
- name: Update UI Dependencies
run: |
sudo -u nginx bash -c "cd /var/www/html/milsim-site-v4/ui && npm install"
- name: Update API Dependencies
run: |
sudo -u nginx bash -c "cd /var/www/html/milsim-site-v4/api && npm install"
- name: Build UI
run: |
sudo -u nginx bash -c "cd /var/www/html/milsim-site-v4/ui && npm run build"
- name: Build API
run: |
sudo -u nginx bash -c "cd /var/www/html/milsim-site-v4/api && npm run build"

4
.gitignore vendored
View File

@@ -31,4 +31,6 @@ coverage
*.sql *.sql
.env .env
*.db *.db
db_data

12
api/.env.development Normal file
View File

@@ -0,0 +1,12 @@
# DATABASE SETTINGS
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=ranger_unit_tracker
DB_USERNAME=dev
DB_PASSWORD=dev
# AUTH SETTINGS
AUTH_MODE=mock # mock bypasses authentik
# SERVER SETTINGS
SERVER_PORT=3000

View File

@@ -1,25 +0,0 @@
# DATABASE SETTINGS
DB_HOST=
DB_PORT=
DB_DATABASE=
DB_USERNAME=
DB_PASSWORD=
# AUTH SETTINGS
AUTH_DOMAIN=
AUTH_ISSUER=
AUTH_CLIENT_ID=
AUTH_CLIENT_SECRET=
AUTH_REDIRECT_URI=
AUTH_REVOCATION_URI=
AUTH_END_SESSION_URI=
# AUTH_MODE=mock #uncomment this to bypass authentik
# SERVER SETTINGS
SERVER_PORT=3000
CLIENT_URL= # This is whatever URL the client web app is served on
CLIENT_DOMAIN= #whatever.com
# Glitchtip
GLITCHTIP_DSN=
DISABLE_GLITCHTIP= # true/false

4
api/.gitignore vendored
View File

@@ -1 +1,3 @@
built built
!migrations/*.sql

View File

@@ -0,0 +1,609 @@
-- --------------------------------------------------------
-- Host: gs.iceberg-gaming.com
-- Server version: 10.6.5-MariaDB - mariadb.org binary distribution
-- Server OS: Win64
-- HeidiSQL Version: 12.12.0.7122
-- --------------------------------------------------------
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET NAMES utf8 */;
/*!50503 SET NAMES utf8mb4 */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
-- Dumping database structure for ranger_unit_tracker
CREATE DATABASE IF NOT EXISTS `ranger_unit_tracker` /*!40100 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci */;
USE `ranger_unit_tracker`;
-- Dumping structure for table ranger_unit_tracker.applications
CREATE TABLE IF NOT EXISTS `applications` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`member_id` int(11) NOT NULL,
`app_version` int(11) NOT NULL,
`app_data` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL CHECK (json_valid(`app_data`)),
`submitted_at` datetime NOT NULL DEFAULT current_timestamp(),
`updated_at` datetime DEFAULT NULL,
`approved_at` datetime DEFAULT NULL,
`denied_at` datetime DEFAULT NULL,
`app_status` varchar(20) GENERATED ALWAYS AS (case when `approved_at` is not null then 'Accepted' when `denied_at` is not null then 'Denied' else 'Pending' end) STORED,
`decision_at` datetime GENERATED ALWAYS AS (coalesce(`approved_at`,`denied_at`)) STORED,
PRIMARY KEY (`id`),
UNIQUE KEY `member_id` (`member_id`),
CONSTRAINT `fk_app_member` FOREIGN KEY (`member_id`) REFERENCES `members` (`id`) ON UPDATE CASCADE,
CONSTRAINT `chk_json_valid` CHECK (json_valid(`app_data`)),
CONSTRAINT `chk_app_version` CHECK (`app_version` >= 1),
CONSTRAINT `chk_exclusive_decision` CHECK (`approved_at` is null or `denied_at` is null)
) ENGINE=InnoDB AUTO_INCREMENT=9 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.application_comments
CREATE TABLE IF NOT EXISTS `application_comments` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`post_content` text COLLATE utf8mb4_unicode_ci NOT NULL,
`application_id` int(11) NOT NULL,
`poster_id` int(11) NOT NULL,
`post_time` timestamp NOT NULL DEFAULT current_timestamp(),
`last_modified` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00',
PRIMARY KEY (`id`),
KEY `poster_id` (`poster_id`),
KEY `application_id` (`application_id`),
CONSTRAINT `application_comments_ibfk_1` FOREIGN KEY (`application_id`) REFERENCES `applications` (`id`),
CONSTRAINT `application_comments_ibfk_2` FOREIGN KEY (`poster_id`) REFERENCES `members` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.arma_maps
CREATE TABLE IF NOT EXISTS `arma_maps` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`map` varchar(50) DEFAULT NULL,
`name` text DEFAULT NULL,
`last_used` datetime DEFAULT NULL,
`deleted` tinyint(4) DEFAULT 0,
`workshop_id` varchar(100) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `map` (`map`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COMMENT='Contains a list of Arma3 Maps';
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.awards
CREATE TABLE IF NOT EXISTS `awards` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` longtext DEFAULT NULL,
`short_name` longtext DEFAULT NULL,
`description` longtext DEFAULT NULL,
`type` longtext DEFAULT NULL,
`footprint` longtext DEFAULT NULL,
`created_at` datetime(3) NOT NULL,
`updated_at` datetime(3) NOT NULL,
`image_url` longtext DEFAULT NULL,
`deleted` tinyint(1) NOT NULL DEFAULT 0,
`deleted_at` datetime(3) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_awards_deleted` (`deleted`)
) ENGINE=InnoDB AUTO_INCREMENT=81 DEFAULT CHARSET=utf8mb4 COMMENT='Contains a list of Awards for the unit.';
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.calendar_events
CREATE TABLE IF NOT EXISTS `calendar_events` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`start` datetime NOT NULL,
`end` datetime NOT NULL,
`location` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`color` char(7) COLLATE utf8mb4_unicode_ci NOT NULL,
`description` text COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`creator` int(11) DEFAULT NULL,
`cancelled` tinyint(1) DEFAULT 0,
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`id`),
KEY `fk_creator` (`creator`),
CONSTRAINT `fk_creator` FOREIGN KEY (`creator`) REFERENCES `members` (`id`) ON DELETE SET NULL ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.calendar_events_signups
CREATE TABLE IF NOT EXISTS `calendar_events_signups` (
`member_id` int(11) NOT NULL,
`event_id` int(11) NOT NULL,
`status` enum('not_attending','attending','maybe') COLLATE utf8mb4_unicode_ci NOT NULL,
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`member_id`,`event_id`),
KEY `fk_signup_event` (`event_id`),
CONSTRAINT `fk_signup_event` FOREIGN KEY (`event_id`) REFERENCES `calendar_events` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_signup_member` FOREIGN KEY (`member_id`) REFERENCES `members` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.courses
CREATE TABLE IF NOT EXISTS `courses` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(100) NOT NULL,
`short_name` varchar(10) NOT NULL,
`category` varchar(100) NOT NULL,
`description` varchar(1000) DEFAULT NULL,
`image_url` varchar(255) DEFAULT NULL,
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
`updated_at` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
`deleted` tinyint(4) DEFAULT 0,
`prereq_id` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `name` (`name`),
UNIQUE KEY `shortName` (`short_name`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=39 DEFAULT CHARSET=utf8mb4;
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.courses_single
CREATE TABLE IF NOT EXISTS `courses_single` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(100) NOT NULL,
`short_name` varchar(10) NOT NULL,
`category` varchar(100) NOT NULL,
`description` varchar(1000) DEFAULT NULL,
`image_url` varchar(255) DEFAULT NULL,
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
`updated_at` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
`deleted` tinyint(4) DEFAULT 0,
`prereq_id` int(11) DEFAULT NULL,
`require_qual` tinyint(4) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `name` (`name`),
UNIQUE KEY `shortName` (`short_name`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=39 DEFAULT CHARSET=utf8mb4;
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.courses_sme
CREATE TABLE IF NOT EXISTS `courses_sme` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`course_id` int(11) NOT NULL DEFAULT 0,
`member_id` int(11) NOT NULL DEFAULT 0,
`created_at` datetime DEFAULT current_timestamp(),
`updated_at` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`id`),
KEY `fk_course_sme_course_id` (`course_id`),
KEY `fk_course_sme_member_id` (`member_id`),
CONSTRAINT `fk_course_sme_course_id` FOREIGN KEY (`course_id`) REFERENCES `courses` (`id`) ON UPDATE CASCADE,
CONSTRAINT `fk_course_sme_member_id` FOREIGN KEY (`member_id`) REFERENCES `members` (`id`) ON UPDATE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4;
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.course_attendees
CREATE TABLE IF NOT EXISTS `course_attendees` (
`passed` tinyint(1) DEFAULT 0,
`attendee_id` int(11) NOT NULL,
`course_event_id` int(11) NOT NULL,
`attendee_role_id` int(11) DEFAULT NULL,
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
`updated_at` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`attendee_id`,`course_event_id`) USING BTREE,
KEY `courseInstanceId` (`course_event_id`) USING BTREE,
KEY `fk_CourseInstancesMembers_CoureseAttendeeType_id` (`attendee_role_id`) USING BTREE,
CONSTRAINT `fk_course_event_member_coures_attendee_type_id` FOREIGN KEY (`attendee_role_id`) REFERENCES `course_attendee_roles` (`id`) ON UPDATE CASCADE,
CONSTRAINT `fk_course_event_members_coures_event_id` FOREIGN KEY (`course_event_id`) REFERENCES `course_events` (`id`) ON UPDATE CASCADE,
CONSTRAINT `fk_course_event_members_members_id` FOREIGN KEY (`attendee_id`) REFERENCES `members` (`id`) ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.course_attendee_roles
CREATE TABLE IF NOT EXISTS `course_attendee_roles` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(50) DEFAULT NULL,
`description` text DEFAULT NULL,
`created_at` datetime DEFAULT current_timestamp(),
`updated_at` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(),
`deleted` tinyint(4) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `type` (`name`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4 COMMENT='Changed from course_attendee_type to event_attendee_type';
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.course_category
CREATE TABLE IF NOT EXISTS `course_category` (
`id` int(11) DEFAULT NULL,
`name` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.course_events
CREATE TABLE IF NOT EXISTS `course_events` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`course_id` int(11) DEFAULT NULL,
`event_type` int(11) DEFAULT NULL,
`event_date` datetime NOT NULL,
`guilded_event_id` int(11) DEFAULT NULL,
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
`updated_at` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
`deleted` tinyint(4) DEFAULT 0,
`report_url` varchar(2048) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `fk_course_events_event_type_id` (`event_type`) USING BTREE,
KEY `courseId` (`course_id`) USING BTREE,
CONSTRAINT `fk_coures_events_course_id` FOREIGN KEY (`course_id`) REFERENCES `courses` (`id`) ON UPDATE CASCADE,
CONSTRAINT `fk_course_events_event_type_id` FOREIGN KEY (`event_type`) REFERENCES `event_types` (`id`) ON UPDATE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4;
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.course_qualified_trainers
CREATE TABLE IF NOT EXISTS `course_qualified_trainers` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`member_id` int(11) DEFAULT NULL,
`course_id` int(11) DEFAULT NULL,
`created_at` datetime DEFAULT current_timestamp(),
`updated_at` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(),
`qualified` tinyint(4) DEFAULT NULL,
`instance_qualified_id` int(11) DEFAULT NULL,
`deleted` tinyint(4) DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `memberId_courseId` (`member_id`,`course_id`) USING BTREE,
KEY `fk_coures_qualified_trainers_coures_id` (`course_id`) USING BTREE,
KEY `fk_CourseTrainers_CourseInstance_id` (`instance_qualified_id`) USING BTREE,
CONSTRAINT `fk_coures_qualified_trainers_coures_events_id` FOREIGN KEY (`instance_qualified_id`) REFERENCES `course_events` (`id`) ON UPDATE CASCADE,
CONSTRAINT `fk_coures_qualified_trainers_coures_id` FOREIGN KEY (`course_id`) REFERENCES `courses` (`id`) ON UPDATE CASCADE,
CONSTRAINT `fk_coures_qualified_trainers_mebers_id` FOREIGN KEY (`member_id`) REFERENCES `members` (`id`) ON UPDATE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COMMENT='Contains a linked group of qualified trainers';
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.event_types
CREATE TABLE IF NOT EXISTS `event_types` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`event_type` varchar(100) DEFAULT NULL,
`event_category` varchar(100) DEFAULT NULL,
`deleted` tinyint(4) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=9 DEFAULT CHARSET=utf8mb4;
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.guilded_events
CREATE TABLE IF NOT EXISTS `guilded_events` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`channel_id` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`event_id` varchar(10) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`name` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`event_type` int(11) DEFAULT NULL,
`event_date` datetime DEFAULT NULL,
`created_at` datetime DEFAULT current_timestamp(),
`updated_at` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(),
`deleted` int(11) DEFAULT NULL,
`url` varchar(2048) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `calendar_id_event_id` (`channel_id`,`event_id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=3267 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.leave_of_absences
CREATE TABLE IF NOT EXISTS `leave_of_absences` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`member_id` int(11) DEFAULT NULL,
`filed_date` datetime NOT NULL,
`start_date` datetime NOT NULL,
`end_date` datetime NOT NULL,
`reason` text DEFAULT NULL,
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
`updated_at` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
`deleted` tinyint(4) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `fk_leave_of_absesnse_members_id` (`member_id`) USING BTREE,
CONSTRAINT `fk_leave_of_absesnse_members_id` FOREIGN KEY (`member_id`) REFERENCES `members` (`id`) ON UPDATE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=14 DEFAULT CHARSET=utf8mb4;
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.members
CREATE TABLE IF NOT EXISTS `members` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(100) NOT NULL,
`timezone` varchar(5) DEFAULT NULL,
`email` varchar(100) DEFAULT NULL,
`website` varchar(240) DEFAULT NULL,
`guilded_id` varchar(10) DEFAULT NULL,
`steam_id_64` varchar(17) DEFAULT NULL,
`teamspeak_uid` varchar(32) DEFAULT NULL,
`steam_profile_name` varchar(32) DEFAULT NULL,
`discord_id` varchar(20) DEFAULT NULL,
`discord_username` varchar(32) DEFAULT NULL,
`aliases` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`aliases`)),
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
`updated_at` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
`deleted` tinyint(4) DEFAULT NULL,
`remarks` text DEFAULT NULL,
`authentik_sub` varchar(255) DEFAULT NULL,
`authentik_issuer` varchar(255) DEFAULT NULL,
`state` enum('guest','applicant','member','retired','banned','denied') NOT NULL DEFAULT 'guest',
PRIMARY KEY (`id`),
UNIQUE KEY `name` (`name`),
UNIQUE KEY `steamId64` (`steam_id_64`) USING BTREE,
UNIQUE KEY `discordId` (`discord_id`) USING BTREE,
UNIQUE KEY `guilded_id` (`guilded_id`),
UNIQUE KEY `uk_authentik_identity` (`authentik_issuer`,`authentik_sub`)
) ENGINE=InnoDB AUTO_INCREMENT=191 DEFAULT CHARSET=utf8mb4;
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.members_awards
CREATE TABLE IF NOT EXISTS `members_awards` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`member_id` int(11) DEFAULT NULL,
`awards_id` int(11) DEFAULT NULL,
`event_date` datetime DEFAULT NULL,
`created_at` datetime DEFAULT current_timestamp(),
`updated_at` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`id`),
KEY `fk_members_awards_membres_id` (`member_id`),
KEY `fk_members_awards_awards_id` (`awards_id`),
CONSTRAINT `fk_members_awards_awards_id` FOREIGN KEY (`awards_id`) REFERENCES `awards` (`id`) ON UPDATE CASCADE,
CONSTRAINT `fk_members_awards_membres_id` FOREIGN KEY (`member_id`) REFERENCES `members` (`id`) ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.members_qualifications
CREATE TABLE IF NOT EXISTS `members_qualifications` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`member_id` int(11) DEFAULT 0,
`qualification_id` int(11) DEFAULT 0,
`event_date` datetime DEFAULT NULL,
`created_at` datetime DEFAULT current_timestamp(),
`updated_at` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(),
`deleted` tinyint(4) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `fk_members_qualifications_member_id` (`member_id`),
KEY `fk_members_qualifications_qualifications_id` (`qualification_id`),
CONSTRAINT `fk_members_qualifications_member_id` FOREIGN KEY (`member_id`) REFERENCES `members` (`id`) ON UPDATE CASCADE,
CONSTRAINT `fk_members_qualifications_qualifications_id` FOREIGN KEY (`qualification_id`) REFERENCES `qualifications` (`id`) ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.members_ranks
CREATE TABLE IF NOT EXISTS `members_ranks` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`member_id` int(11) DEFAULT NULL,
`rank_id` int(11) DEFAULT NULL,
`event_date` datetime DEFAULT NULL,
`created_at` datetime DEFAULT current_timestamp(),
`updated_at` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`id`),
KEY `fk_members_ranks_members_id` (`member_id`),
KEY `fk_members_ranks_rank_id` (`rank_id`),
CONSTRAINT `fk_members_ranks_members_id` FOREIGN KEY (`member_id`) REFERENCES `members` (`id`) ON UPDATE CASCADE,
CONSTRAINT `fk_members_ranks_rank_id` FOREIGN KEY (`rank_id`) REFERENCES `ranks` (`id`) ON UPDATE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=210 DEFAULT CHARSET=utf8mb4;
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.members_roles
CREATE TABLE IF NOT EXISTS `members_roles` (
`member_id` int(11) NOT NULL,
`role_id` int(11) NOT NULL,
PRIMARY KEY (`member_id`,`role_id`),
KEY `role_id` (`role_id`),
CONSTRAINT `members_roles_ibfk_1` FOREIGN KEY (`member_id`) REFERENCES `members` (`id`) ON DELETE CASCADE,
CONSTRAINT `members_roles_ibfk_2` FOREIGN KEY (`role_id`) REFERENCES `roles` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.members_statuses
CREATE TABLE IF NOT EXISTS `members_statuses` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`member_id` int(11) DEFAULT NULL,
`status_id` int(11) DEFAULT NULL,
`reason_id` int(11) DEFAULT NULL,
`event_date` datetime DEFAULT NULL,
`created_at` datetime DEFAULT current_timestamp(),
`updated_at` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`id`),
KEY `fk_members_statuses_status_id` (`status_id`),
KEY `fk_members_statuses_member_id` (`member_id`),
KEY `fk_members_statuses_statuses_reasons_id` (`reason_id`) USING BTREE,
CONSTRAINT `fk_members_statuses_member_id` FOREIGN KEY (`member_id`) REFERENCES `members` (`id`) ON UPDATE CASCADE,
CONSTRAINT `fk_members_statuses_status_id` FOREIGN KEY (`status_id`) REFERENCES `statuses` (`id`) ON UPDATE CASCADE,
CONSTRAINT `members_statuses_FK` FOREIGN KEY (`reason_id`) REFERENCES `statuses_reasons` (`id`) ON UPDATE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=207 DEFAULT CHARSET=utf8mb4;
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.mission_attendee_roles
CREATE TABLE IF NOT EXISTS `mission_attendee_roles` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(50) DEFAULT NULL,
`short_name` varchar(50) DEFAULT NULL,
`description` text DEFAULT NULL,
`deleted` tinyint(4) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `role_name` (`name`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=24 DEFAULT CHARSET=utf8mb4;
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.mission_events
CREATE TABLE IF NOT EXISTS `mission_events` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`event_type_id` int(11) NOT NULL,
`event_name` varchar(100) NOT NULL,
`description` varchar(100) DEFAULT NULL,
`mission_name` varchar(100) NOT NULL,
`author_id` int(11) DEFAULT NULL,
`map_id` int(11) DEFAULT NULL,
`event_date` datetime NOT NULL,
`guilded_event_id` int(11) DEFAULT NULL,
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
`updated_at` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
`deleted` tinyint(4) DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `event_name` (`event_name`),
KEY `fk_events_author_member_id` (`author_id`) USING BTREE,
KEY `fk_mission_event_type_id` (`event_type_id`),
KEY `fk_mission_event_map_id` (`map_id`),
CONSTRAINT `fk_events_author_id` FOREIGN KEY (`author_id`) REFERENCES `members` (`id`) ON UPDATE CASCADE,
CONSTRAINT `fk_mission_event_map_id` FOREIGN KEY (`map_id`) REFERENCES `arma_maps` (`id`) ON UPDATE CASCADE,
CONSTRAINT `fk_mission_event_type_id` FOREIGN KEY (`event_type_id`) REFERENCES `event_types` (`id`) ON UPDATE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4;
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.mission_event_attendees
CREATE TABLE IF NOT EXISTS `mission_event_attendees` (
`id` int(11) DEFAULT NULL,
`member_id` int(11) NOT NULL,
`event_id` int(11) NOT NULL,
`member_role_id` int(11) DEFAULT NULL,
`event_type` int(11) NOT NULL,
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
`updated_at` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`member_id`,`event_id`) USING BTREE,
KEY `fk_mission_event_attendees_role_id` (`member_role_id`),
KEY `fk_mission_event_events_id` (`event_id`),
CONSTRAINT `fk_member_event_member_id` FOREIGN KEY (`member_id`) REFERENCES `members` (`id`) ON UPDATE CASCADE,
CONSTRAINT `fk_mission_event_attendees_role_id` FOREIGN KEY (`member_role_id`) REFERENCES `mission_attendee_roles` (`id`) ON UPDATE CASCADE,
CONSTRAINT `fk_mission_event_events_id` FOREIGN KEY (`event_id`) REFERENCES `mission_events` (`id`) ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.qualifications
CREATE TABLE IF NOT EXISTS `qualifications` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(100) DEFAULT NULL,
`short_name` varchar(10) DEFAULT NULL,
`description` text DEFAULT NULL,
`type` varchar(100) DEFAULT NULL,
`classification` varchar(100) DEFAULT NULL,
`footprint` varchar(50) DEFAULT NULL,
`created_at` datetime DEFAULT current_timestamp(),
`updated_at` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(),
`image_url` varchar(250) DEFAULT NULL,
`deleted` tinytext DEFAULT '0',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `name` (`name`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=99 DEFAULT CHARSET=utf8mb4 COMMENT='Contains a list of Member Qualifications for the unit.';
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.ranks
CREATE TABLE IF NOT EXISTS `ranks` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` longtext DEFAULT NULL,
`short_name` varchar(70) NOT NULL,
`category` varchar(100) NOT NULL,
`sort_id` int(11) NOT NULL DEFAULT 0,
`image_url` varchar(240) DEFAULT NULL,
`created_at` datetime(3) NOT NULL,
`updated_at` datetime(3) NOT NULL,
`deleted` tinyint(1) NOT NULL DEFAULT 0,
`deleted_at` datetime(3) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `shortName` (`short_name`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=22 DEFAULT CHARSET=utf8mb4;
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.roles
CREATE TABLE IF NOT EXISTS `roles` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL,
`color` varchar(9) COLLATE utf8mb4_unicode_ci NOT NULL,
`description` text COLLATE utf8mb4_unicode_ci DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `name` (`name`)
) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.statuses
CREATE TABLE IF NOT EXISTS `statuses` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(100) NOT NULL,
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
`updated_at` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
`deleted` tinyint(4) DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `name` (`name`)
) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4;
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.statuses_reasons
CREATE TABLE IF NOT EXISTS `statuses_reasons` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`created_at` datetime DEFAULT current_timestamp(),
`updated_at` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(),
`deleted` tinyint(4) DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `name` (`name`)
) ENGINE=InnoDB AUTO_INCREMENT=14 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Contains a list of Reasons that a Status Change occured. This helps determine Promotions, Demotions, Transfers, Joins, Leaves.';
-- Data exporting was unselected.
-- Dumping structure for table ranger_unit_tracker.status_change_requests
CREATE TABLE IF NOT EXISTS `status_change_requests` (
`id` int(11) NOT NULL,
`member_id` int(11) DEFAULT NULL,
`current_status` int(11) DEFAULT NULL,
`next_status` int(11) DEFAULT NULL,
`next_rank` int(11) DEFAULT NULL,
`approval_from` int(11) DEFAULT NULL,
`approval_from_date` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
`approval_to` int(11) DEFAULT NULL,
`approval_to_date` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00',
`request_date` timestamp NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
KEY `member_id` (`member_id`),
KEY `next_status` (`next_status`),
KEY `next_rank` (`next_rank`),
KEY `approval_from` (`approval_from`),
KEY `approval_to` (`approval_to`),
KEY `current_status` (`current_status`),
CONSTRAINT `status_change_requests_ibfk_1` FOREIGN KEY (`member_id`) REFERENCES `members` (`id`),
CONSTRAINT `status_change_requests_ibfk_2` FOREIGN KEY (`next_status`) REFERENCES `statuses` (`id`),
CONSTRAINT `status_change_requests_ibfk_3` FOREIGN KEY (`next_rank`) REFERENCES `ranks` (`id`),
CONSTRAINT `status_change_requests_ibfk_4` FOREIGN KEY (`approval_from`) REFERENCES `members` (`id`),
CONSTRAINT `status_change_requests_ibfk_5` FOREIGN KEY (`approval_to`) REFERENCES `members` (`id`),
CONSTRAINT `status_change_requests_ibfk_6` FOREIGN KEY (`current_status`) REFERENCES `statuses` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Data exporting was unselected.
-- Dumping structure for view ranger_unit_tracker.view_member_rank_status_all
-- Creating temporary table to overcome VIEW dependency errors
CREATE TABLE `view_member_rank_status_all` (
`member_id` INT(11) NOT NULL,
`member_name` VARCHAR(1) NOT NULL COLLATE 'utf8mb4_general_ci',
`rank` LONGTEXT NULL COLLATE 'utf8mb4_general_ci',
`rank_date` DATETIME NULL,
`status` VARCHAR(1) NULL COLLATE 'utf8mb4_general_ci',
`status_date` DATETIME NULL
);
-- Removing temporary table and create final VIEW structure
DROP TABLE IF EXISTS `view_member_rank_status_all`;
CREATE ALGORITHM=UNDEFINED SQL SECURITY DEFINER VIEW `view_member_rank_status_all` AS select `ranger_unit_tracker`.`members`.`id` AS `member_id`,`ranger_unit_tracker`.`members`.`name` AS `member_name`,`ranger_unit_tracker`.`ranks`.`name` AS `rank`,`members_ranks`.`event_date` AS `rank_date`,`ranger_unit_tracker`.`statuses`.`name` AS `status`,`members_statuses`.`event_date` AS `status_date` from ((((`ranger_unit_tracker`.`members` left join (select `mr`.`id` AS `id`,`mr`.`member_id` AS `member_id`,`mr`.`rank_id` AS `rank_id`,`mr`.`event_date` AS `event_date`,`mr`.`created_at` AS `created_at`,`mr`.`updated_at` AS `updated_at` from (`ranger_unit_tracker`.`members_ranks` `mr` join (select `ranger_unit_tracker`.`members_ranks`.`member_id` AS `member_id`,max(`ranger_unit_tracker`.`members_ranks`.`event_date`) AS `max_rank_date` from `ranger_unit_tracker`.`members_ranks` group by `ranger_unit_tracker`.`members_ranks`.`member_id`) `latest_ranks` on(`mr`.`member_id` = `latest_ranks`.`member_id` and `mr`.`event_date` = `latest_ranks`.`max_rank_date`))) `members_ranks` on(`ranger_unit_tracker`.`members`.`id` = `members_ranks`.`member_id`)) left join (select `ms`.`id` AS `id`,`ms`.`member_id` AS `member_id`,`ms`.`status_id` AS `status_id`,`ms`.`event_date` AS `event_date`,`ms`.`created_at` AS `created_at`,`ms`.`updated_at` AS `updated_at` from (`ranger_unit_tracker`.`members_statuses` `ms` join (select `ranger_unit_tracker`.`members_statuses`.`member_id` AS `member_id`,max(`ranger_unit_tracker`.`members_statuses`.`event_date`) AS `max_status_date` from `ranger_unit_tracker`.`members_statuses` group by `ranger_unit_tracker`.`members_statuses`.`member_id`) `latest_statuses` on(`ms`.`member_id` = `latest_statuses`.`member_id` and `ms`.`event_date` = `latest_statuses`.`max_status_date`))) `members_statuses` on(`ranger_unit_tracker`.`members`.`id` = `members_statuses`.`member_id`)) left join `ranger_unit_tracker`.`ranks` on(`members_ranks`.`rank_id` = `ranger_unit_tracker`.`ranks`.`id`)) left join `ranger_unit_tracker`.`statuses` on(`members_statuses`.`status_id` = `ranger_unit_tracker`.`statuses`.`id`))
;
/*!40103 SET TIME_ZONE=IFNULL(@OLD_TIME_ZONE, 'system') */;
/*!40101 SET SQL_MODE=IFNULL(@OLD_SQL_MODE, '') */;
/*!40014 SET FOREIGN_KEY_CHECKS=IFNULL(@OLD_FOREIGN_KEY_CHECKS, 1) */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40111 SET SQL_NOTES=IFNULL(@OLD_SQL_NOTES, 1) */;

3655
api/migrations/seed.sql Normal file

File diff suppressed because it is too large Load Diff

1529
api/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,14 +8,20 @@
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1", "test": "echo \"Error: no test specified\" && exit 1",
"dev": "tsc && tsc-alias && node ./built/api/src/index.js", "dev": "tsc && cross-env NODE_ENV=development node ./built/api/src/index.js",
"build": "tsc && tsc-alias" "prod": "tsc && node ./built/api/src/index.js",
"migrate": "node ./scripts/migrate.js",
"migrate:create": "npm run migrate -- create -ext sql -dir /migrations",
"migrate:seed": "node ./scripts/seed.js",
"migrate:up": "npm run migrate -- up",
"migrate:down": "npm run migrate -- down 1"
}, },
"dependencies": { "dependencies": {
"@sentry/node": "^10.27.0",
"connect-sqlite3": "^0.9.16", "connect-sqlite3": "^0.9.16",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.2.1", "dotenv": "16.6.1",
"express": "^5.1.0", "express": "^5.1.0",
"express-session": "^1.18.2", "express-session": "^1.18.2",
"mariadb": "^3.4.5", "mariadb": "^3.4.5",
@@ -28,7 +34,7 @@
"@types/express": "^5.0.3", "@types/express": "^5.0.3",
"@types/morgan": "^1.9.10", "@types/morgan": "^1.9.10",
"@types/node": "^24.8.1", "@types/node": "^24.8.1",
"tsc-alias": "^1.8.16", "cross-env": "^10.1.0",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
} }

30
api/scripts/migrate.js Normal file
View File

@@ -0,0 +1,30 @@
const dotenv = require('dotenv');
const path = require('path');
const { execSync } = require('child_process');
const mode = process.env.NODE_ENV || "development";
dotenv.config({ path: path.resolve(process.cwd(), `.env.${mode}`) });
const db = {
user: process.env.DB_USERNAME,
pass: process.env.DB_PASSWORD,
host: process.env.DB_HOST,
port: process.env.DB_PORT,
name: process.env.DB_DATABASE,
};
const dbUrl = `mysql://${db.user}:${db.pass}@tcp(host.docker.internal:${db.port})/${db.name}`;
const args = process.argv.slice(2).join(" ");
const migrations = path.join(process.cwd(), "migrations");
const cmd = [
"docker run --rm",
`-v ${migrations}:/migrations`,
"migrate/migrate",
`-path=/migrations`,
`-database \"${dbUrl}\"`,
args,
].join(" ");
console.log(cmd);
execSync(cmd, { stdio: "inherit" });

33
api/scripts/seed.js Normal file
View File

@@ -0,0 +1,33 @@
const dotenv = require("dotenv");
const path = require("path");
const mariadb = require("mariadb");
const fs = require("fs");
const mode = process.env.NODE_ENV || "development";
dotenv.config({ path: path.resolve(process.cwd(), `.env.${mode}`) });
const { DB_HOST, DB_PORT, DB_USERNAME, DB_PASSWORD, DB_NAME } = process.env;
//do not accidentally seed prod pls
if (mode !== "development") {
process.exit(0);
}
(async () => {
const conn = await mariadb.createConnection({
host: DB_HOST,
port: DB_PORT,
user: DB_USERNAME,
password: DB_PASSWORD,
database: DB_NAME,
multipleStatements: true,
});
const seedFile = path.join(process.cwd(), "migrations", "seed.sql");
const sql = fs.readFileSync(seedFile, "utf8");
await conn.query(sql);
await conn.end();
console.log("Seeded");
})();

View File

@@ -1,8 +1,14 @@
// const mariadb = require('mariadb') // const mariadb = require('mariadb')
import * as mariadb from 'mariadb'; import * as mariadb from 'mariadb';
const dotenv = require('dotenv') // const dotenv = require('dotenv')
dotenv.config(); // import path = require('path');
// console.log('NODE_ENV =', process.env.NODE_ENV);
// const envFile = process.env.NODE_ENV === 'development' ? '.env.development' : '.env';
// dotenv.config({ path: path.resolve(process.cwd(), envFile) });
// console.log(`Loaded environment from ${envFile}`);
// console.log(process.env.DB_HOST)
const pool = mariadb.createPool({ const pool = mariadb.createPool({
host: process.env.DB_HOST, host: process.env.DB_HOST,

View File

@@ -1,5 +1,8 @@
const dotenv = require('dotenv') const dotenv = require('dotenv')
dotenv.config(); const path = require('path')
const envFile = process.env.NODE_ENV === 'development' ? '.env.development' : '.env';
dotenv.config({ path: path.resolve(process.cwd(), envFile) });
console.log(`Loaded environment from ${envFile}`);
const express = require('express') const express = require('express')
const cors = require('cors') const cors = require('cors')
@@ -8,7 +11,7 @@ const app = express()
app.use(morgan('dev')) app.use(morgan('dev'))
app.use(cors({ app.use(cors({
origin: [process.env.CLIENT_URL], // your SPA origins origin: ['https://aj17thdev.nexuszone.net', 'http://localhost:5173'], // your SPA origins
credentials: true credentials: true
})); }));
@@ -18,16 +21,6 @@ app.set('trust proxy', 1);
const port = process.env.SERVER_PORT; const port = process.env.SERVER_PORT;
//glitchtip setup
const sentry = require('@sentry/node');
if (!process.env.DISABLE_GLITCHTIP) {
console.log("Glitchtip disabled AAAAAA")
} else {
let dsn = process.env.GLITCHTIP_DSN;
sentry.init({ dsn: dsn });
console.log("Glitchtip initialized");
}
//session setup //session setup
const path = require('path') const path = require('path')
const session = require('express-session') const session = require('express-session')
@@ -42,7 +35,7 @@ app.use(session({
cookie: { cookie: {
httpOnly: true, httpOnly: true,
sameSite: 'lax', sameSite: 'lax',
domain: process.env.CLIENT_DOMAIN domain: 'nexuszone.net'
} }
})); }));
app.use(passport.authenticate('session')); app.use(passport.authenticate('session'));
@@ -55,8 +48,6 @@ const loaHandler = require('./routes/loa')
const { status, memberStatus } = require('./routes/statuses') const { status, memberStatus } = require('./routes/statuses')
const authRouter = require('./routes/auth') const authRouter = require('./routes/auth')
const { roles, memberRoles } = require('./routes/roles'); const { roles, memberRoles } = require('./routes/roles');
const { courseRouter, eventRouter } = require('./routes/course');
const { calendarRouter } = require('./routes/calendar')
const morgan = require('morgan'); const morgan = require('morgan');
app.use('/application', applicationsRouter); app.use('/application', applicationsRouter);
@@ -68,9 +59,6 @@ app.use('/status', status)
app.use('/memberStatus', memberStatus) app.use('/memberStatus', memberStatus)
app.use('/roles', roles) app.use('/roles', roles)
app.use('/memberRoles', memberRoles) app.use('/memberRoles', memberRoles)
app.use('/course', courseRouter)
app.use('/courseEvent', eventRouter)
app.use('/calendar', calendarRouter)
app.use('/', authRouter) app.use('/', authRouter)
app.get('/ping', (req, res) => { app.get('/ping', (req, res) => {

View File

@@ -38,27 +38,12 @@ router.get('/all', async (req, res) => {
}); });
router.get('/me', async (req, res) => { router.get('/me', async (req, res) => {
let userID = req.user.id; let userID = req.user.id;
try { console.log("application/me")
let application = await getMemberApplication(userID);
if (application === undefined) let app = getMemberApplication(userID);
res.sendStatus(204); console.log(app);
const comments: CommentRow[] = await getApplicationComments(application.id);
const output: ApplicationFull = {
application,
comments,
}
return res.status(200).json(output);
} catch (error) {
console.error('Failed to load application:', error);
return res.status(500).json(error);
}
}) })
// GET /application/:id // GET /application/:id
@@ -69,7 +54,7 @@ router.get('/:id', async (req, res) => {
const application = await getApplicationByID(appID); const application = await getApplicationByID(appID);
if (application === undefined) if (application === undefined)
return res.sendStatus(204); return res.sendStatus(204);
const comments: CommentRow[] = await getApplicationComments(appID); const comments: CommentRow[] = await getApplicationComments(appID);
const output: ApplicationFull = { const output: ApplicationFull = {

View File

@@ -12,9 +12,9 @@ const querystring = require('querystring');
passport.use(new OpenIDConnectStrategy({ passport.use(new OpenIDConnectStrategy({
issuer: process.env.AUTH_ISSUER, issuer: process.env.AUTH_ISSUER,
authorizationURL: process.env.AUTH_DOMAIN + '/authorize/', authorizationURL: 'https://sso.iceberg-gaming.com/application/o/authorize/',
tokenURL: process.env.AUTH_DOMAIN + '/token/', tokenURL: 'https://sso.iceberg-gaming.com/application/o/token/',
userInfoURL: process.env.AUTH_DOMAIN + '/userinfo/', userInfoURL: 'https://sso.iceberg-gaming.com/application/o/userinfo/',
clientID: process.env.AUTH_CLIENT_ID, clientID: process.env.AUTH_CLIENT_ID,
clientSecret: process.env.AUTH_CLIENT_SECRET, clientSecret: process.env.AUTH_CLIENT_SECRET,
callbackURL: process.env.AUTH_REDIRECT_URI, callbackURL: process.env.AUTH_REDIRECT_URI,
@@ -46,8 +46,9 @@ passport.use(new OpenIDConnectStrategy({
`INSERT INTO members (name, authentik_sub, authentik_issuer) VALUES (?, ?, ?)`, `INSERT INTO members (name, authentik_sub, authentik_issuer) VALUES (?, ?, ?)`,
[username, sub, issuer] [username, sub, issuer]
) )
memberId = Number(result.insertId); memberId = result.insertId;
} }
await con.commit(); await con.commit();
return cb(null, { memberId }); return cb(null, { memberId });
} catch (error) { } catch (error) {
@@ -60,7 +61,7 @@ passport.use(new OpenIDConnectStrategy({
router.get('/login', (req, res, next) => { router.get('/login', (req, res, next) => {
// Store redirect target in session if provided // Store redirect target in session if provided
req.session.redirectTo = req.query.redirect; req.session.redirectTo = req.query.redirect || '/';
next(); next();
}, passport.authenticate('openidconnect')); }, passport.authenticate('openidconnect'));
@@ -68,7 +69,7 @@ router.get('/login', (req, res, next) => {
// router.get('/callback', (req, res, next) => { // router.get('/callback', (req, res, next) => {
// passport.authenticate('openidconnect', { // passport.authenticate('openidconnect', {
// successRedirect: req.session.redirectTo, // successRedirect: req.session.redirectTo,
// failureRedirect: process.env.CLIENT_URL // failureRedirect: 'https://aj17thdev.nexuszone.net/'
// }) // })
// }); // });
@@ -76,27 +77,27 @@ router.get('/callback', (req, res, next) => {
const redirectURI = req.session.redirectTo; const redirectURI = req.session.redirectTo;
passport.authenticate('openidconnect', (err, user) => { passport.authenticate('openidconnect', (err, user) => {
if (err) return next(err); if (err) return next(err);
if (!user) return res.redirect(process.env.CLIENT_URL); if (!user) return res.redirect('https://aj17thdev.nexuszone.net/');
req.logIn(user, err => { req.logIn(user, err => {
if (err) return next(err); if (err) return next(err);
// Use redirect saved from session // Use redirect saved from session
const redirectTo = redirectURI || process.env.CLIENT_URL; const redirectTo = redirectURI || 'https://aj17thdev.nexuszone.net/';
delete req.session.redirectTo; delete req.session.redirectTo;
return res.redirect(redirectTo); return res.redirect(redirectTo);
}); });
})(req, res, next); })(req, res, next);
}); });
router.get('/logout', function (req, res, next) { router.post('/logout', function (req, res, next) {
req.logout(function (err) { req.logout(function (err) {
if (err) { return next(err); } if (err) { return next(err); }
var params = { var params = {
client_id: process.env.AUTH_CLIENT_ID, client_id: process.env.AUTH_CLIENT_ID,
returnTo: process.env.CLIENT_URL returnTo: 'https://aj17thdev.nexuszone.net/'
}; };
res.redirect(process.env.AUTH_END_SESSION_URI + '?' + querystring.stringify(params)); res.redirect(process.env.AUTH_DOMAIN + '/v2/logout?' + querystring.stringify(params));
}); });
}); });
@@ -108,7 +109,6 @@ passport.serializeUser(function (user, cb) {
passport.deserializeUser(function (user, cb) { passport.deserializeUser(function (user, cb) {
process.nextTick(async function () { process.nextTick(async function () {
const memberID = user.memberId; const memberID = user.memberId;
const con = await pool.getConnection(); const con = await pool.getConnection();

View File

@@ -1,6 +1,4 @@
import { Request, Response } from "express"; import { getEventAttendance, getEventDetails, getShortEventsInRange } from "../services/calendarService";
import { createEvent, getEventAttendance, getEventDetails, getShortEventsInRange, setAttendanceStatus, setEventCancelled, updateEvent } from "../services/calendarService";
import { CalendarAttendance, CalendarEvent } from "@app/shared/types/calendar";
const express = require('express'); const express = require('express');
const r = express.Router(); const r = express.Router();
@@ -11,108 +9,42 @@ function addMonths(date: Date, months: number): Date {
return d return d
} }
//get calendar events paged, requires a query string with from= and to= as mariadb ISO strings //get calendar events paged
r.get('/', async (req, res) => { r.get('/', async (req, res) => {
try { const viewDate: Date = req.body.date;
const fromDate: string = req.query.from; //generate date range
const toDate: string = req.query.to; const backDate: Date = addMonths(viewDate, -1);
const frontDate: Date = addMonths(viewDate, 2);
if (fromDate === undefined || toDate === undefined) { const events = getShortEventsInRange(backDate, frontDate);
res.status(400).send("Missing required query parameters 'from' and 'to'");
return;
}
const events = await getShortEventsInRange(fromDate, toDate); res.status(200).json(events);
res.status(200).json(events);
} catch (error) {
console.error('Error fetching calendar events:', error);
res.status(500).send('Error fetching calendar events');
}
}); });
r.get('/upcoming', async (req, res) => { r.get('/upcoming', async (req, res) => {
res.sendStatus(501); res.sendStatus(501);
}) })
r.post('/:id/cancel', async (req: Request, res: Response) => {
try {
const eventID = Number(req.params.id);
setEventCancelled(eventID, true);
res.sendStatus(200);
} catch (error) {
console.error('Error setting cancel status:', error);
res.status(500).send('Error setting cancel status');
}
})
r.post('/:id/uncancel', async (req: Request, res: Response) => {
try {
const eventID = Number(req.params.id);
setEventCancelled(eventID, false);
res.sendStatus(200);
} catch (error) {
console.error('Error setting cancel status:', error);
res.status(500).send('Error setting cancel status');
}
})
r.post('/:id/attendance', async (req: Request, res: Response) => {
try {
let member = req.user.id;
let event = Number(req.params.id);
let state = req.query.state as CalendarAttendance;
setAttendanceStatus(member, event, state);
res.sendStatus(200);
} catch (error) {
console.error('Failed to set attendance:', error);
res.status(500).json(error);
}
})
//get event details //get event details
r.get('/:id', async (req: Request, res: Response) => { r.get('/:id', async (req, res) => {
try { try {
const eventID: number = Number(req.params.id); const eventID: number = req.params.id;
let details: CalendarEvent = await getEventDetails(eventID); let details = getEventDetails(eventID);
details.eventSignups = await getEventAttendance(eventID); let attendance = await getEventAttendance(eventID);
res.status(200).json(details);
let out = { ...details, attendance }
console.log(out);
res.status(200).json(out);
} catch (err) { } catch (err) {
console.error('Insert failed:', err); console.error('Insert failed:', err);
res.status(500).json(err); res.status(500).json(err);
} }
}) })
//post a new calendar event
r.post('/', async (req, res) => {
//post a new calendar event
r.post('/', async (req: Request, res: Response) => {
try {
const member = req.user.id;
let event: CalendarEvent = req.body;
event.creator_id = member;
event.start = new Date(event.start);
event.end = new Date(event.end);
createEvent(event);
res.sendStatus(200);
} catch (error) {
console.error('Failed to create event:', error);
res.status(500).json(error);
}
}) })
r.put('/', async (req: Request, res: Response) => { module.exports.calendar = r;
try {
let event: CalendarEvent = req.body;
event.start = new Date(event.start);
event.end = new Date(event.end);
console.log(event);
updateEvent(event);
res.sendStatus(200);
} catch (error) {
console.error('Failed to update event:', error);
res.status(500).json(error);
}
})
module.exports.calendarRouter = r;

View File

@@ -1,89 +0,0 @@
import { CourseAttendee, CourseEventDetails } from "@app/shared/types/course";
import { getAllCourses, getCourseEventAttendees, getCourseEventDetails, getCourseEventRoles, getCourseEvents, insertCourseEvent } from "../services/CourseSerivce";
import { Request, Response, Router } from "express";
const courseRouter = Router();
const eventRouter = Router();
courseRouter.get('/', async (req, res) => {
try {
const courses = await getAllCourses();
res.status(200).json(courses);
} catch (err) {
console.error('failed to fetch courses', err);
res.status(500).json('failed to fetch courses\n' + err);
}
})
courseRouter.get('/roles', async (req, res) => {
try {
const roles = await getCourseEventRoles();
res.status(200).json(roles);
} catch (err) {
console.error('failed to fetch course roles', err);
res.status(500).json('failed to fetch course roles\n' + err);
}
})
eventRouter.get('/', async (req: Request, res: Response) => {
const allowedSorts = new Map([
["ascending", "ASC"],
["descending", "DESC"]
]);
const sort = String(req.query.sort || "").toLowerCase();
const search = String(req.query.search || "").toLowerCase();
if (!allowedSorts.has(sort)) {
return res.status(400).json({
message: `Invalid sort direction '${req.query.sort}'. Allowed values are 'ascending' or 'descending'.`
});
}
const sortDir = allowedSorts.get(sort);
try {
let events = await getCourseEvents(sortDir, search);
res.status(200).json(events);
} catch (error) {
console.error('failed to fetch reports', error);
res.status(500).json(error);
}
});
eventRouter.get('/:id', async (req: Request, res: Response) => {
try {
let out = await getCourseEventDetails(Number(req.params.id));
res.status(200).json(out);
} catch (error) {
console.error('failed to fetch report', error);
res.status(500).json(error);
}
});
eventRouter.get('/attendees/:id', async (req: Request, res: Response) => {
try {
const attendees: CourseAttendee[] = await getCourseEventAttendees(Number(req.params.id));
res.status(200).json(attendees);
} catch (err) {
console.error('failed to fetch attendees', err);
res.status(500).json("failed to fetch attendees\n" + err);
}
})
eventRouter.post('/', async (req: Request, res: Response) => {
const posterID: number = req.user.id;
try {
console.log();
let data: CourseEventDetails = req.body;
data.created_by = posterID;
data.event_date = new Date(data.event_date);
const id = await insertCourseEvent(data);
res.status(201).json(id);
} catch (error) {
console.error('failed to post training', error);
res.status(500).json("failed to post training\n" + error)
}
})
module.exports.courseRouter = courseRouter;
module.exports.eventRouter = eventRouter;

View File

@@ -1,147 +0,0 @@
import pool from "../db"
import { Course, CourseAttendee, CourseAttendeeRole, CourseEventDetails, CourseEventSummary, RawAttendeeRow } from "@app/shared/types/course"
import { toDateTime } from "@app/shared/utils/time";
export async function getAllCourses(): Promise<Course[]> {
const sql = "SELECT * FROM courses WHERE deleted = false ORDER BY name ASC;"
const res: Course[] = await pool.query(sql);
return res;
}
export async function getCourseByID(id: number): Promise<Course> {
const sql = "SELECT * FROM courses WHERE id = ?;"
const res: Course[] = await pool.query(sql, [id]);
return res[0];
}
function buildAttendee(row: RawAttendeeRow): CourseAttendee {
return {
passed_bookwork: !!row.passed_bookwork,
passed_qual: !!row.passed_qual,
attendee_id: row.attendee_id,
course_event_id: row.course_event_id,
created_at: new Date(row.created_at),
updated_at: new Date(row.updated_at),
remarks: row.remarks,
attendee_role_id: row.attendee_role_id,
attendee_name: row.attendee_name,
role: row.role_id
? {
id: row.role_id,
name: row.role_name,
description: row.role_description,
deleted: !!row.role_deleted,
created_at: new Date(row.role_created_at),
updated_at: new Date(row.role_updated_at),
}
: null
};
}
export async function getCourseEventAttendees(id: number): Promise<CourseAttendee[]> {
const sql = `SELECT
ca.*,
mem.name AS attendee_name,
ar.id AS role_id,
ar.name AS role_name,
ar.description AS role_description,
ar.deleted AS role_deleted,
ar.created_at AS role_created_at,
ar.updated_at AS role_updated_at
FROM course_attendees ca
LEFT JOIN course_attendee_roles ar ON ar.id = ca.attendee_role_id
LEFT JOIN members mem ON ca.attendee_id = mem.id
WHERE ca.course_event_id = ?;`;
const res: RawAttendeeRow[] = await pool.query(sql, [id]);
return res.map((row) => buildAttendee(row))
}
export async function getCourseEventDetails(id: number): Promise<CourseEventDetails> {
const sql = `SELECT
E.*,
M.name AS created_by_name,
C.name AS course_name
FROM course_events AS E
LEFT JOIN courses AS C
ON E.course_id = C.id
LEFT JOIN members AS M
ON E.created_by = M.id
WHERE E.id = ?;
`;
let rows: CourseEventDetails[] = await pool.query(sql, [id]);
let event = rows[0];
event.attendees = await getCourseEventAttendees(id);
event.course = await getCourseByID(event.course_id);
return event;
}
export async function insertCourseEvent(event: CourseEventDetails): Promise<number> {
console.log(event);
const con = await pool.getConnection();
try {
await con.beginTransaction();
const res = await con.query("INSERT INTO course_events (course_id, event_date, remarks, created_by) VALUES (?, ?, ?, ?);", [event.course_id, toDateTime(event.event_date), event.remarks, event.created_by]);
var eventID: number = res.insertId;
for (const attendee of event.attendees) {
await con.query(`INSERT INTO course_attendees (
attendee_id,
course_event_id,
attendee_role_id,
passed_bookwork,
passed_qual,
remarks
)
VALUES (?, ?, ?, ?, ?, ?);`, [attendee.attendee_id, eventID, attendee.attendee_role_id, attendee.passed_bookwork, attendee.passed_qual, attendee.remarks]);
}
await con.commit();
await con.release();
return Number(eventID);
} catch (error) {
await con.rollback();
await con.release();
throw error;
}
}
export async function getCourseEvents(sortDir: string, search: string = ""): Promise<CourseEventSummary[]> {
let params = [];
let searchString = "";
if (search !== "") {
searchString = `WHERE (C.name LIKE ? OR
C.short_name LIKE ? OR
M.name LIKE ?) `;
const p = `%${search}%`;
params.push(p, p, p);
}
const sql = `SELECT
E.id AS event_id,
E.course_id,
E.event_date AS date,
E.created_by,
C.name AS course_name,
C.short_name AS course_shortname,
M.name AS created_by_name
FROM course_events AS E
LEFT JOIN courses AS C
ON E.course_id = C.id
LEFT JOIN members AS M
ON E.created_by = M.id
${searchString}
ORDER BY E.event_date ${sortDir};`;
console.log(sql)
console.log(params)
let events: CourseEventSummary[] = await pool.query(sql, params);
return events;
}
export async function getCourseEventRoles(): Promise<CourseAttendeeRole[]> {
const sql = "SELECT * FROM course_attendee_roles;"
const roles: CourseAttendeeRole[] = await pool.query(sql);
return roles;
}

6
api/src/services/calendarService.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
export declare function createEvent(eventObject: any): Promise<void>;
export declare function updateEvent(eventObject: any): Promise<void>;
export declare function cancelEvent(eventID: any): Promise<void>;
export declare function getShortEventsInRange(startDate: any, endDate: any): Promise<void>;
export declare function getEventDetailed(eventID: any): Promise<void>;
//# sourceMappingURL=calendarService.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"calendarService.d.ts","sourceRoot":"","sources":["calendarService.ts"],"names":[],"mappings":"AAEA,wBAAsB,WAAW,CAAC,WAAW,KAAA,iBAE5C;AAED,wBAAsB,WAAW,CAAC,WAAW,KAAA,iBAE5C;AAED,wBAAsB,WAAW,CAAC,OAAO,KAAA,iBAExC;AAED,wBAAsB,qBAAqB,CAAC,SAAS,KAAA,EAAE,OAAO,KAAA,iBAE7D;AAED,wBAAsB,gBAAgB,CAAC,OAAO,KAAA,iBAE7C"}

View File

@@ -0,0 +1,19 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.createEvent = createEvent;
exports.updateEvent = updateEvent;
exports.cancelEvent = cancelEvent;
exports.getShortEventsInRange = getShortEventsInRange;
exports.getEventDetailed = getEventDetailed;
const pool = require('../db');
async function createEvent(eventObject) {
}
async function updateEvent(eventObject) {
}
async function cancelEvent(eventID) {
}
async function getShortEventsInRange(startDate, endDate) {
}
async function getEventDetailed(eventID) {
}
//# sourceMappingURL=calendarService.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"calendarService.js","sourceRoot":"","sources":["calendarService.ts"],"names":[],"mappings":";;AAEA,kCAEC;AAED,kCAEC;AAED,kCAEC;AAED,sDAEC;AAED,4CAEC;AApBD,MAAM,IAAI,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;AAEtB,KAAK,UAAU,WAAW,CAAC,WAAW;AAE7C,CAAC;AAEM,KAAK,UAAU,WAAW,CAAC,WAAW;AAE7C,CAAC;AAEM,KAAK,UAAU,WAAW,CAAC,OAAO;AAEzC,CAAC;AAEM,KAAK,UAAU,qBAAqB,CAAC,SAAS,EAAE,OAAO;AAE9D,CAAC;AAEM,KAAK,UAAU,gBAAgB,CAAC,OAAO;AAE9C,CAAC"}

View File

@@ -1,12 +1,26 @@
import pool from '../db'; import pool from '../db';
import { CalendarEventShort, CalendarSignup, CalendarEvent, CalendarAttendance } from "@app/shared/types/calendar"
import { toDateTime } from "@app/shared/utils/time" export interface CalendarEvent {
id: number;
name: string;
start: Date; // DATETIME -> Date
end: Date; // DATETIME -> Date
location: string;
color: string; // 7 character hex string
description?: string | null;
creator?: number | null; // foreign key to members.id, nullable
cancelled: boolean; // TINYINT(1) -> boolean
created_at: Date; // TIMESTAMP -> Date
updated_at: Date; // TIMESTAMP -> Date
}
export type Attendance = 'attending' | 'maybe' | 'not_attending';
export async function createEvent(eventObject: Omit<CalendarEvent, 'id' | 'created_at' | 'updated_at' | 'cancelled'>) { export async function createEvent(eventObject: Omit<CalendarEvent, 'id' | 'created_at' | 'updated_at' | 'cancelled'>) {
const sql = ` const sql = `
INSERT INTO calendar_events INSERT INTO calendar_events
(name, start, end, location, color, description, creator) (name, start, end, location, color, description, creator)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`; `;
const params = [ const params = [
eventObject.name, eventObject.name,
@@ -15,7 +29,7 @@ export async function createEvent(eventObject: Omit<CalendarEvent, 'id' | 'creat
eventObject.location, eventObject.location,
eventObject.color, eventObject.color,
eventObject.description ?? null, eventObject.description ?? null,
eventObject.creator_id, eventObject.creator,
]; ];
const result = await pool.query(sql, params); const result = await pool.query(sql, params);
@@ -26,6 +40,7 @@ export async function updateEvent(eventObject: CalendarEvent) {
if (!eventObject.id) { if (!eventObject.id) {
throw new Error("updateEvent: Missing event ID."); throw new Error("updateEvent: Missing event ID.");
} }
const sql = ` const sql = `
UPDATE calendar_events UPDATE calendar_events
SET SET
@@ -34,14 +49,14 @@ export async function updateEvent(eventObject: CalendarEvent) {
end = ?, end = ?,
location = ?, location = ?,
color = ?, color = ?,
description = ? description = ?,
WHERE id = ? WHERE id = ?
`; `;
const params = [ const params = [
eventObject.name, eventObject.name,
toDateTime(eventObject.start), eventObject.start,
toDateTime(eventObject.end), eventObject.end,
eventObject.location, eventObject.location,
eventObject.color, eventObject.color,
eventObject.description ?? null, eventObject.description ?? null,
@@ -52,30 +67,28 @@ export async function updateEvent(eventObject: CalendarEvent) {
return { success: true }; return { success: true };
} }
export async function setEventCancelled(eventID: number, cancelled: boolean) { export async function cancelEvent(eventID: number) {
const input = cancelled ? 1 : 0;
const sql = ` const sql = `
UPDATE calendar_events UPDATE calendar_events
SET cancelled = ? SET cancelled = 1
WHERE id = ? WHERE id = ?
`; `;
await pool.query(sql, [input, eventID]); await pool.query(sql, [eventID]);
return { success: true }; return { success: true };
} }
export async function getShortEventsInRange(startDate: string, endDate: string): Promise<CalendarEventShort[]> { export async function getShortEventsInRange(startDate: Date, endDate: Date) {
const sql = ` const sql = `
SELECT id, name, start, end, color, cancelled, full_day SELECT id, name, start, end, color
FROM calendar_events FROM calendar_events
WHERE start BETWEEN ? AND ? WHERE start BETWEEN ? AND ?
ORDER BY start ASC ORDER BY start ASC
`; `;
const res: CalendarEventShort[] = await pool.query(sql, [startDate, endDate]); return await pool.query(sql, [startDate, endDate]);
return res;
} }
export async function getEventDetails(eventID: number): Promise<CalendarEvent> { export async function getEventDetails(eventID: number) {
const sql = ` const sql = `
SELECT SELECT
e.id, e.id,
@@ -88,14 +101,14 @@ export async function getEventDetails(eventID: number): Promise<CalendarEvent> {
e.cancelled, e.cancelled,
e.created_at, e.created_at,
e.updated_at, e.updated_at,
e.creator AS creator_id, m.id AS creator_id,
m.name AS creator_name m.name AS creator_name
FROM calendar_events e FROM calendar_events e
LEFT JOIN members m ON e.creator = m.id LEFT JOIN members m ON e.creator = m.id
WHERE e.id = ? WHERE e.id = ?
`; `;
let vals: CalendarEvent[] = await pool.query(sql, [eventID]);
return vals[0]; return await pool.query(sql, [eventID])
} }
export async function getUpcomingEvents(date: Date, limit: number) { export async function getUpcomingEvents(date: Date, limit: number) {
@@ -111,7 +124,7 @@ export async function getUpcomingEvents(date: Date, limit: number) {
} }
export async function setAttendanceStatus(memberID: number, eventID: number, status: CalendarAttendance) { export async function setAttendanceStatus(memberID: number, eventID: number, status: Attendance) {
const sql = ` const sql = `
INSERT INTO calendar_events_signups (member_id, event_id, status) INSERT INTO calendar_events_signups (member_id, event_id, status)
VALUES (?, ?, ?) VALUES (?, ?, ?)
@@ -122,7 +135,7 @@ export async function setAttendanceStatus(memberID: number, eventID: number, sta
return { success: true } return { success: true }
} }
export async function getEventAttendance(eventID: number): Promise<CalendarSignup[]> { export async function getEventAttendance(eventID: number) {
const sql = ` const sql = `
SELECT SELECT
s.member_id, s.member_id,

View File

@@ -20,15 +20,4 @@ export async function setUserState(userID: number, state: MemberState) {
SET state = ? SET state = ?
WHERE id = ?;`; WHERE id = ?;`;
return await pool.query(sql, [state, userID]); return await pool.query(sql, [state, userID]);
} }
declare global {
namespace Express {
interface Request {
user: {
id: number;
name: string;
};
}
}
}

View File

@@ -1,5 +1,4 @@
import pool from '../db'; import pool from '../db';
import { Role } from '@app/shared/types/roles'
export async function assignUserGroup(userID: number, roleID: number) { export async function assignUserGroup(userID: number, roleID: number) {
@@ -17,7 +16,7 @@ export async function createGroup(name: string, color: string, description: stri
return { id: result.insertId, name, color, description }; return { id: result.insertId, name, color, description };
} }
export async function getUserRoles(userID: number): Promise<Role[]> { export async function getUserRoles(userID: number) {
const sql = `SELECT r.id, r.name const sql = `SELECT r.id, r.name
FROM members_roles mr FROM members_roles mr
INNER JOIN roles r ON mr.role_id = r.id INNER JOIN roles r ON mr.role_id = r.id

13
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,13 @@
version: "3.9"
services:
db:
image: mariadb:10.6.23-ubi9
environment:
MARIADB_ROOT_PASSWORD: root
MARIADB_DATABASE: ranger_unit_tracker
MARIADB_USER: dev
MARIADB_PASSWORD: dev
ports:
- "3306:3306"
volumes:
- ./db_data:/var/lib/mysql

View File

@@ -1,14 +0,0 @@
module.exports = {
apps : [{
name: '17th-api',
cwd: 'api',
script: 'built/api/src/index.js',
watch: ['.env', 'built'],
ignore_watch: ['.gitignore', '\.json', 'src', '\.db', 'node_modules'],
watch_options: {
usePolling: true,
interval: 10000
},
time: true
}]
};

View File

@@ -1,24 +0,0 @@
{
"name": "@app/shared",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@app/shared",
"version": "1.0.0",
"dependencies": {
"zod": "^3.25.76"
}
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View File

@@ -2,8 +2,5 @@
"name": "@app/shared", "name": "@app/shared",
"version": "1.0.0", "version": "1.0.0",
"main": "index.ts", "main": "index.ts",
"type": "module", "type": "module"
"dependencies": { }
"zod": "^3.25.76"
}
}

View File

@@ -1,33 +0,0 @@
import z from "zod";
const dateRe = /^\d{4}-\d{2}-\d{2}$/ // YYYY-MM-DD
const timeRe = /^(?:[01]\d|2[0-3]):[0-5]\d$/ // HH:mm (24h)\
export function parseLocalDateTime(dateStr: string, timeStr: string) {
// Construct a Date in the user's local timezone
const [y, m, d] = dateStr.split("-").map(Number)
const [hh, mm] = timeStr.split(":").map(Number)
return new Date(y, m - 1, d, hh, mm, 0, 0)
}
export const calendarEventSchema = z.object({
name: z.string().min(2, "Please enter at least 2 characters").max(100),
startDate: z.string().regex(dateRe, "Use YYYY-MM-DD"),
startTime: z.string().regex(timeRe, "Use HH:mm (24h)"),
endDate: z.string().regex(dateRe, "Use YYYY-MM-DD"),
endTime: z.string().regex(timeRe, "Use HH:mm (24h)"),
location: z.string().max(200).default(""),
color: z.string().regex(/^#([0-9A-Fa-f]{6})$/, "Use a hex color like #AABBCC"),
description: z.string().max(2000).default(""),
id: z.number().int().nonnegative().nullable().default(null),
}).superRefine((vals, ctx) => {
const start = parseLocalDateTime(vals.startDate, vals.startTime)
const end = parseLocalDateTime(vals.endDate, vals.endTime)
if (!(end > start)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "End must be after start",
path: ["endTime"], // attach to a visible field
})
}
})

View File

@@ -1,60 +0,0 @@
import { z } from "zod";
export const courseEventAttendeeSchema = z.object({
attendee_id: z.number({ invalid_type_error: "Must select a member" }).int().positive(),
passed_bookwork: z.boolean(),
passed_qual: z.boolean(),
remarks: z.string(),
attendee_role_id: z.number({ invalid_type_error: "Must select a role" }).int().positive()
})
export const trainingReportSchema = z.object({
id: z.number().int().positive().optional(),
course_id: z.number({ invalid_type_error: "Must select a training" }).int(),
event_date: z
.string()
.refine(
(val) => !isNaN(Date.parse(val)),
"Must be a valid date"
),
remarks: z.string().nullable().optional(),
attendees: z.array(courseEventAttendeeSchema).default([]),
}).superRefine((data, ctx) => {
const trainerRole = 1;
const traineeRole = 2;
const hasTrainer = data.attendees.some((a) => a.attendee_role_id === trainerRole);
const hasTrainee = data.attendees.some((a) => a.attendee_role_id === traineeRole);
if (!hasTrainer) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["attendees"],
message: "At least one Primary Trainer is required.",
});
}
if (!hasTrainee) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["attendees"],
message: "At least one Trainee is required.",
});
}
//no duplicates
const idCounts = new Map<number, number>();
data.attendees.forEach((a, index) => {
idCounts.set(a.attendee_id, (idCounts.get(a.attendee_id) ?? 0) + 1);
if (idCounts.get(a.attendee_id)! > 1) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["attendees"],
message: "Cannot have duplicate attendee.",
});
}
})
})

View File

@@ -1,17 +0,0 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"declaration": true,
"emitDeclarationOnly": true,
"outDir": "dist",
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"esModuleInterop": true,
"isolatedModules": true,
"resolveJsonModule": true
},
"include": ["./**/*.ts"]
}

View File

@@ -1,39 +0,0 @@
export interface CalendarEvent {
id?: number;
name: string;
start: Date;
end: Date;
location: string;
color: string;
description: string;
creator_id?: number;
cancelled?: boolean;
created_at?: Date;
updated_at?: Date;
creator_name?: string | null;
eventSignups?: CalendarSignup[] | null;
}
export enum CalendarAttendance {
Attending = "attending",
NotAttending = "not_attending",
Maybe = "maybe"
}
export interface CalendarSignup {
member_id: number;
eventID: number;
status: CalendarAttendance;
member_name?: string;
}
export interface CalendarEventShort {
id: number;
name: string;
start: Date;
end: Date;
color: string;
cancelled: boolean;
full_day: boolean;
}

View File

@@ -1,91 +0,0 @@
export interface Course {
id: number;
name: string;
short_name: string;
category: string;
description?: string | null;
image_url?: string | null;
created_at: Date;
updated_at: Date;
deleted?: number | boolean;
prereq_id?: number | null;
hasBookwork: boolean;
hasQual: boolean;
}
export interface CourseEventDetails {
id: number | null; // PK
course_id: number | null; // FK → courses.id
event_type: number | null; // FK → event_types.id
event_date: Date; // datetime (not nullable)
guilded_event_id: number | null;
created_at: Date; // datetime
updated_at: Date; // datetime
deleted: boolean | null; // tinyint(4), nullable
report_url: string | null; // varchar(2048)
remarks: string | null; // text
attendees: CourseAttendee[] | null;
created_by: number | null;
created_by_name: string | null;
course_name: string | null;
course: Course | null;
}
export interface CourseAttendee {
passed_bookwork: boolean; // tinyint(1)
passed_qual: boolean; // tinyint(1)
attendee_id: number; // PK
course_event_id: number; // PK
attendee_role_id: number | null;
role: CourseAttendeeRole | null;
created_at: Date; // datetime → ISO string
updated_at: Date; // datetime → ISO string
remarks: string | null;
attendee_name: string | null;
}
export interface CourseAttendeeRole {
id: number; // PK, auto-increment
name: string | null; // varchar(50), unique, nullable
description: string | null; // text
created_at: Date | null; // datetime (nullable)
updated_at: Date | null; // datetime (nullable)
deleted: boolean; // tinyint(4)
}
export interface RawAttendeeRow {
passed_bookwork: number; // tinyint(1)
passed_qual: number; // tinyint(1)
attendee_id: number;
course_event_id: number;
attendee_role_id: number | null;
created_at: string;
updated_at: string;
remarks: string | null;
role_id: number | null;
role_name: string | null;
role_description: string | null;
role_deleted: number | null;
role_created_at: string | null;
role_updated_at: string | null;
attendee_name: string | null;
}
export interface CourseEventSummary {
event_id: number;
course_id: number;
course_name: string;
course_shortname: string;
date: string;
created_by: number;
created_by_name: string;
}

View File

@@ -1,6 +0,0 @@
export interface Role {
id: number;
name: string;
color?: string;
description?: string;
}

View File

@@ -1,12 +0,0 @@
export function toDateTime(date: Date): string {
console.log(date);
// This produces a CST-local time because server runs in CST
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, "0");
const day = date.getDate().toString().padStart(2, "0");
const hour = date.getHours().toString().padStart(2, "0");
const minute = date.getMinutes().toString().padStart(2, "0");
const second = date.getSeconds().toString().padStart(2, "0");
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
}

View File

@@ -1,7 +0,0 @@
# SITE SETTINGS
VITE_APIHOST=
VITE_ENVIRONMENT= # dev / prod
# Glitchtip
VITE_GLITCHTIP_DSN=
VITE_DISABLE_GLITCHTIP= # true/false

View File

@@ -2,7 +2,7 @@
"compilerOptions": { "compilerOptions": {
"paths": { "paths": {
"@/*": ["./src/*"], "@/*": ["./src/*"],
"@shared/*": ["../shared/*"] "@shared": ["../shared/*"]
} }
}, },
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]

106
ui/package-lock.json generated
View File

@@ -13,7 +13,6 @@
"@fullcalendar/interaction": "^6.1.19", "@fullcalendar/interaction": "^6.1.19",
"@fullcalendar/timegrid": "^6.1.19", "@fullcalendar/timegrid": "^6.1.19",
"@fullcalendar/vue3": "^6.1.19", "@fullcalendar/vue3": "^6.1.19",
"@sentry/vue": "^10.27.0",
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"@tanstack/vue-table": "^8.21.3", "@tanstack/vue-table": "^8.21.3",
"@vee-validate/zod": "^4.15.1", "@vee-validate/zod": "^4.15.1",
@@ -22,7 +21,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-vue-next": "^0.539.0", "lucide-vue-next": "^0.539.0",
"pinia": "^3.0.3", "pinia": "^3.0.3",
"reka-ui": "^2.6.1", "reka-ui": "^2.5.0",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.11", "tailwindcss": "^4.1.11",
"tw-animate-css": "^1.3.6", "tw-animate-css": "^1.3.6",
@@ -1393,103 +1392,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@sentry-internal/browser-utils": {
"version": "10.27.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.27.0.tgz",
"integrity": "sha512-17tO6AXP+rmVQtLJ3ROQJF2UlFmvMWp7/8RDT5x9VM0w0tY31z8Twc0gw2KA7tcDxa5AaHDUbf9heOf+R6G6ow==",
"license": "MIT",
"dependencies": {
"@sentry/core": "10.27.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/feedback": {
"version": "10.27.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.27.0.tgz",
"integrity": "sha512-UecsIDJcv7VBwycge/MDvgSRxzevDdcItE1i0KSwlPz00rVVxLY9kV28PJ4I2E7r6/cIaP9BkbWegCEcv09NuA==",
"license": "MIT",
"dependencies": {
"@sentry/core": "10.27.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/replay": {
"version": "10.27.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.27.0.tgz",
"integrity": "sha512-tKSzHq1hNzB619Ssrqo25cqdQJ84R3xSSLsUWEnkGO/wcXJvpZy94gwdoS+KmH18BB1iRRRGtnMxZcUkiPSesw==",
"license": "MIT",
"dependencies": {
"@sentry-internal/browser-utils": "10.27.0",
"@sentry/core": "10.27.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/replay-canvas": {
"version": "10.27.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.27.0.tgz",
"integrity": "sha512-inhsRYSVBpu3BI1kZphXj6uB59baJpYdyHeIPCiTfdFNBE5tngNH0HS/aedZ1g9zICw290lwvpuyrWJqp4VBng==",
"license": "MIT",
"dependencies": {
"@sentry-internal/replay": "10.27.0",
"@sentry/core": "10.27.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/browser": {
"version": "10.27.0",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.27.0.tgz",
"integrity": "sha512-G8q362DdKp9y1b5qkQEmhTFzyWTOVB0ps1rflok0N6bVA75IEmSDX1pqJsNuY3qy14VsVHYVwQBJQsNltQLS0g==",
"license": "MIT",
"dependencies": {
"@sentry-internal/browser-utils": "10.27.0",
"@sentry-internal/feedback": "10.27.0",
"@sentry-internal/replay": "10.27.0",
"@sentry-internal/replay-canvas": "10.27.0",
"@sentry/core": "10.27.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/core": {
"version": "10.27.0",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.27.0.tgz",
"integrity": "sha512-Zc68kdH7tWTDtDbV1zWIbo3Jv0fHAU2NsF5aD2qamypKgfSIMSbWVxd22qZyDBkaX8gWIPm/0Sgx6aRXRBXrYQ==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/vue": {
"version": "10.27.0",
"resolved": "https://registry.npmjs.org/@sentry/vue/-/vue-10.27.0.tgz",
"integrity": "sha512-vQVxnw59jRe5WsdB9ad/WpMPQ93QXE6Y0JEy01xIRcDlQ1pXp5wuxLkKGuTfvjdQzVUGIBLr0CgIqRAmPRymVg==",
"license": "MIT",
"dependencies": {
"@sentry/browser": "10.27.0",
"@sentry/core": "10.27.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"pinia": "2.x || 3.x",
"vue": "2.x || 3.x"
},
"peerDependenciesMeta": {
"pinia": {
"optional": true
}
}
},
"node_modules/@sindresorhus/merge-streams": { "node_modules/@sindresorhus/merge-streams": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz",
@@ -3333,9 +3235,9 @@
} }
}, },
"node_modules/reka-ui": { "node_modules/reka-ui": {
"version": "2.6.1", "version": "2.5.0",
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.6.1.tgz", "resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.5.0.tgz",
"integrity": "sha512-XK7cJDQoNuGXfCNzBBo/81Yg/OgjPwvbabnlzXG2VsdSgNsT6iIkuPBPr+C0Shs+3bb0x0lbPvgQAhMSCKm5Ww==", "integrity": "sha512-81aMAmJeVCy2k0E6x7n1kypDY6aM1ldLis5+zcdV1/JtoAlSDck5OBsyLRJU9CfgbrQp1ImnRnBSmC4fZ2fkZQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@floating-ui/dom": "^1.6.13", "@floating-ui/dom": "^1.6.13",

View File

@@ -17,7 +17,6 @@
"@fullcalendar/interaction": "^6.1.19", "@fullcalendar/interaction": "^6.1.19",
"@fullcalendar/timegrid": "^6.1.19", "@fullcalendar/timegrid": "^6.1.19",
"@fullcalendar/vue3": "^6.1.19", "@fullcalendar/vue3": "^6.1.19",
"@sentry/vue": "^10.27.0",
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"@tanstack/vue-table": "^8.21.3", "@tanstack/vue-table": "^8.21.3",
"@vee-validate/zod": "^4.15.1", "@vee-validate/zod": "^4.15.1",
@@ -26,7 +25,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-vue-next": "^0.539.0", "lucide-vue-next": "^0.539.0",
"pinia": "^3.0.3", "pinia": "^3.0.3",
"reka-ui": "^2.6.1", "reka-ui": "^2.5.0",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.11", "tailwindcss": "^4.1.11",
"tw-animate-css": "^1.3.6", "tw-animate-css": "^1.3.6",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

View File

@@ -1,13 +1,39 @@
<script setup> <script setup>
import { RouterView } from 'vue-router'; import { RouterLink, RouterView } from 'vue-router';
import Separator from './components/ui/separator/Separator.vue';
import Button from './components/ui/button/Button.vue'; import Button from './components/ui/button/Button.vue';
import { Popover, PopoverContent, PopoverTrigger } from './components/ui/popover';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from './components/ui/dropdown-menu';
import { onMounted } from 'vue';
import { useUserStore } from './stores/user'; import { useUserStore } from './stores/user';
import Alert from './components/ui/alert/Alert.vue'; import Alert from './components/ui/alert/Alert.vue';
import AlertDescription from './components/ui/alert/AlertDescription.vue'; import AlertDescription from './components/ui/alert/AlertDescription.vue';
import Navbar from './components/Navigation/Navbar.vue';
const userStore = useUserStore(); const userStore = useUserStore();
// onMounted(async () => {
// const res = await fetch(`${import.meta.env.VITE_APIHOST}/members/me`, {
// credentials: 'include',
// });
// const data = await res.json();
// console.log(data);
// userStore.user = data;
// });
async function logout() {
await fetch(`${import.meta.env.VITE_APIHOST}/logout`, {
method: 'POST',
credentials: 'include',
});
userStore.user = null;
}
function formatDate(dateStr) { function formatDate(dateStr) {
if (!dateStr) return ""; if (!dateStr) return "";
return new Date(dateStr).toLocaleDateString("en-US", { return new Date(dateStr).toLocaleDateString("en-US", {
@@ -16,28 +42,88 @@ function formatDate(dateStr) {
day: "numeric", day: "numeric",
}); });
} }
const environment = import.meta.env.VITE_ENVIRONMENT;
</script> </script>
<template> <template>
<div class="flex flex-col min-h-screen"> <div>
<div class="sticky top-0 bg-background z-50"> <div class="flex items-center justify-between px-10">
<Navbar class="flex"></Navbar> <div></div>
<Alert v-if="environment == 'dev'" class="m-2 mx-auto w-5xl" variant="info"> <div class="h-15 flex items-center justify-center gap-20">
<AlertDescription class="flex flex-row items-center text-nowrap gap-5 mx-auto"> <RouterLink to="/">
<p>This is a development build of the application. Some features will be unavailable or unstable.</p> <Button variant="link">Home</Button>
</AlertDescription> </RouterLink>
</Alert> <RouterLink to="/calendar">
<Alert v-if="userStore.user?.loa?.[0]" class="m-2 mx-auto w-5xl" variant="info"> <Button variant="link">Calendar</Button>
<AlertDescription class="flex flex-row items-center text-nowrap gap-5 mx-auto"> </RouterLink>
<p>You are on LOA until <strong>{{ formatDate(userStore.user?.loa?.[0].end_date) }}</strong></p> <RouterLink to="/members">
<Button variant="secondary">End LOA</Button> <Button variant="link">Members</Button>
</AlertDescription> </RouterLink>
</Alert> <Popover>
</div> <PopoverTrigger as-child>
<Button variant="link">Forms</Button>
</PopoverTrigger>
<PopoverContent class="flex flex-col gap-4 items-center w-min">
<RouterLink to="/transfer">
<Button variant="link">Transfer Request</Button>
</RouterLink>
<RouterLink to="/trainingReport">
<Button variant="link">Training Report</Button>
</RouterLink>
</PopoverContent>
</Popover>
<Popover>
<PopoverTrigger as-child>
<Button variant="link">Administration</Button>
</PopoverTrigger>
<PopoverContent class="flex flex-col gap-4 items-center w-min">
<RouterLink to="/administration/rankChange">
<Button variant="link">Promotions</Button>
</RouterLink>
<RouterLink to="/administration/loa">
<Button variant="link">Leave of Absence</Button>
</RouterLink>
<RouterLink to="/administration/transfer">
<Button variant="link">Transfer Requests</Button>
</RouterLink>
<RouterLink to="/administration/applications">
<Button variant="link">Recruitment</Button>
</RouterLink>
<RouterLink to="/administration/roles">
<Button variant="link">Role Management</Button>
</RouterLink>
</PopoverContent>
</Popover>
<RouterView class="flex-1 min-h-0"></RouterView> </div>
<div>
<DropdownMenu v-if="userStore.isLoggedIn">
<DropdownMenuTrigger class="cursor-pointer">
<p>{{ userStore.user.name }}</p>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>My Profile</DropdownMenuItem>
<DropdownMenuItem>Settings</DropdownMenuItem>
<DropdownMenuItem>
<RouterLink to="/loa">
Submit LOA
</RouterLink>
</DropdownMenuItem>
<DropdownMenuItem :variant="'destructive'" @click="logout()">Logout</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<a v-else href="https://aj17thdevapi.nexuszone.net/login">Login</a>
</div>
</div>
<Separator></Separator>
<Alert v-if="userStore.user?.loa?.[0]" class="m-2 mx-auto w-5xl" variant="info">
<AlertDescription class="flex flex-row items-center text-nowrap gap-5 mx-auto">
<p>You are on LOA until <strong>{{ formatDate(userStore.user?.loa?.[0].end_date) }}</strong></p>
<Button variant="secondary">End LOA</Button>
</AlertDescription>
</Alert>
<RouterView class=""></RouterView>
</div> </div>
</template> </template>

View File

@@ -74,7 +74,7 @@ export enum ApplicationStatus {
const addr = import.meta.env.VITE_APIHOST; const addr = import.meta.env.VITE_APIHOST;
export async function loadApplication(id: number | string): Promise<ApplicationFull | null> { export async function loadApplication(id: number | string): Promise<ApplicationFull | null> {
const res = await fetch(`${addr}/application/${id}`, { credentials: 'include' }) const res = await fetch(`${addr}/application/${id}`)
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()

View File

@@ -1,13 +1,13 @@
// export interface CalendarEvent { export interface CalendarEvent {
// name: string, name: string,
// start: Date, start: Date,
// end: Date, end: Date,
// location: string, location: string,
// color: string, color: string,
// description: string, description: string,
// creator: any | null, // user object creator: any | null, // user object
// id: number | null id: number | null
// } }
export enum CalendarAttendance { export enum CalendarAttendance {
Attending = "attending", Attending = "attending",
@@ -21,107 +21,22 @@ export interface CalendarSignup {
state: CalendarAttendance state: CalendarAttendance
} }
import { CalendarEventShort, CalendarEvent } from "@shared/types/calendar";
//@ts-ignore
const addr = import.meta.env.VITE_APIHOST;
export async function getMonthCalendarEvents(viewedMonth: Date): Promise<CalendarEventShort[]> {
const year = viewedMonth.getFullYear();
const month = viewedMonth.getMonth();
// Base range: first and last day of the month
const firstOfMonth = new Date(year, month, 1);
const lastOfMonth = new Date(year, month + 1, 0);
// --- Apply 10 day padding ---
const start = new Date(firstOfMonth);
start.setDate(start.getDate() - 10);
const end = new Date(lastOfMonth);
end.setDate(end.getDate() + 10);
end.setHours(23, 59, 59, 999);
const from = start.toISOString();
const to = end.toISOString();
const url = `${addr}/calendar?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`;
const res = await fetch(url);
if (!res.ok) {
throw new Error(`Failed to fetch events: ${res.status} ${res.statusText}`);
}
return res.json();
}
export async function getCalendarEvent(id: number): Promise<CalendarEvent> {
let res = await fetch(`${addr}/calendar/${id}`);
if (res.ok) {
return await res.json();
} else {
throw new Error(`Failed to fetch event: ${res.status} ${res.statusText}`);
}
}
export async function createCalendarEvent(eventData: CalendarEvent) { export async function createCalendarEvent(eventData: CalendarEvent) {
let res = await fetch(`${addr}/calendar`, {
method: "POST",
credentials: "include",
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(eventData)
});
if (res.ok) {
return;
} else {
throw new Error(`Failed to set attendance: ${res.status} ${res.statusText}`);
}
} }
export async function editCalendarEvent(eventData: CalendarEvent) { export async function editCalendarEvent(eventData: CalendarEvent) {
let res = await fetch(`${addr}/calendar`, {
method: "PUT",
credentials: "include",
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(eventData)
});
if (res.ok) {
return;
} else {
throw new Error(`Failed to set attendance: ${res.status} ${res.statusText}`);
}
} }
export async function setCancelCalendarEvent(eventID: number, cancel: boolean) { export async function cancelCalendarEvent(eventID: number) {
let route = cancel ? "cancel" : "uncancel";
console.log(route); }
let res = await fetch(`${addr}/calendar/${eventID}/${route}`, {
method: "POST",
credentials: "include"
});
if (res.ok) { export async function adminCancelCalendarEvent(eventID: number) {
return;
} else {
throw new Error(`Failed to set attendance: ${res.status} ${res.statusText}`);
}
} }
export async function setCalendarEventAttendance(eventID: number, state: CalendarAttendance) { export async function setCalendarEventAttendance(eventID: number, state: CalendarAttendance) {
let res = await fetch(`${addr}/calendar/${eventID}/attendance?state=${state}`, {
method: "POST",
credentials: "include",
});
if (res.ok) {
return;
} else {
throw new Error(`Failed to set attendance: ${res.status} ${res.statusText}`);
}
} }

View File

@@ -1,66 +0,0 @@
import { Course, CourseAttendeeRole, CourseEventDetails, CourseEventSummary } from '@shared/types/course'
//@ts-ignore
const addr = import.meta.env.VITE_APIHOST;
export async function getTrainingReports(sortMode: string, search: string): Promise<CourseEventSummary[]> {
const res = await fetch(`${addr}/courseEvent?sort=${sortMode}&search=${search}`);
if (res.ok) {
return await res.json() as Promise<CourseEventSummary[]>;
} else {
console.error("Something went wrong");
throw new Error("Failed to load training reports");
}
}
export async function getTrainingReport(id: number): Promise<CourseEventDetails> {
const res = await fetch(`${addr}/courseEvent/${id}`);
if (res.ok) {
return await res.json() as Promise<CourseEventDetails>;
} else {
console.error("Something went wrong");
throw new Error("Failed to load training reports");
}
}
export async function getAllTrainings(): Promise<Course[]> {
const res = await fetch(`${addr}/course`);
if (res.ok) {
return await res.json() as Promise<Course[]>;
} else {
console.error("Something went wrong");
throw new Error("Failed to load training list");
}
}
export async function getAllAttendeeRoles(): Promise<CourseAttendeeRole[]> {
const res = await fetch(`${addr}/course/roles`);
if (res.ok) {
return await res.json() as Promise<CourseAttendeeRole[]>;
} else {
console.error("Something went wrong");
throw new Error("Failed to load attendee roles");
}
}
export async function postTrainingReport(report: CourseEventDetails) {
const res = await fetch(`${addr}/courseEvent`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(report),
credentials: 'include',
});
if (!res.ok) {
const errorText = await res.text();
throw new Error(`Failed to post training report: ${res.status} ${errorText}`);
}
return res.json(); // expected to return the inserted report or new ID
}

View File

@@ -4,55 +4,6 @@
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
:root { :root {
--background: oklch(0.2046 0 0);
--foreground: oklch(0.9219 0 0);
--card: oklch(23.075% 0.00003 271.152);
--card-foreground: oklch(0.9219 0 0);
--popover: oklch(0.2686 0 0);
--popover-foreground: oklch(0.9219 0 0);
--primary: oklch(0.7686 0.1647 70.0804);
--primary-foreground: oklch(0 0 0);
--secondary: oklch(0.2686 0 0);
--secondary-foreground: oklch(0.9219 0 0);
--muted: oklch(0.2686 0 0);
--muted-foreground: oklch(0.7155 0 0);
--accent: oklch(100% 0.00011 271.152 / 0.253);
--accent-foreground: oklch(100% 0.00011 271.152);
--destructive: oklch(0.6368 0.2078 25.3313);
--destructive-foreground: oklch(1.0000 0 0);
--success: oklch(66.104% 0.16937 144.153);
--success-foreground: oklch(1.0000 0 0);
--border: oklch(0.3715 0 0);
--input: oklch(0.3715 0 0);
--ring: oklch(0.7686 0.1647 70.0804);
--chart-1: oklch(0.8369 0.1644 84.4286);
--chart-2: oklch(0.6658 0.1574 58.3183);
--chart-3: oklch(0.4732 0.1247 46.2007);
--chart-4: oklch(0.5553 0.1455 48.9975);
--chart-5: oklch(0.4732 0.1247 46.2007);
--sidebar: oklch(0.1684 0 0);
--sidebar-foreground: oklch(0.9219 0 0);
--sidebar-primary: oklch(0.7686 0.1647 70.0804);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.4732 0.1247 46.2007);
--sidebar-accent-foreground: oklch(0.9243 0.1151 95.7459);
--sidebar-border: oklch(0.3715 0 0);
--sidebar-ring: oklch(0.7686 0.1647 70.0804);
--font-sans: Inter, sans-serif;
--font-serif: Source Serif 4, serif;
--font-mono: JetBrains Mono, monospace;
--radius: 0.375rem;
--shadow-2xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.07);
--shadow-xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.07);
--shadow-sm: 0px 4px 8px -1px hsl(0 0% 0% / 0.14), 0px 1px 2px -2px hsl(0 0% 0% / 0.14);
--shadow: 0px 4px 8px -1px hsl(0 0% 0% / 0.14), 0px 1px 2px -2px hsl(0 0% 0% / 0.14);
--shadow-md: 0px 4px 8px -1px hsl(0 0% 0% / 0.14), 0px 2px 4px -2px hsl(0 0% 0% / 0.14);
--shadow-lg: 0px 4px 8px -1px hsl(0 0% 0% / 0.14), 0px 4px 6px -2px hsl(0 0% 0% / 0.14);
--shadow-xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.14), 0px 8px 10px -2px hsl(0 0% 0% / 0.14);
--shadow-2xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.35);
}
/* .dark {
--background: oklch(0.2046 0 0); --background: oklch(0.2046 0 0);
--foreground: oklch(0.9219 0 0); --foreground: oklch(0.9219 0 0);
--card: oklch(23.075% 0.00003 271.152); --card: oklch(23.075% 0.00003 271.152);
@@ -99,7 +50,56 @@
--shadow-lg: 0px 4px 8px -1px hsl(0 0% 0% / 0.14), 0px 4px 6px -2px hsl(0 0% 0% / 0.14); --shadow-lg: 0px 4px 8px -1px hsl(0 0% 0% / 0.14), 0px 4px 6px -2px hsl(0 0% 0% / 0.14);
--shadow-xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.14), 0px 8px 10px -2px hsl(0 0% 0% / 0.14); --shadow-xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.14), 0px 8px 10px -2px hsl(0 0% 0% / 0.14);
--shadow-2xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.35); --shadow-2xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.35);
} */ }
.dark {
--background: oklch(0.2046 0 0);
--foreground: oklch(0.9219 0 0);
--card: oklch(23.075% 0.00003 271.152);
--card-foreground: oklch(0.9219 0 0);
--popover: oklch(0.2686 0 0);
--popover-foreground: oklch(0.9219 0 0);
--primary: oklch(0.7686 0.1647 70.0804);
--primary-foreground: oklch(0 0 0);
--secondary: oklch(0.2686 0 0);
--secondary-foreground: oklch(0.9219 0 0);
--muted: oklch(0.2686 0 0);
--muted-foreground: oklch(0.7155 0 0);
--accent: oklch(0.4732 0.1247 46.2007);
--accent-foreground: oklch(0.9243 0.1151 95.7459);
--destructive: oklch(0.6368 0.2078 25.3313);
--destructive-foreground: oklch(1.0000 0 0);
--success: oklch(66.104% 0.16937 144.153);
--success-foreground: oklch(1.0000 0 0);
--border: oklch(0.3715 0 0);
--input: oklch(0.3715 0 0);
--ring: oklch(0.7686 0.1647 70.0804);
--chart-1: oklch(0.8369 0.1644 84.4286);
--chart-2: oklch(0.6658 0.1574 58.3183);
--chart-3: oklch(0.4732 0.1247 46.2007);
--chart-4: oklch(0.5553 0.1455 48.9975);
--chart-5: oklch(0.4732 0.1247 46.2007);
--sidebar: oklch(0.1684 0 0);
--sidebar-foreground: oklch(0.9219 0 0);
--sidebar-primary: oklch(0.7686 0.1647 70.0804);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.4732 0.1247 46.2007);
--sidebar-accent-foreground: oklch(0.9243 0.1151 95.7459);
--sidebar-border: oklch(0.3715 0 0);
--sidebar-ring: oklch(0.7686 0.1647 70.0804);
--font-sans: Inter, sans-serif;
--font-serif: Source Serif 4, serif;
--font-mono: JetBrains Mono, monospace;
--radius: 0.375rem;
--shadow-2xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.07);
--shadow-xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.07);
--shadow-sm: 0px 4px 8px -1px hsl(0 0% 0% / 0.14), 0px 1px 2px -2px hsl(0 0% 0% / 0.14);
--shadow: 0px 4px 8px -1px hsl(0 0% 0% / 0.14), 0px 1px 2px -2px hsl(0 0% 0% / 0.14);
--shadow-md: 0px 4px 8px -1px hsl(0 0% 0% / 0.14), 0px 2px 4px -2px hsl(0 0% 0% / 0.14);
--shadow-lg: 0px 4px 8px -1px hsl(0 0% 0% / 0.14), 0px 4px 6px -2px hsl(0 0% 0% / 0.14);
--shadow-xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.14), 0px 8px 10px -2px hsl(0 0% 0% / 0.14);
--shadow-2xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.35);
}
@theme inline { @theme inline {

View File

@@ -1,181 +0,0 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router';
import Separator from '../ui/separator/Separator.vue';
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
import { useUserStore } from '@/stores/user';
import Button from '../ui/button/Button.vue';
import NavigationMenu from '../ui/navigation-menu/NavigationMenu.vue';
import NavigationMenuList from '../ui/navigation-menu/NavigationMenuList.vue';
import NavigationMenuItem from '../ui/navigation-menu/NavigationMenuItem.vue';
import NavigationMenuLink from '../ui/navigation-menu/NavigationMenuLink.vue';
import NavigationMenuTrigger from '../ui/navigation-menu/NavigationMenuTrigger.vue';
import NavigationMenuContent from '../ui/navigation-menu/NavigationMenuContent.vue';
import { navigationMenuTriggerStyle } from '../ui/navigation-menu/'
import { useAuth } from '@/composables/useAuth';
import { CircleArrowOutUpRight } from 'lucide-vue-next';
const userStore = useUserStore();
const auth = useAuth();
//@ts-ignore
const APIHOST = import.meta.env.VITE_APIHOST;
async function logout() {
userStore.user = null;
window.location.href = APIHOST + "/logout";
}
function blurAfter() {
requestAnimationFrame(() => {
(document.activeElement as HTMLElement)?.blur();
});
}
</script>
<template>
<div class="w-full border-b">
<div class="max-w-screen-3xl w-full mx-auto flex items-center justify-between pr-10 pl-7">
<!-- left side -->
<div class="flex items-center gap-7">
<RouterLink to="/">
<img src="/17RBN_Logo.png" class="w-10 h-10 object-contain"></img>
</RouterLink>
<!-- Member navigation -->
<div v-if="auth.accountStatus.value == 'member'" class="h-15 flex items-center justify-center">
<NavigationMenu>
<NavigationMenuList class="gap-3">
<!-- Calendar -->
<NavigationMenuItem>
<NavigationMenuLink as-child :class="navigationMenuTriggerStyle()">
<RouterLink to="/calendar" @click="blurAfter">Calendar</RouterLink>
</NavigationMenuLink>
</NavigationMenuItem>
<!-- Members -->
<NavigationMenuItem>
<NavigationMenuLink as-child :class="navigationMenuTriggerStyle()">
<RouterLink to="/" @click="blurAfter">Documents</RouterLink>
</NavigationMenuLink>
</NavigationMenuItem>
<!-- Forms (Dropdown) -->
<NavigationMenuItem class="bg-none !focus:bg-none !active:bg-none">
<NavigationMenuTrigger>Forms</NavigationMenuTrigger>
<NavigationMenuContent
class="grid gap-1 p-2 text-left [&_a]:w-full [&_a]:block [&_a]:whitespace-nowrap *:bg-transparent">
<NavigationMenuLink as-child :class="navigationMenuTriggerStyle()">
<RouterLink to="/loa" @click="blurAfter">
Leave of Absence
</RouterLink>
</NavigationMenuLink>
<NavigationMenuLink as-child :class="navigationMenuTriggerStyle()">
<RouterLink to="/transfer" @click="blurAfter">
Transfer Request
</RouterLink>
</NavigationMenuLink>
<NavigationMenuLink as-child :class="navigationMenuTriggerStyle()">
<RouterLink to="/trainingReport" @click="blurAfter">
Training Report
</RouterLink>
</NavigationMenuLink>
</NavigationMenuContent>
</NavigationMenuItem>
<!-- Administration (Dropdown) -->
<NavigationMenuItem
v-if="auth.hasAnyRole(['17th Administrator', '17th HQ', '17th Command', 'Recruiter'])">
<NavigationMenuTrigger>Administration</NavigationMenuTrigger>
<NavigationMenuContent
class="grid gap-1 p-2 text-left [&_a]:w-full [&_a]:block [&_a]:whitespace-nowrap *:bg-transparent">
<NavigationMenuLink
v-if="auth.hasAnyRole(['17th Administrator', '17th HQ', '17th Command'])"
as-child :class="navigationMenuTriggerStyle()">
<RouterLink to="/administration/rankChange" @click="blurAfter">
Promotions
</RouterLink>
</NavigationMenuLink>
<NavigationMenuLink
v-if="auth.hasAnyRole(['17th Administrator', '17th HQ', '17th Command'])"
as-child :class="navigationMenuTriggerStyle()">
<RouterLink to="/administration/loa" @click="blurAfter">
Leave of Absence
</RouterLink>
</NavigationMenuLink>
<NavigationMenuLink
v-if="auth.hasAnyRole(['17th Administrator', '17th HQ', '17th Command'])"
as-child :class="navigationMenuTriggerStyle()">
<RouterLink to="/administration/transfer" @click="blurAfter">
Transfer Requests
</RouterLink>
</NavigationMenuLink>
<NavigationMenuLink v-if="auth.hasRole('Recruiter')" as-child
:class="navigationMenuTriggerStyle()">
<RouterLink to="/administration/applications" @click="blurAfter">
Recruitment
</RouterLink>
</NavigationMenuLink>
<NavigationMenuLink v-if="auth.hasRole('17th Administrator')" as-child
:class="navigationMenuTriggerStyle()">
<RouterLink to="/administration/roles" @click="blurAfter">
Role Management
</RouterLink>
</NavigationMenuLink>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem as-child :class="navigationMenuTriggerStyle()">
<RouterLink to="/members" @click="blurAfter">
Members (debug)
</RouterLink>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
</div>
<!-- Guest navigation -->
<div v-else class="h-15 flex items-center justify-center">
<NavigationMenu>
<NavigationMenuList>
<NavigationMenuItem>
<NavigationMenuLink as-child :class="navigationMenuTriggerStyle()">
<RouterLink to="/join" @click="blurAfter">Join</RouterLink>
</NavigationMenuLink>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
</div>
</div>
<!-- right side -->
<div>
<DropdownMenu v-if="userStore.isLoggedIn">
<DropdownMenuTrigger class="cursor-pointer">
<p>{{ userStore.user.name }}</p>
</DropdownMenuTrigger>
<DropdownMenuContent>
<!-- <DropdownMenuItem>My Profile</DropdownMenuItem> -->
<!-- <DropdownMenuItem>Settings</DropdownMenuItem> -->
<DropdownMenuItem @click="$router.push('/join')">My Application</DropdownMenuItem>
<DropdownMenuItem :variant="'destructive'" @click="logout()">Logout</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<a v-else :href="APIHOST + '/login'">Login</a>
</div>
</div>
<!-- <Separator></Separator> -->
</div>
</template>

View File

@@ -38,7 +38,7 @@ function onSubmit(values: { text: string }, { resetForm }: { resetForm: () => vo
<Form :validation-schema="commentSchema" @submit="onSubmit"> <Form :validation-schema="commentSchema" @submit="onSubmit">
<FormField name="text" v-slot="{ componentField }"> <FormField name="text" v-slot="{ componentField }">
<FormItem> <FormItem>
<FormLabel>Comment</FormLabel> <FormLabel class="sr-only">Comment</FormLabel>
<FormControl> <FormControl>
<Textarea v-bind="componentField" rows="3" placeholder="Write a comment…" <Textarea v-bind="componentField" rows="3" placeholder="Write a comment…"
class="bg-neutral-800 resize-none w-full" /> class="bg-neutral-800 resize-none w-full" />

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { toTypedSchema } from "@vee-validate/zod" import { toTypedSchema } from "@vee-validate/zod"
import { ref, defineExpose, watch, nextTick } from "vue" import { ref, defineExpose, watch } from "vue"
import * as z from "zod" import * as z from "zod"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@@ -21,18 +21,11 @@ import {
} from "@/components/ui/form" } from "@/components/ui/form"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import Textarea from "../ui/textarea/Textarea.vue" import Textarea from "../ui/textarea/Textarea.vue"
import { CalendarEvent } from "@shared/types/calendar" import { CalendarEvent } from "@/api/calendar"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
// ---------- helpers ----------
const dateRe = /^\d{4}-\d{2}-\d{2}$/ // YYYY-MM-DD
const timeRe = /^(?:[01]\d|2[0-3]):[0-5]\d$/ // HH:mm (24h)
function toLocalDateString(d: Date) { function toLocalDateString(d: Date) {
// yyyy-MM-dd with local time zone // yyyy-MM-dd with local time zone
@@ -52,50 +45,45 @@ function roundToNextHour(d = new Date()) {
t.setHours(t.getHours() + 1) t.setHours(t.getHours() + 1)
return t return t
} }
function parseLocalDateTime(dateStr: string, timeStr: string) {
// Construct a Date in the user's local timezone
const [y, m, d] = dateStr.split("-").map(Number)
const [hh, mm] = timeStr.split(":").map(Number)
return new Date(y, m - 1, d, hh, mm, 0, 0)
}
import { calendarEventSchema, parseLocalDateTime } from '@shared/schemas/calendarEventSchema' // ---------- schema ----------
import { createCalendarEvent, editCalendarEvent } from "@/api/calendar" const zEvent = z.object({
import DialogDescription from "../ui/dialog/DialogDescription.vue" name: z.string().min(2, "Please enter at least 2 characters").max(100),
const formSchema = toTypedSchema(calendarEventSchema) startDate: z.string().regex(dateRe, "Use YYYY-MM-DD"),
startTime: z.string().regex(timeRe, "Use HH:mm (24h)"),
endDate: z.string().regex(dateRe, "Use YYYY-MM-DD"),
endTime: z.string().regex(timeRe, "Use HH:mm (24h)"),
location: z.string().max(200).default(""),
color: z.string().regex(/^#([0-9A-Fa-f]{6})$/, "Use a hex color like #AABBCC"),
description: z.string().max(2000).default(""),
id: z.number().int().nonnegative().nullable().default(null),
}).superRefine((vals, ctx) => {
const start = parseLocalDateTime(vals.startDate, vals.startTime)
const end = parseLocalDateTime(vals.endDate, vals.endTime)
if (!(end > start)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "End must be after start",
path: ["endTime"], // attach to a visible field
})
}
})
const formSchema = toTypedSchema(zEvent)
// ---------- dialog state & defaults ---------- // ---------- dialog state & defaults ----------
const clickedDate = ref<string | null>(null);
const dialogOpen = ref(false) const dialogOpen = ref(false)
const dialogMode = ref<'create' | 'edit'>('create'); function openDialog() { dialogOpen.value = true }
const editEvent = ref<CalendarEvent | null>();
function openDialog(dateStr?: string, mode?: 'create' | 'edit', event?: CalendarEvent) {
dialogMode.value = mode ?? 'create';
editEvent.value = event ?? null;
clickedDate.value = dateStr ?? null;
dialogOpen.value = true
initialValues.value = makeInitialValues()
}
defineExpose({ openDialog }) defineExpose({ openDialog })
function makeInitialValues() { function makeInitialValues() {
const start = roundToNextHour()
if (dialogMode.value === 'edit' && editEvent.value) {
const e = editEvent.value;
return {
name: e.name,
startDate: toLocalDateString(new Date(e.start)),
startTime: toLocalTimeString(new Date(e.start)),
endDate: toLocalDateString(new Date(e.end)),
endTime: toLocalTimeString(new Date(e.end)),
location: e.location,
color: e.color,
description: e.description,
id: e.id,
}
}
let start: Date;
if (clickedDate.value) {
const local = new Date(clickedDate.value + "T20:00:00");
start = local;
} else {
start = roundToNextHour();
}
const end = new Date(start.getTime() + 60 * 60 * 1000) const end = new Date(start.getTime() + 60 * 60 * 1000)
return { return {
name: "", name: "",
@@ -104,77 +92,50 @@ function makeInitialValues() {
endDate: toLocalDateString(end), endDate: toLocalDateString(end),
endTime: toLocalTimeString(end), endTime: toLocalTimeString(end),
location: "", location: "",
color: "#6890ee", color: "#3b82f6",
description: "", description: "",
id: null as number | null, id: null as number | null,
} }
} }
const initialValues = ref(null) const initialValues = ref(makeInitialValues())
const formKey = ref(0) const formKey = ref(0)
watch(dialogOpen, async (isOpen) => { watch(dialogOpen, (isOpen) => {
if (isOpen) { if (!isOpen) {
await nextTick(); formKey.value++ // remounts the form -> picks up fresh initialValues
formRef.value?.resetForm({ values: makeInitialValues() })
} }
}) })
// ---------- submit ---------- // ---------- submit ----------
async function onSubmit(vals: z.infer<typeof calendarEventSchema>) { function onSubmit(vals: z.infer<typeof zEvent>) {
const start = parseLocalDateTime(vals.startDate, vals.startTime) const start = parseLocalDateTime(vals.startDate, vals.startTime)
const end = parseLocalDateTime(vals.endDate, vals.endTime) const end = parseLocalDateTime(vals.endDate, vals.endTime)
const event: CalendarEvent = { const event: CalendarEvent = {
id: vals.id ?? null,
name: vals.name, name: vals.name,
start, start,
end, end,
location: vals.location, location: vals.location,
color: vals.color, color: vals.color,
description: vals.description, description: vals.description,
id: null,
creator: null
} }
try { console.log("Submitting CalendarEvent:", event)
if (dialogMode.value === "edit") {
await editCalendarEvent(event);
} else {
await createCalendarEvent(event);
}
emit('reload');
} catch (error) {
console.error(error);
}
// close after success // close after success
dialogOpen.value = false dialogOpen.value = false
} }
const emit = defineEmits<{
(e: 'reload'): void
}>()
const formRef = ref(null);
const colorOptions = [
{ name: "Blue", hex: "#6890ee" },
{ name: "Purple", hex: "#a25fce" },
{ name: "Orange", hex: "#fba037" },
{ name: "Green", hex: "#6cd265" },
{ name: "Red", hex: "#ff5959" },
];
function getColorName(hex: string) {
return colorOptions.find(c => c.hex === hex)?.name ?? hex
}
</script> </script>
<template> <template>
<Form ref="formRef" :key="formKey" v-slot="{ handleSubmit, resetForm }" :validation-schema="formSchema" <Form :key="formKey" v-slot="{ handleSubmit, resetForm }" :validation-schema="formSchema"
:initial-values="initialValues" keep-values as=""> :initial-values="initialValues" keep-values as="">
<Dialog v-model:open="dialogOpen"> <Dialog v-model:open="dialogOpen">
<DialogContent class="sm:max-w-[520px]"> <DialogContent class="sm:max-w-[520px]">
<DialogHeader> <DialogHeader>
<DialogTitle>{{ dialogMode == "edit" ? 'Edit Event' : 'Create Event' }}</DialogTitle> <DialogTitle>Create Event</DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader> </DialogHeader>
<form id="dialogForm" class="grid grid-cols-1 gap-4" <form id="dialogForm" class="grid grid-cols-1 gap-4"
@@ -189,48 +150,21 @@ function getColorName(hex: string) {
<FormControl> <FormControl>
<Input type="text" v-bind="componentField" /> <Input type="text" v-bind="componentField" />
</FormControl> </FormControl>
<div class="h-3"> <FormMessage />
<FormMessage />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
</div> </div>
<!-- Color --> <!-- Color -->
<div class="w-[120px]"> <div class="w-[60px]">
<FormField v-slot="{ componentField }" name="color"> <FormField v-slot="{ componentField }" name="color">
<FormItem> <FormItem>
<FormLabel>Color</FormLabel> <FormLabel>Color</FormLabel>
<FormControl> <FormControl>
<Select :modelValue="componentField.modelValue" <Input type="color" class="h-[38px] p-1 cursor-pointer"
@update:modelValue="componentField.onChange"> v-bind="componentField" />
<SelectTrigger>
<SelectValue asChild>
<template #default="{ selected }">
<div class="flex items-center gap-2 w-[70px]">
<span class="inline-block size-4 rounded"
:style="{ background: componentField.modelValue }">
</span>
<span>{{ getColorName(componentField.modelValue) }}</span>
</div>
</template>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem v-for="opt in colorOptions" :key="opt.hex" :value="opt.hex">
<div class="flex items-center gap-2">
<span class="inline-block size-4 rounded"
:style="{ background: opt.hex }"></span>
<span>{{ opt.name }}</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</FormControl> </FormControl>
<div class="h-3"> <FormMessage />
<FormMessage />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
</div> </div>
@@ -246,9 +180,7 @@ function getColorName(hex: string) {
<FormControl> <FormControl>
<Input type="date" v-bind="componentField" /> <Input type="date" v-bind="componentField" />
</FormControl> </FormControl>
<div class="h-3"> <FormMessage />
<FormMessage />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
@@ -259,9 +191,7 @@ function getColorName(hex: string) {
<Input type="text" v-bind="componentField" /> <Input type="text" v-bind="componentField" />
<!-- If you ever want native picker: type="time" --> <!-- If you ever want native picker: type="time" -->
</FormControl> </FormControl>
<div class="h-3"> <FormMessage />
<FormMessage />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
</div> </div>
@@ -274,9 +204,7 @@ function getColorName(hex: string) {
<FormControl> <FormControl>
<Input type="date" v-bind="componentField" /> <Input type="date" v-bind="componentField" />
</FormControl> </FormControl>
<div class="h-3"> <FormMessage />
<FormMessage />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
@@ -286,9 +214,7 @@ function getColorName(hex: string) {
<FormControl> <FormControl>
<Input type="text" v-bind="componentField" /> <Input type="text" v-bind="componentField" />
</FormControl> </FormControl>
<div class="h-3"> <FormMessage />
<FormMessage />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
</div> </div>
@@ -300,9 +226,7 @@ function getColorName(hex: string) {
<FormControl> <FormControl>
<Input type="text" v-bind="componentField" /> <Input type="text" v-bind="componentField" />
</FormControl> </FormControl>
<div class="h-3"> <FormMessage />
<FormMessage />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
@@ -312,11 +236,9 @@ function getColorName(hex: string) {
<FormItem> <FormItem>
<FormLabel>Description</FormLabel> <FormLabel>Description</FormLabel>
<FormControl> <FormControl>
<Textarea class="resize-none h-32 scrollbar-themed" v-bind="componentField" /> <Textarea class="resize-none h-32" v-bind="componentField" />
</FormControl> </FormControl>
<div class="h-3"> <FormMessage />
<FormMessage/>
</div>
</FormItem> </FormItem>
</FormField> </FormField>
@@ -327,39 +249,9 @@ function getColorName(hex: string) {
</form> </form>
<DialogFooter> <DialogFooter>
<Button type="submit" form="dialogForm">{{ dialogMode == "edit" ? 'Update' : 'Create' }}</Button> <Button type="submit" form="dialogForm">Create</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</Form> </Form>
</template> </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

@@ -1,226 +0,0 @@
<script setup lang="ts">
import type { CalendarEvent, CalendarSignup } from '@shared/types/calendar'
import { CircleAlert, Clock, EllipsisVertical, MapPin, User, X } from 'lucide-vue-next';
import { computed, onMounted, ref, watch } from 'vue';
import ButtonGroup from '../ui/button-group/ButtonGroup.vue';
import Button from '../ui/button/Button.vue';
import { CalendarAttendance, getCalendarEvent, setCalendarEventAttendance, setCancelCalendarEvent } from '@/api/calendar';
import { useUserStore } from '@/stores/user';
import { useRoute } from 'vue-router';
import DropdownMenu from '../ui/dropdown-menu/DropdownMenu.vue';
import DropdownMenuTrigger from '../ui/dropdown-menu/DropdownMenuTrigger.vue';
import DropdownMenuContent from '../ui/dropdown-menu/DropdownMenuContent.vue';
import DropdownMenuItem from '../ui/dropdown-menu/DropdownMenuItem.vue';
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 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(
() => route.params.id,
async (id) => {
if (!id) return;
activeEvent.value = await getCalendarEvent(Number(id));
loaded.value = true;
},
{ immediate: true }
);
const emit = defineEmits<{
(e: 'close'): void
(e: 'reload'): void
(e: 'edit', event: CalendarEvent): void
}>()
// const activeEvent = computed(() => props.event)
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, {
hour: 'numeric', minute: '2-digit'
})
const whenText = computed(() => {
if (!activeEvent.value?.start) return ''
const s = new Date(activeEvent.value.start)
const e = activeEvent.value?.end ? new Date(activeEvent.value.end) : null
return e
? `${startFmt.format(s)} ${endFmt.format(e)}`
: `${startFmt.format(s)}`
})
const attending = computed<CalendarSignup[]>(() => { return activeEvent.value.eventSignups.filter((s) => s.status == CalendarAttendance.Attending) })
const maybe = computed<CalendarSignup[]>(() => { return activeEvent.value.eventSignups.filter((s) => s.status == CalendarAttendance.Maybe) })
const declined = computed<CalendarSignup[]>(() => { return activeEvent.value.eventSignups.filter((s) => s.status == CalendarAttendance.NotAttending) })
const viewedState = ref<CalendarAttendance>(CalendarAttendance.Attending);
let user = useUserStore();
const myAttendance = computed<CalendarSignup | null>(() => {
return activeEvent.value.eventSignups.find(
(s) => s.member_id === user.user.id
) || null;
});
async function setAttendance(state: CalendarAttendance) {
await setCalendarEventAttendance(activeEvent.value.id, state);
//refresh event data
activeEvent.value = await getCalendarEvent(activeEvent.value.id);
}
const canEditEvent = computed(() => {
if (user.user.id == activeEvent.value.creator_id)
return true;
});
async function setCancel(isCancelled: boolean) {
await setCancelCalendarEvent(activeEvent.value.id, isCancelled);
emit("reload");
activeEvent.value = await getCalendarEvent(activeEvent.value.id);
}
async function forceReload() {
activeEvent.value = await getCalendarEvent(activeEvent.value.id);
}
defineExpose({forceReload})
</script>
<template>
<div v-if="loaded">
<!-- Header -->
<div class="flex items-center justify-between gap-3 border-b px-4 py-3">
<h2 class="text-lg font-semibold break-all">
{{ activeEvent?.name || 'Event' }}
</h2>
<div class="flex gap-4">
<DropdownMenu v-if="canEditEvent">
<DropdownMenuTrigger>
<button
class="inline-flex flex-none size-8 items-center justify-center cursor-pointer rounded-md hover:bg-muted/40 transition">
<EllipsisVertical class="size-6" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="emit('edit', activeEvent)">
Edit
</DropdownMenuItem>
<DropdownMenuItem v-if="activeEvent.cancelled"
@click="setCancel(false)">
Un-Cancel
</DropdownMenuItem>
<DropdownMenuItem v-else @click="setCancel(true)">
Cancel
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<button
class="inline-flex flex-none size-8 items-center justify-center rounded-md border hover:bg-muted/40 transition cursor-pointer"
aria-label="Close" @click="emit('close')">
<X class="size-4" />
</button>
</div>
</div>
<!-- Body -->
<div class="flex-1 flex flex-col items-center min-h-0 overflow-y-auto px-4 py-4 space-y-6 w-full">
<section v-if="activeEvent.cancelled == true" class="w-full">
<div class="flex p-2 rounded-md w-full bg-destructive gap-3">
<CircleAlert></CircleAlert> This event has been cancelled
</div>
</section>
<section class="w-full">
<ButtonGroup class="flex w-full">
<Button variant="outline"
:class="myAttendance?.status === CalendarAttendance.Attending ? 'border-2 border-primary text-primary' : ''"
@click="setAttendance(CalendarAttendance.Attending)">Going</Button>
<Button variant="outline"
:class="myAttendance?.status === CalendarAttendance.Maybe ? 'border-2 !border-l-2 border-primary text-primary' : ''"
@click="setAttendance(CalendarAttendance.Maybe)">Maybe</Button>
<Button variant="outline"
:class="myAttendance?.status === CalendarAttendance.NotAttending ? 'border-2 !border-l-2 border-primary text-primary' : ''"
@click="setAttendance(CalendarAttendance.NotAttending)">Declined</Button>
</ButtonGroup>
</section>
<!-- When -->
<section v-if="whenText" class="space-y-2 w-full">
<div class="inline-flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-sm">
<Clock class="size-4 opacity-80" />
<span class="font-medium">{{ whenText }}</span>
</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>
<!-- Description -->
<section class="space-y-2 w-full">
<p class="text-lg font-semibold">Description</p>
<p class="border bg-muted/50 px-3 py-2 rounded-lg min-h-24 my-2 whitespace-pre-line">
{{ activeEvent.description }}
</p>
</section>
<!-- Attendance -->
<section class="space-y-2 w-full">
<p class="text-lg font-semibold">Attendance</p>
<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">
<label
:class="viewedState === CalendarAttendance.Attending ? 'border-b-3 border-foreground' : 'mb-[2px]'"
@click="viewedState = CalendarAttendance.Attending">Going {{ attending.length }}</label>
<label
:class="viewedState === CalendarAttendance.Maybe ? 'border-b-3 border-foreground' : 'mb-[2px]'"
@click="viewedState = CalendarAttendance.Maybe">Maybe {{ maybe.length }}</label>
<label
:class="viewedState === CalendarAttendance.NotAttending ? 'border-b-3 border-foreground' : 'mb-[2px]'"
@click="viewedState = CalendarAttendance.NotAttending">Declined {{ declined.length
}}</label>
</div>
<div class="px-5 py-4 min-h-28">
<div v-if="viewedState === CalendarAttendance.Attending" v-for="person in attending">
<p>{{ person.member_name }}</p>
</div>
<div v-if="viewedState === CalendarAttendance.Maybe" v-for="person in maybe">
<p>{{ person.member_name }}</p>
</div>
<div v-if="viewedState === CalendarAttendance.NotAttending" v-for="person in declined">
<p>{{ person.member_name }}</p>
</div>
</div>
</div>
</section>
</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>
</template>

View File

@@ -101,7 +101,7 @@ function toMariaDBDatetime(date: Date): string {
</script> </script>
<template> <template>
<div class="flex flex-row-reverse gap-6 mx-auto w-full" :class="!adminMode ? 'max-w-5xl' : 'max-w-5xl'"> <div class="flex flex-row-reverse gap-6 mx-auto " :class="!adminMode ? 'max-w-5xl' : 'max-w-5xl'">
<div v-if="!adminMode" class="flex-1 flex space-x-4 rounded-md border p-4"> <div v-if="!adminMode" class="flex-1 flex space-x-4 rounded-md border p-4">
<div class="flex-2 space-y-1"> <div class="flex-2 space-y-1">
<p class="text-sm font-medium leading-none"> <p class="text-sm font-medium leading-none">

View File

@@ -1,309 +0,0 @@
<script setup lang="ts">
import { trainingReportSchema, courseEventAttendeeSchema } from '@shared/schemas/trainingReportSchema'
import { Course, CourseAttendee, CourseAttendeeRole, CourseEventDetails } from '@shared/types/course'
import { useForm, useFieldArray, FieldArray as VeeFieldArray, ErrorMessage, Field as VeeField } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { computed, onMounted, ref, watch } from 'vue'
import { getAllAttendeeRoles, getAllTrainings, postTrainingReport } from '@/api/trainingReport'
import { getMembers, Member } from '@/api/member'
import FieldGroup from '../ui/field/FieldGroup.vue'
import Field from '../ui/field/Field.vue'
import FieldLabel from '../ui/field/FieldLabel.vue'
import FieldError from '../ui/field/FieldError.vue'
import Button from '../ui/button/Button.vue'
import Textarea from '../ui/textarea/Textarea.vue'
import { Plus, X } from 'lucide-vue-next';
import FieldSet from '../ui/field/FieldSet.vue'
import FieldLegend from '../ui/field/FieldLegend.vue'
import FieldDescription from '../ui/field/FieldDescription.vue'
import Checkbox from '../ui/checkbox/Checkbox.vue'
import { configure } from 'vee-validate'
const { handleSubmit, resetForm, errors, values, setFieldValue } = useForm({
validationSchema: toTypedSchema(trainingReportSchema),
validateOnMount: false,
initialValues: {
course_id: null,
event_date: "",
remarks: "",
attendees: [],
}
})
// watch(errors, (newErrors) => {
// console.log(newErrors)
// }, { deep: true })
// watch(values, (newErrors) => {
// console.log(newErrors.attendees)
// }, { deep: true })
watch(() => values.course_id, (newCourseId, oldCourseId) => {
if (!oldCourseId) return;
values.attendees.forEach((a, index) => {
setFieldValue(`attendees[${index}].passed_bookwork`, false);
setFieldValue(`attendees[${index}].passed_qual`, false);
});
});
const submitForm = handleSubmit(onSubmit);
function toMySQLDateTime(date: Date): string {
return date
.toISOString() // 2025-11-19T00:00:00.000Z
.slice(0, 23) // keep milliseconds → 2025-11-19T00:00:00.000
.replace("T", " ") + "000"; // becomes → 2025-11-19 00:00:00.000000
}
function onSubmit(vals) {
try {
const clean: CourseEventDetails = {
...vals,
event_date: new Date(vals.event_date),
}
postTrainingReport(clean).then((newID) => {
emit("submit", newID);
});
} catch (err) {
console.error("There was an error submitting the training report", err);
}
}
const { remove, push, fields } = useFieldArray('attendees');
const selectedCourse = computed<Course | undefined>(() => { return trainings.value?.find(c => c.id == values.course_id) })
const trainings = ref<Course[] | null>(null);
const members = ref<Member[] | null>(null);
const eventRoles = ref<CourseAttendeeRole[] | null>(null);
const emit = defineEmits(['submit'])
onMounted(async () => {
trainings.value = await getAllTrainings();
members.value = await getMembers();
eventRoles.value = await getAllAttendeeRoles();
})
</script>
<template>
<form id="trainingForm" @submit.prevent="submitForm" class="flex flex-col gap-5">
<div class="flex gap-5">
<div class="flex-1">
<FieldGroup>
<VeeField v-slot="{ field, errors }" name="course_id">
<Field :data-invalid="!!errors.length">
<FieldLabel class="scroll-m-20 text-lg tracking-tight">Training Course</FieldLabel>
<select v-bind="field"
class="h-9 border rounded p-2 w-auto focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] bg-background outline-none">
<option value="" disabled>Select a course</option>
<option v-for="course in trainings" :key="course.id" :value="course.id">
{{ course.name }}
</option>
</select>
<div class="h-4">
<FieldError v-if="errors.length" :errors="errors" />
</div>
</Field>
</VeeField>
</FieldGroup>
</div>
<div class="w-[150px]">
<FieldGroup>
<VeeField v-slot="{ field, errors }" name="event_date">
<Field :data-invalid="!!errors.length">
<FieldLabel class="scroll-m-20 text-lg tracking-tight">Event Date</FieldLabel>
<input type="date" v-bind="field"
class="h-9 border rounded p-2 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] bg-background outline-none" />
<div class="h-4">
<FieldError v-if="errors.length" :errors="errors" />
</div>
</Field>
</VeeField>
</FieldGroup>
</div>
</div>
<VeeFieldArray name="attendees" v-slot="{ fields, push, remove }">
<FieldSet class="gap-4">
<div class="flex flex-col gap-2">
<FieldLegend class="scroll-m-20 text-lg tracking-tight mb-0">Attendees</FieldLegend>
<FieldDescription class="mb-0">Add members who attended this session.</FieldDescription>
<div class="h-4">
<div class="text-red-500 text-sm"
v-if="errors.attendees && typeof errors.attendees === 'string'">
{{ errors.attendees }}
</div>
</div>
</div>
<FieldGroup class="gap-4">
<!-- Column Headers -->
<div class="relative">
<div
class="grid grid-cols-[180px_155px_65px_41px_1fr_auto] gap-3 font-medium text-sm text-muted-foreground">
<div>Member</div>
<div>Role</div>
<!-- Bookwork -->
<div class="text-center">Bookwork</div>
<!-- Qual -->
<div class="text-center">Qual</div>
<div>Remarks</div>
<div></div> <!-- empty for remove button -->
</div>
<!-- FLOATING SUPERHEADER -->
<div class="absolute left-[calc(180px+155px+65px/2)] -top-5
w-[106px] text-center text-xs font-medium text-muted-foreground
pointer-events-none">
Pass
</div>
</div>
<!-- Attendee Rows -->
<template v-for="(field, index) in fields" :key="field.key">
<div class="grid grid-cols-[180px_160px_50px_50px_1fr_auto] gap-3 items-center">
<!-- Member Select -->
<VeeField :name="`attendees[${index}].attendee_id`" v-slot="{ field: f, errors: e }">
<div>
<select v-bind="f"
class="border rounded p-2 w-full focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] bg-background outline-none">
<option value="">Select member...</option>
<option v-for="m in members" :key="m.member_id" :value="m.member_id">
{{ m.member_name }}
</option>
</select>
<div class="h-4">
<FieldError v-if="e.length" :errors="e" />
</div>
</div>
</VeeField>
<!-- Role Select -->
<VeeField :name="`attendees[${index}].attendee_role_id`" v-slot="{ field: f, errors: e }">
<div>
<select v-bind="f"
class="border rounded p-2 w-full focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] bg-background outline-none">
<option value="">Select role...</option>
<option v-for="r in eventRoles" :key="r.id" :value="r.id">
{{ r.name }}
</option>
</select>
<div class="h-4">
<FieldError v-if="e.length" :errors="e" />
</div>
</div>
</VeeField>
<!-- Bookwork Checkbox -->
<VeeField v-slot="{ field }" :name="`attendees[${index}].passed_bookwork`" type="checkbox"
:value="false" :unchecked-value="true">
<div class="flex flex-col items-center">
<div class="relative inline-flex items-center group">
<Checkbox :disabled="!selectedCourse?.hasBookwork"
:name="`attendees[${index}].passed_bookwork`" :model-value="!field.checked"
@update:model-value="field['onUpdate:modelValue']">
</Checkbox>
<!-- Tooltip bubble -->
<div v-if="!selectedCourse?.hasBookwork" class="pointer-events-none absolute -top-9 left-1/2 -translate-x-1/2
whitespace-nowrap rounded-md bg-popover px-2 py-1 text-xs
text-popover-foreground shadow-md border border-border
opacity-0 translate-y-1
group-hover:opacity-100 group-hover:translate-y-0
transition-opacity transition-transform duration-150">
This course does not have bookwork
</div>
</div>
<div class="h-4">
</div>
</div>
</VeeField>
<!-- Qual Checkbox -->
<VeeField v-slot="{ field }" :name="`attendees[${index}].passed_qual`" type="checkbox"
:value="false" :unchecked-value="true">
<div class="flex flex-col items-center">
<div class="relative inline-flex items-center group">
<Checkbox :disabled="!selectedCourse?.hasQual"
:name="`attendees[${index}].passed_qual`" :model-value="!field.checked"
@update:model-value="field['onUpdate:modelValue']"></Checkbox>
<!-- Tooltip bubble -->
<div v-if="!selectedCourse?.hasQual" class="pointer-events-none absolute -top-9 left-1/2 -translate-x-1/2
whitespace-nowrap rounded-md bg-popover px-2 py-1 text-xs
text-popover-foreground shadow-md border border-border
opacity-0 translate-y-1
group-hover:opacity-100 group-hover:translate-y-0
transition-opacity transition-transform duration-150">
This course does not have a qualification
</div>
</div>
<div class="h-4">
</div>
</div>
</VeeField>
<!-- Remarks -->
<VeeField :name="`attendees[${index}].remarks`" v-slot="{ field: f, errors: e }">
<div class="flex flex-col">
<textarea v-bind="f"
class="h-[38px] resize-none border rounded p-2 w-full focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] bg-background outline-none"
placeholder="Optional remarks"></textarea>
<div class="h-4">
<FieldError v-if="e.length" :errors="e" />
</div>
</div>
</VeeField>
<div>
<!-- Remove button -->
<Button type="button" variant="ghost" size="sm" @click="remove(index)"
class="self-center">
<X :size="10" />
</Button>
<div class="h-4">
</div>
</div>
</div>
</template>
</FieldGroup>
<Button type="button" size="sm" variant="outline"
@click="push({ attendee_id: null, attendee_role_id: null, passed_bookwork: false, passed_qual: false, remarks: '' })">
<Plus class="mr-1 h-4 w-4" />
Add Attendee
</Button>
</FieldSet>
</VeeFieldArray>
<FieldGroup class="pt-3">
<VeeField v-slot="{ field, errors }" name="remarks">
<Field :data-invalid="!!errors.length">
<FieldLabel class="scroll-m-20 text-lg tracking-tight">Remarks</FieldLabel>
<Textarea v-bind="field" placeholder="Any remarks about this training event..."
autocomplete="off" />
<FieldError v-if="errors.length" :errors="errors" />
</Field>
</VeeField>
</FieldGroup>
<div class="flex gap-3 justify-end">
<Button type="button" variant="outline" @click="resetForm">Reset</Button>
<Button type="submit" form="trainingForm">Submit</Button>
</div>
</form>
</template>

View File

@@ -1,22 +0,0 @@
<script setup>
import { cn } from "@/lib/utils";
import { buttonGroupVariants } from ".";
const props = defineProps({
class: { type: null, required: false },
orientation: { type: null, required: false },
});
</script>
<template>
<div
role="group"
data-slot="button-group"
:data-orientation="props.orientation"
:class="
cn(buttonGroupVariants({ orientation: props.orientation }), props.class)
"
>
<slot />
</div>
</template>

View File

@@ -1,28 +0,0 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { cn } from "@/lib/utils";
import { Separator } from '@/components/ui/separator';
const props = defineProps({
orientation: { type: String, required: false, default: "vertical" },
decorative: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<Separator
data-slot="button-group-separator"
v-bind="delegatedProps"
:orientation="props.orientation"
:class="
cn(
'bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto',
props.class,
)
"
/>
</template>

View File

@@ -1,29 +0,0 @@
<script setup>
import { Primitive } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
orientation: { type: null, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false, default: "div" },
});
</script>
<template>
<Primitive
role="group"
data-slot="button-group"
:data-orientation="props.orientation"
:as="as"
:as-child="asChild"
:class="
cn(
'bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*=\'size-\'])]:size-4',
props.class,
)
"
>
<slot />
</Primitive>
</template>

View File

@@ -1,22 +0,0 @@
import { cva } from "class-variance-authority";
export { default as ButtonGroup } from "./ButtonGroup.vue";
export { default as ButtonGroupSeparator } from "./ButtonGroupSeparator.vue";
export { default as ButtonGroupText } from "./ButtonGroupText.vue";
export const buttonGroupVariants = cva(
"flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
{
variants: {
orientation: {
horizontal:
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
vertical:
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
},
},
defaultVariants: {
orientation: "horizontal",
},
},
);

View File

@@ -1,20 +0,0 @@
<script setup>
import { cn } from "@/lib/utils";
import { fieldVariants } from ".";
const props = defineProps({
class: { type: null, required: false },
orientation: { type: null, required: false },
});
</script>
<template>
<div
role="group"
data-slot="field"
:data-orientation="orientation"
:class="cn(fieldVariants({ orientation }), props.class)"
>
<slot />
</div>
</template>

View File

@@ -1,21 +0,0 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="field-content"
:class="
cn(
'group/field-content flex flex-1 flex-col gap-1.5 leading-snug',
props.class,
)
"
>
<slot />
</div>
</template>

View File

@@ -1,23 +0,0 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<p
data-slot="field-description"
:class="
cn(
'text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance',
'last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5',
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
props.class,
)
"
>
<slot />
</p>
</template>

View File

@@ -1,56 +0,0 @@
<script setup>
import { computed } from "vue";
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
errors: { type: Array, required: false },
});
const content = computed(() => {
if (!props.errors || props.errors.length === 0) return null;
const uniqueErrors = [
...new Map(
props.errors.filter(Boolean).map((error) => {
const message = typeof error === "string" ? error : error?.message;
return [message, error];
}),
).values(),
];
if (uniqueErrors.length === 1 && uniqueErrors[0]) {
return typeof uniqueErrors[0] === "string"
? uniqueErrors[0]
: uniqueErrors[0].message;
}
return uniqueErrors.map((error) =>
typeof error === "string" ? error : error?.message,
);
});
</script>
<template>
<div
v-if="$slots.default || content"
role="alert"
data-slot="field-error"
:class="cn('text-destructive text-sm font-normal', props.class)"
>
<slot v-if="$slots.default" />
<template v-else-if="typeof content === 'string'">
{{ content }}
</template>
<ul
v-else-if="Array.isArray(content)"
class="ml-4 flex list-disc flex-col gap-1"
>
<li v-for="(error, index) in content" :key="index">
{{ error }}
</li>
</ul>
</div>
</template>

View File

@@ -1,21 +0,0 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="field-group"
:class="
cn(
'group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4',
props.class,
)
"
>
<slot />
</div>
</template>

View File

@@ -1,24 +0,0 @@
<script setup>
import { cn } from "@/lib/utils";
import { Label } from '@/components/ui/label';
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<Label
data-slot="field-label"
:class="
cn(
'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50',
'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4',
'has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10',
props.class,
)
"
>
<slot />
</Label>
</template>

View File

@@ -1,25 +0,0 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
variant: { type: String, required: false },
});
</script>
<template>
<legend
data-slot="field-legend"
:data-variant="variant"
:class="
cn(
'mb-3 font-medium',
'data-[variant=legend]:text-base',
'data-[variant=label]:text-sm',
props.class,
)
"
>
<slot />
</legend>
</template>

View File

@@ -1,30 +0,0 @@
<script setup>
import { cn } from "@/lib/utils";
import { Separator } from '@/components/ui/separator';
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="field-separator"
:data-content="!!$slots.default"
:class="
cn(
'relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2',
props.class,
)
"
>
<Separator class="absolute inset-0 top-1/2" />
<span
v-if="$slots.default"
class="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
data-slot="field-separator-content"
>
<slot />
</span>
</div>
</template>

View File

@@ -1,22 +0,0 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<fieldset
data-slot="field-set"
:class="
cn(
'flex flex-col gap-6',
'has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3',
props.class,
)
"
>
<slot />
</fieldset>
</template>

View File

@@ -1,21 +0,0 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="field-label"
:class="
cn(
'flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50',
props.class,
)
"
>
<slot />
</div>
</template>

View File

@@ -1,36 +0,0 @@
import { cva } from "class-variance-authority";
export const fieldVariants = cva(
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
horizontal: [
"flex-row items-center",
"[&>[data-slot=field-label]]:flex-auto",
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
responsive: [
"flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto",
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
},
},
defaultVariants: {
orientation: "vertical",
},
},
);
export { default as Field } from "./Field.vue";
export { default as FieldContent } from "./FieldContent.vue";
export { default as FieldDescription } from "./FieldDescription.vue";
export { default as FieldError } from "./FieldError.vue";
export { default as FieldGroup } from "./FieldGroup.vue";
export { default as FieldLabel } from "./FieldLabel.vue";
export { default as FieldLegend } from "./FieldLegend.vue";
export { default as FieldSeparator } from "./FieldSeparator.vue";
export { default as FieldSet } from "./FieldSet.vue";
export { default as FieldTitle } from "./FieldTitle.vue";

View File

@@ -1,36 +0,0 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="input-group"
role="group"
:class="
cn(
'group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none',
'h-9 min-w-0 has-[>textarea]:h-auto',
// Variants based on alignment.
'has-[>[data-align=inline-start]]:[&>input]:pl-2',
'has-[>[data-align=inline-end]]:[&>input]:pr-2',
'has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3',
'has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3',
// Focus state.
'has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]',
// Error state.
'has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40',
props.class,
)
"
>
<slot />
</div>
</template>

View File

@@ -1,32 +0,0 @@
<script setup>
import { cn } from "@/lib/utils";
import { inputGroupAddonVariants } from ".";
const props = defineProps({
align: { type: null, required: false, default: "inline-start" },
class: { type: null, required: false },
});
function handleInputGroupAddonClick(e) {
const currentTarget = e.currentTarget;
const target = e.target;
if (target && target.closest("button")) {
return;
}
if (currentTarget && currentTarget?.parentElement) {
currentTarget.parentElement?.querySelector("input")?.focus();
}
}
</script>
<template>
<div
role="group"
data-slot="input-group-addon"
:data-align="props.align"
:class="cn(inputGroupAddonVariants({ align: props.align }), props.class)"
@click="handleInputGroupAddonClick"
>
<slot />
</div>
</template>

View File

@@ -1,47 +0,0 @@
import { cva } from "class-variance-authority";
export { default as InputGroup } from "./InputGroup.vue";
export { default as InputGroupAddon } from "./InputGroupAddon.vue";
export { default as InputGroupButton } from "./InputGroupButton.vue";
export { default as InputGroupInput } from "./InputGroupInput.vue";
export { default as InputGroupText } from "./InputGroupText.vue";
export { default as InputGroupTextarea } from "./InputGroupTextarea.vue";
export const inputGroupAddonVariants = cva(
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
{
variants: {
align: {
"inline-start":
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
"inline-end":
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
"block-start":
"order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
"block-end":
"order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5",
},
},
defaultVariants: {
align: "inline-start",
},
},
);
export const inputGroupButtonVariants = cva(
"text-sm shadow-none flex gap-2 items-center",
{
variants: {
size: {
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
},
);

View File

@@ -1,45 +0,0 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { NavigationMenuRoot, useForwardPropsEmits } from "reka-ui";
import { cn } from "@/lib/utils";
import NavigationMenuViewport from "./NavigationMenuViewport.vue";
const props = defineProps({
modelValue: { type: String, required: false },
defaultValue: { type: String, required: false },
dir: { type: String, required: false },
orientation: { type: String, required: false },
delayDuration: { type: Number, required: false },
skipDelayDuration: { type: Number, required: false },
disableClickTrigger: { type: Boolean, required: false },
disableHoverTrigger: { type: Boolean, required: false },
disablePointerLeaveClose: { type: Boolean, required: false },
unmountOnHide: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
viewport: { type: Boolean, required: false, default: true },
});
const emits = defineEmits(["update:modelValue"]);
const delegatedProps = reactiveOmit(props, "class", "viewport");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<NavigationMenuRoot
v-slot="slotProps"
data-slot="navigation-menu"
:data-viewport="viewport"
v-bind="forwarded"
:class="
cn(
'group/navigation-menu relative flex max-w-max flex-1 items-center justify-center',
props.class,
)
"
>
<slot v-bind="slotProps" />
<NavigationMenuViewport v-if="viewport" />
</NavigationMenuRoot>
</template>

View File

@@ -1,39 +0,0 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { NavigationMenuContent, useForwardPropsEmits } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
forceMount: { type: Boolean, required: false },
disableOutsidePointerEvents: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const emits = defineEmits([
"escapeKeyDown",
"pointerDownOutside",
"focusOutside",
"interactOutside",
]);
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<NavigationMenuContent
data-slot="navigation-menu-content"
v-bind="forwarded"
:class="
cn(
'data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto',
'group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none',
props.class,
)
"
>
<slot />
</NavigationMenuContent>
</template>

View File

@@ -1,33 +0,0 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { NavigationMenuIndicator, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
forceMount: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<NavigationMenuIndicator
data-slot="navigation-menu-indicator"
v-bind="forwardedProps"
:class="
cn(
'data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden',
props.class,
)
"
>
<div
class="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md"
/>
</NavigationMenuIndicator>
</template>

View File

@@ -1,24 +0,0 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { NavigationMenuItem } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
value: { type: String, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<NavigationMenuItem
data-slot="navigation-menu-item"
v-bind="delegatedProps"
:class="cn('relative', props.class)"
>
<slot />
</NavigationMenuItem>
</template>

View File

@@ -1,31 +0,0 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { NavigationMenuLink, useForwardPropsEmits } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
active: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const emits = defineEmits(["select"]);
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<NavigationMenuLink
data-slot="navigation-menu-link"
v-bind="forwarded"
:class="
cn(
'data-active:focus:bg-accent data-active:hover:bg-accent data-active:bg-accent/50 data-active:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 [&_svg:not([class*=\'text-\'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1 [&_svg:not([class*=\'size-\'])]:size-4',
props.class,
)
"
>
<slot />
</NavigationMenuLink>
</template>

View File

@@ -1,30 +0,0 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { NavigationMenuList, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<NavigationMenuList
data-slot="navigation-menu-list"
v-bind="forwardedProps"
:class="
cn(
'group flex flex-1 list-none items-center justify-center gap-1',
props.class,
)
"
>
<slot />
</NavigationMenuList>
</template>

View File

@@ -1,32 +0,0 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { ChevronDown } from "lucide-vue-next";
import { NavigationMenuTrigger, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
import { navigationMenuTriggerStyle } from ".";
const props = defineProps({
disabled: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<NavigationMenuTrigger
data-slot="navigation-menu-trigger"
v-bind="forwardedProps"
:class="cn(navigationMenuTriggerStyle(), 'group', props.class)"
>
<slot />
<ChevronDown
class="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuTrigger>
</template>

View File

@@ -1,32 +0,0 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { NavigationMenuViewport, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
forceMount: { type: Boolean, required: false },
align: { type: String, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<div class="absolute top-full left-0 isolate z-50 flex justify-center">
<NavigationMenuViewport
data-slot="navigation-menu-viewport"
v-bind="forwardedProps"
:class="
cn(
'origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--reka-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--reka-navigation-menu-viewport-width)] left-[var(--reka-navigation-menu-viewport-left)]',
props.class,
)
"
/>
</div>
</template>

View File

@@ -1,14 +0,0 @@
import { cva } from "class-variance-authority";
export { default as NavigationMenu } from "./NavigationMenu.vue";
export { default as NavigationMenuContent } from "./NavigationMenuContent.vue";
export { default as NavigationMenuIndicator } from "./NavigationMenuIndicator.vue";
export { default as NavigationMenuItem } from "./NavigationMenuItem.vue";
export { default as NavigationMenuLink } from "./NavigationMenuLink.vue";
export { default as NavigationMenuList } from "./NavigationMenuList.vue";
export { default as NavigationMenuTrigger } from "./NavigationMenuTrigger.vue";
export { default as NavigationMenuViewport } from "./NavigationMenuViewport.vue";
export const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1",
);

View File

@@ -1,26 +0,0 @@
<script setup>
import { SelectRoot, useForwardPropsEmits } from "reka-ui";
const props = defineProps({
open: { type: Boolean, required: false },
defaultOpen: { type: Boolean, required: false },
defaultValue: { type: null, required: false },
modelValue: { type: null, required: false },
by: { type: [String, Function], required: false },
dir: { type: String, required: false },
multiple: { type: Boolean, required: false },
autocomplete: { type: String, required: false },
disabled: { type: Boolean, required: false },
name: { type: String, required: false },
required: { type: Boolean, required: false },
});
const emits = defineEmits(["update:modelValue", "update:open"]);
const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>
<SelectRoot v-slot="slotProps" data-slot="select" v-bind="forwarded">
<slot v-bind="slotProps" />
</SelectRoot>
</template>

View File

@@ -1,81 +0,0 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import {
SelectContent,
SelectPortal,
SelectViewport,
useForwardPropsEmits,
} from "reka-ui";
import { cn } from "@/lib/utils";
import { SelectScrollDownButton, SelectScrollUpButton } from ".";
defineOptions({
inheritAttrs: false,
});
const props = defineProps({
forceMount: { type: Boolean, required: false },
position: { type: String, required: false, default: "popper" },
bodyLock: { type: Boolean, required: false },
side: { type: null, required: false },
sideOffset: { type: Number, required: false },
sideFlip: { type: Boolean, required: false },
align: { type: null, required: false },
alignOffset: { type: Number, required: false },
alignFlip: { type: Boolean, required: false },
avoidCollisions: { type: Boolean, required: false },
collisionBoundary: { type: null, required: false },
collisionPadding: { type: [Number, Object], required: false },
arrowPadding: { type: Number, required: false },
sticky: { type: String, required: false },
hideWhenDetached: { type: Boolean, required: false },
positionStrategy: { type: String, required: false },
updatePositionStrategy: { type: String, required: false },
disableUpdateOnLayoutShift: { type: Boolean, required: false },
prioritizePosition: { type: Boolean, required: false },
reference: { type: null, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const emits = defineEmits([
"closeAutoFocus",
"escapeKeyDown",
"pointerDownOutside",
]);
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<SelectPortal>
<SelectContent
data-slot="select-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--reka-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
props.class,
)
"
>
<SelectScrollUpButton />
<SelectViewport
:class="
cn(
'p-1',
position === 'popper' &&
'h-[var(--reka-select-trigger-height)] w-full min-w-[var(--reka-select-trigger-width)] scroll-my-1',
)
"
>
<slot />
</SelectViewport>
<SelectScrollDownButton />
</SelectContent>
</SelectPortal>
</template>

View File

@@ -1,14 +0,0 @@
<script setup>
import { SelectGroup } from "reka-ui";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
});
</script>
<template>
<SelectGroup data-slot="select-group" v-bind="props">
<slot />
</SelectGroup>
</template>

View File

@@ -1,49 +0,0 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { Check } from "lucide-vue-next";
import {
SelectItem,
SelectItemIndicator,
SelectItemText,
useForwardProps,
} from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
value: { type: null, required: true },
disabled: { type: Boolean, required: false },
textValue: { type: String, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<SelectItem
data-slot="select-item"
v-bind="forwardedProps"
:class="
cn(
'focus:bg-accent focus:text-accent-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2',
props.class,
)
"
>
<span class="absolute right-2 flex size-3.5 items-center justify-center">
<SelectItemIndicator>
<slot name="indicator-icon">
<Check class="size-4" />
</slot>
</SelectItemIndicator>
</span>
<SelectItemText>
<slot />
</SelectItemText>
</SelectItem>
</template>

View File

@@ -1,14 +0,0 @@
<script setup>
import { SelectItemText } from "reka-ui";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
});
</script>
<template>
<SelectItemText data-slot="select-item-text" v-bind="props">
<slot />
</SelectItemText>
</template>

View File

@@ -1,20 +0,0 @@
<script setup>
import { SelectLabel } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
for: { type: String, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
</script>
<template>
<SelectLabel
data-slot="select-label"
:class="cn('text-muted-foreground px-2 py-1.5 text-xs', props.class)"
>
<slot />
</SelectLabel>
</template>

View File

@@ -1,30 +0,0 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { ChevronDown } from "lucide-vue-next";
import { SelectScrollDownButton, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<SelectScrollDownButton
data-slot="select-scroll-down-button"
v-bind="forwardedProps"
:class="
cn('flex cursor-default items-center justify-center py-1', props.class)
"
>
<slot>
<ChevronDown class="size-4" />
</slot>
</SelectScrollDownButton>
</template>

View File

@@ -1,30 +0,0 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { ChevronUp } from "lucide-vue-next";
import { SelectScrollUpButton, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<SelectScrollUpButton
data-slot="select-scroll-up-button"
v-bind="forwardedProps"
:class="
cn('flex cursor-default items-center justify-center py-1', props.class)
"
>
<slot>
<ChevronUp class="size-4" />
</slot>
</SelectScrollUpButton>
</template>

View File

@@ -1,21 +0,0 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { SelectSeparator } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<SelectSeparator
data-slot="select-separator"
v-bind="delegatedProps"
:class="cn('bg-border pointer-events-none -mx-1 my-1 h-px', props.class)"
/>
</template>

View File

@@ -1,37 +0,0 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { ChevronDown } from "lucide-vue-next";
import { SelectIcon, SelectTrigger, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
disabled: { type: Boolean, required: false },
reference: { type: null, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
size: { type: String, required: false, default: "default" },
});
const delegatedProps = reactiveOmit(props, "class", "size");
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<SelectTrigger
data-slot="select-trigger"
:data-size="size"
v-bind="forwardedProps"
:class="
cn(
'border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
props.class,
)
"
>
<slot />
<SelectIcon as-child>
<ChevronDown class="size-4 opacity-50" />
</SelectIcon>
</SelectTrigger>
</template>

View File

@@ -1,15 +0,0 @@
<script setup>
import { SelectValue } from "reka-ui";
const props = defineProps({
placeholder: { type: String, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
});
</script>
<template>
<SelectValue data-slot="select-value" v-bind="props">
<slot />
</SelectValue>
</template>

View File

@@ -1,11 +0,0 @@
export { default as Select } from "./Select.vue";
export { default as SelectContent } from "./SelectContent.vue";
export { default as SelectGroup } from "./SelectGroup.vue";
export { default as SelectItem } from "./SelectItem.vue";
export { default as SelectItemText } from "./SelectItemText.vue";
export { default as SelectLabel } from "./SelectLabel.vue";
export { default as SelectScrollDownButton } from "./SelectScrollDownButton.vue";
export { default as SelectScrollUpButton } from "./SelectScrollUpButton.vue";
export { default as SelectSeparator } from "./SelectSeparator.vue";
export { default as SelectTrigger } from "./SelectTrigger.vue";
export { default as SelectValue } from "./SelectValue.vue";

View File

@@ -1,31 +0,0 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { StepperRoot, useForwardPropsEmits } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
defaultValue: { type: Number, required: false },
orientation: { type: String, required: false },
dir: { type: String, required: false },
modelValue: { type: Number, required: false },
linear: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const emits = defineEmits(["update:modelValue"]);
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<StepperRoot
v-slot="slotProps"
:class="cn('flex gap-2', props.class)"
v-bind="forwarded"
>
<slot v-bind="slotProps" />
</StepperRoot>
</template>

View File

@@ -1,25 +0,0 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { StepperDescription, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardProps(delegatedProps);
</script>
<template>
<StepperDescription
v-slot="slotProps"
v-bind="forwarded"
:class="cn('text-xs text-muted-foreground', props.class)"
>
<slot v-bind="slotProps" />
</StepperDescription>
</template>

View File

@@ -1,36 +0,0 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { StepperIndicator, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardProps(delegatedProps);
</script>
<template>
<StepperIndicator
v-slot="slotProps"
v-bind="forwarded"
:class="
cn(
'inline-flex items-center justify-center rounded-full text-muted-foreground/50 w-8 h-8',
// Disabled
'group-data-[disabled]:text-muted-foreground group-data-[disabled]:opacity-50',
// Active
'group-data-[state=active]:bg-primary group-data-[state=active]:text-primary-foreground',
// Completed
'group-data-[state=completed]:bg-accent group-data-[state=completed]:text-accent-foreground',
props.class,
)
"
>
<slot v-bind="slotProps" />
</StepperIndicator>
</template>

View File

@@ -1,33 +0,0 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { StepperItem, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
step: { type: Number, required: true },
disabled: { type: Boolean, required: false },
completed: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardProps(delegatedProps);
</script>
<template>
<StepperItem
v-slot="slotProps"
v-bind="forwarded"
:class="
cn(
'flex items-center gap-2 group data-[disabled]:pointer-events-none',
props.class,
)
"
>
<slot v-bind="slotProps" />
</StepperItem>
</template>

View File

@@ -1,33 +0,0 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { StepperSeparator, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
orientation: { type: String, required: false },
decorative: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardProps(delegatedProps);
</script>
<template>
<StepperSeparator
v-bind="forwarded"
:class="
cn(
'bg-muted',
// Disabled
'group-data-[disabled]:bg-muted group-data-[disabled]:opacity-50',
// Completed
'group-data-[state=completed]:bg-accent',
props.class,
)
"
/>
</template>

View File

@@ -1,24 +0,0 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { StepperTitle, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardProps(delegatedProps);
</script>
<template>
<StepperTitle
v-bind="forwarded"
:class="cn('text-md font-semibold whitespace-nowrap', props.class)"
>
<slot />
</StepperTitle>
</template>

View File

@@ -1,29 +0,0 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { StepperTrigger, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardProps(delegatedProps);
</script>
<template>
<StepperTrigger
v-bind="forwarded"
:class="
cn(
'p-1 flex flex-col items-center text-center gap-1 rounded-md',
props.class,
)
"
>
<slot />
</StepperTrigger>
</template>

View File

@@ -1,7 +0,0 @@
export { default as Stepper } from "./Stepper.vue";
export { default as StepperDescription } from "./StepperDescription.vue";
export { default as StepperIndicator } from "./StepperIndicator.vue";
export { default as StepperItem } from "./StepperItem.vue";
export { default as StepperSeparator } from "./StepperSeparator.vue";
export { default as StepperTitle } from "./StepperTitle.vue";
export { default as StepperTrigger } from "./StepperTrigger.vue";

Some files were not shown because too many files have changed in this diff Show More