Merge literally everything into main so I stop just working on one branch #14

Merged
Ajdj100 merged 64 commits from recruiter into main 2025-10-28 13:40:41 -05:00
180 changed files with 10247 additions and 408 deletions

4
.gitignore vendored
View File

@@ -28,3 +28,7 @@ coverage
*.sw?
*.tsbuildinfo
*.sql
.env
*.db

1
api/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
built

View File

@@ -1,11 +0,0 @@
const express = require('express')
const app = express()
const port = 3000
app.get('/', (req, res) => {
res.send('Hello World!')
})
app.listen(port, () => {
console.log(`Example app listening on port ${port}`)
})

1904
api/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,9 +7,25 @@
"type": "commonjs",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "tsc && node ./built/api/src/index.js"
},
"dependencies": {
"express": "^5.1.0"
"connect-sqlite3": "^0.9.16",
"cors": "^2.8.5",
"dotenv": "^17.2.1",
"express": "^5.1.0",
"express-session": "^1.18.2",
"mariadb": "^3.4.5",
"morgan": "^1.10.1",
"mysql2": "^3.14.3",
"passport": "^0.7.0",
"passport-openidconnect": "^0.1.2"
},
"devDependencies": {
"@types/express": "^5.0.3",
"@types/morgan": "^1.9.10",
"@types/node": "^24.8.1",
"typescript": "^5.9.3"
}
}

19
api/src/db.ts Normal file
View File

@@ -0,0 +1,19 @@
// const mariadb = require('mariadb')
import * as mariadb from 'mariadb';
const dotenv = require('dotenv')
dotenv.config();
const pool = mariadb.createPool({
host: process.env.DB_HOST,
port: Number(process.env.DB_PORT),
user: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
connectionLimit: 5,
connectTimeout: 10000, // give it more breathing room
acquireTimeout: 15000,
database: 'ranger_unit_tracker',
ssl: false,
});
export default pool;

67
api/src/index.js Normal file
View File

@@ -0,0 +1,67 @@
const dotenv = require('dotenv')
dotenv.config();
const express = require('express')
const cors = require('cors')
const morgan = require('morgan')
const app = express()
app.use(morgan('dev'))
app.use(cors({
origin: ['https://aj17thdev.nexuszone.net', 'http://localhost:5173'], // your SPA origins
credentials: true
}));
app.use(express.json())
app.set('trust proxy', 1);
const port = process.env.SERVER_PORT;
//session setup
const path = require('path')
const session = require('express-session')
const passport = require('passport')
const SQLiteStore = require('connect-sqlite3')(session);
app.use(session({
secret: 'whatever',
resave: false,
saveUninitialized: false,
store: new SQLiteStore({ db: 'sessions.db', dir: './' }),
cookie: {
httpOnly: true,
sameSite: 'lax',
domain: 'nexuszone.net'
}
}));
app.use(passport.authenticate('session'));
// Mount route modules
const applicationsRouter = require('./routes/applications');
const { memberRanks, ranks } = require('./routes/ranks');
const members = require('./routes/members');
const loaHandler = require('./routes/loa')
const { status, memberStatus } = require('./routes/statuses')
const authRouter = require('./routes/auth')
const { roles, memberRoles } = require('./routes/roles');
const morgan = require('morgan');
app.use('/application', applicationsRouter);
app.use('/ranks', ranks);
app.use('/memberRanks', memberRanks);
app.use('/members', members);
app.use('/loa', loaHandler);
app.use('/status', status)
app.use('/memberStatus', memberStatus)
app.use('/roles', roles)
app.use('/memberRoles', memberRoles)
app.use('/', authRouter)
app.get('/ping', (req, res) => {
res.status(200).json({ message: 'pong' });
});
app.listen(port, () => {
console.log(`Example app listening on port ${port} `)
})

View File

@@ -0,0 +1,174 @@
const express = require('express');
const router = express.Router();
import pool from '../db';
import { approveApplication, createApplication, getApplicationByID, getApplicationComments, getApplicationList, getMemberApplication } from '../services/applicationService';
import { MemberState, setUserState } from '../services/memberService';
import { getRankByName, insertMemberRank } from '../services/rankService';
import { ApplicationFull, CommentRow } from "@app/shared/types/application"
import { assignUserToStatus } from '../services/statusService';
// POST /application
router.post('/', async (req, res) => {
try {
const App = req.body?.App || {};
const memberID = req.user.id;
const appVersion = 1;
createApplication(memberID, appVersion, JSON.stringify(App))
setUserState(memberID, MemberState.Applicant);
res.sendStatus(201);
} catch (err) {
console.error('Insert failed:', err);
res.status(500).json({ error: 'Failed to save application' });
}
});
// GET /application/all
router.get('/all', async (req, res) => {
try {
const rows = await getApplicationList();
res.status(200).json(rows);
} catch (err) {
console.error(err);
res.status(500);
}
});
router.get('/me', async (req, res) => {
let userID = req.user.id;
console.log("application/me")
let app = getMemberApplication(userID);
console.log(app);
})
// GET /application/:id
router.get('/:id', async (req, res) => {
let appID = req.params.id;
console.log("HELLO")
try {
const application = await getApplicationByID(appID);
if (application === undefined)
return res.sendStatus(204);
const comments: CommentRow[] = await getApplicationComments(appID);
const output: ApplicationFull = {
application,
comments,
}
return res.status(200).json(output);
}
catch (err) {
console.error('Query failed:', err);
return res.status(500).json({ error: 'Failed to load application' });
}
});
// POST /application/approve/:id
router.post('/approve/:id', async (req, res) => {
const appID = req.params.id;
try {
const app = await getApplicationByID(appID);
const result = await approveApplication(appID);
console.log("START");
console.log(app, result);
//guard against failures
if (result.affectedRows != 1) {
throw new Error("Something went wrong approving the application");
}
console.log(app.member_id);
//update user profile
await setUserState(app.member_id, MemberState.Member);
let nextRank = await getRankByName('Recruit')
await insertMemberRank(app.member_id, nextRank.id);
//assign user to "pending basic"
await assignUserToStatus(app.member_id, 1);
res.sendStatus(200);
} catch (err) {
console.error('Approve failed:', err);
res.status(500).json({ error: 'Failed to approve application' });
}
});
// POST /application/deny/:id
router.post('/deny/:id', async (req, res) => {
const appID = req.params.id;
const sql = `
UPDATE applications
SET denied_at = NOW()
WHERE id = ?
AND approved_at IS NULL
AND denied_at IS NULL
`;
try {
const result = await pool.execute(sql, appID);
console.log(result);
if (result.affectedRows === 0) {
res.status(400).json('Something went wrong denying the application');
}
if (result.affectedRows == 1) {
res.sendStatus(200);
}
} catch (err) {
console.error('Approve failed:', err);
res.status(500).json({ error: 'Failed to deny application' });
}
});
// POST /application/:id/comment
router.post('/:id/comment', async (req, res) => {
const appID = req.params.id;
const data = req.body.message;
const user = 1;
const sql = `INSERT INTO application_comments(
application_id,
poster_id,
post_content
)
VALUES(?, ?, ?);`
try {
const conn = await pool.getConnection();
const result = await conn.query(sql, [appID, user, data])
console.log(result)
if (result.affectedRows !== 1) {
conn.release();
throw new Error("Insert Failure")
}
const getSQL = `SELECT app.id AS comment_id,
app.post_content,
app.poster_id,
app.post_time,
app.last_modified,
member.name AS poster_name
FROM application_comments AS app
INNER JOIN members AS member ON member.id = app.poster_id
WHERE app.id = ?; `;
const comment = await conn.query(getSQL, [result.insertId])
res.status(201).json(comment[0]);
} catch (err) {
console.error('Comment failed:', err);
res.status(500).json({ error: 'Could not post comment' });
}
});
module.exports = router;

131
api/src/routes/auth.js Normal file
View File

@@ -0,0 +1,131 @@
const passport = require('passport');
const OpenIDConnectStrategy = require('passport-openidconnect');
const dotenv = require('dotenv');
dotenv.config();
const express = require('express');
const { param } = require('./applications');
const router = express.Router();
import pool from '../db';
const querystring = require('querystring');
passport.use(new OpenIDConnectStrategy({
issuer: process.env.AUTH_ISSUER,
authorizationURL: 'https://sso.iceberg-gaming.com/application/o/authorize/',
tokenURL: 'https://sso.iceberg-gaming.com/application/o/token/',
userInfoURL: 'https://sso.iceberg-gaming.com/application/o/userinfo/',
clientID: process.env.AUTH_CLIENT_ID,
clientSecret: process.env.AUTH_CLIENT_SECRET,
callbackURL: process.env.AUTH_REDIRECT_URI,
scope: ['openid', 'profile']
}, async function verify(issuer, sub, profile, jwtClaims, accessToken, refreshToken, params, cb) {
// console.log('--- OIDC verify() called ---');
// console.log('issuer:', issuer);
// console.log('sub:', sub);
// console.log('profile:', JSON.stringify(profile, null, 2));
// console.log('id_token claims:', JSON.stringify(jwtClaims, null, 2));
// console.log('preferred_username:', jwtClaims?.preferred_username);
const con = await pool.getConnection();
try {
await con.beginTransaction();
//lookup existing user
const existing = await con.query(`SELECT id FROM members WHERE authentik_issuer = ? AND authentik_sub = ? LIMIT 1;`, [issuer, sub]);
let memberId;
//if member exists
if (existing.length > 0) {
memberId = existing[0].id;
} else {
//otherwise: create account
const username = sub.username;
const result = await con.query(
`INSERT INTO members (name, authentik_sub, authentik_issuer) VALUES (?, ?, ?)`,
[username, sub, issuer]
)
memberId = result.insertId;
}
await con.commit();
return cb(null, { memberId });
} catch (error) {
await con.rollback();
return cb(error);
} finally {
con.release();
}
}));
router.get('/login', (req, res, next) => {
// Store redirect target in session if provided
req.session.redirectTo = req.query.redirect || '/';
next();
}, passport.authenticate('openidconnect'));
// router.get('/callback', (req, res, next) => {
// passport.authenticate('openidconnect', {
// successRedirect: req.session.redirectTo,
// failureRedirect: 'https://aj17thdev.nexuszone.net/'
// })
// });
router.get('/callback', (req, res, next) => {
const redirectURI = req.session.redirectTo;
passport.authenticate('openidconnect', (err, user) => {
if (err) return next(err);
if (!user) return res.redirect('https://aj17thdev.nexuszone.net/');
req.logIn(user, err => {
if (err) return next(err);
// Use redirect saved from session
const redirectTo = redirectURI || 'https://aj17thdev.nexuszone.net/';
delete req.session.redirectTo;
return res.redirect(redirectTo);
});
})(req, res, next);
});
router.post('/logout', function (req, res, next) {
req.logout(function (err) {
if (err) { return next(err); }
var params = {
client_id: process.env.AUTH_CLIENT_ID,
returnTo: 'https://aj17thdev.nexuszone.net/'
};
res.redirect(process.env.AUTH_DOMAIN + '/v2/logout?' + querystring.stringify(params));
});
});
passport.serializeUser(function (user, cb) {
process.nextTick(function () {
cb(null, user);
});
});
passport.deserializeUser(function (user, cb) {
process.nextTick(async function () {
const memberID = user.memberId;
const con = await pool.getConnection();
var userData;
try {
let userResults = await con.query(`SELECT id, name FROM members WHERE id = ?;`, [memberID])
userData = userResults[0];
} catch (error) {
console.error(error)
} finally {
con.release();
}
return cb(null, userData);
});
});
module.exports = router;

View File

@@ -0,0 +1,50 @@
import { getEventAttendance, getEventDetails, getShortEventsInRange } from "../services/calendarService";
const express = require('express');
const r = express.Router();
function addMonths(date: Date, months: number): Date {
const d = new Date(date)
d.setMonth(d.getMonth() + months)
return d
}
//get calendar events paged
r.get('/', async (req, res) => {
const viewDate: Date = req.body.date;
//generate date range
const backDate: Date = addMonths(viewDate, -1);
const frontDate: Date = addMonths(viewDate, 2);
const events = getShortEventsInRange(backDate, frontDate);
res.status(200).json(events);
});
r.get('/upcoming', async (req, res) => {
res.sendStatus(501);
})
//get event details
r.get('/:id', async (req, res) => {
try {
const eventID: number = req.params.id;
let details = getEventDetails(eventID);
let attendance = await getEventAttendance(eventID);
let out = { ...details, attendance }
console.log(out);
res.status(200).json(out);
} catch (err) {
console.error('Insert failed:', err);
res.status(500).json(err);
}
})
//post a new calendar event
r.post('/', async (req, res) => {
})
module.exports.calendar = r;

56
api/src/routes/loa.js Normal file
View File

@@ -0,0 +1,56 @@
const express = require('express');
const router = express.Router();
import pool from '../db';
//post a new LOA
router.post("/", async (req, res) => {
const { member_id, filed_date, start_date, end_date, reason } = req.body;
if (!member_id || !filed_date || !start_date || !end_date) {
return res.status(400).json({ error: "Missing required fields" });
}
try {
const result = await pool.query(
`INSERT INTO leave_of_absences
(member_id, filed_date, start_date, end_date, reason)
VALUES (?, ?, ?, ?, ?)`,
[member_id, filed_date, start_date, end_date, reason]
);
res.sendStatus(201);
} catch (error) {
console.error(error);
res.status(500).send('Something went wrong', error);
}
});
//get my current LOA
router.get("/me", async (req, res) => {
//TODO: implement current user getter
const user = 89;
try {
const result = await pool.query("SELECT * FROM leave_of_absences WHERE member_id = ?", [user])
res.status(200).json(result)
} catch (error) {
console.error(error);
res.status(500).send(error);
}
})
router.get('/all', async (req, res) => {
try {
const result = await pool.query(
`SELECT loa.*, members.name
FROM leave_of_absences AS loa
INNER JOIN members ON loa.member_id = members.id;
`);
res.status(200).json(result)
} catch (error) {
console.error(error);
res.status(500).send(error);
}
})
module.exports = router;

84
api/src/routes/members.js Normal file
View File

@@ -0,0 +1,84 @@
const express = require('express');
const router = express.Router();
import pool from '../db';
import { getUserData } from '../services/memberService';
import { getUserRoles } from '../services/rolesService';
router.use((req, res, next) => {
console.log(req.user);
console.log('Time:', Date.now())
next()
})
//get all users
router.get('/', async (req, res) => {
try {
const result = await pool.query(
`SELECT
v.*,
CASE
WHEN EXISTS (
SELECT 1
FROM leave_of_absences l
WHERE l.member_id = v.member_id
AND l.deleted = 0
AND UTC_TIMESTAMP() BETWEEN l.start_date AND l.end_date
) THEN 1 ELSE 0
END AS on_loa
FROM view_member_rank_status_all v;`);
return res.status(200).json(result);
} catch (err) {
console.error('Error fetching users:', err);
return res.status(500).json({ error: 'Failed to fetch users' });
}
});
router.get('/me', async (req, res) => {
if (req.user === undefined)
return res.sendStatus(401)
try {
const { id, name, state } = await getUserData(req.user.id);
const LOAData = await pool.query(
`SELECT *
FROM leave_of_absences
WHERE member_id = ?
AND deleted = 0
AND UTC_TIMESTAMP() BETWEEN start_date AND end_date;`, req.user.id);
const roleData = await getUserRoles(req.user.id);
const userDataFull = { id, name, state, LOAData, roleData };
console.log(userDataFull)
res.status(200).json(userDataFull);
} catch (error) {
console.error('Error fetching user data:', error);
return res.status(500).json({ error: 'Failed to fetch user data' });
}
})
router.get('/:id', async (req, res) => {
try {
const userId = req.params.id;
const result = await pool.query('SELECT * FROM view_member_rank_status_all WHERE id = $1;', [userId]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'User not found' });
}
return res.status(200).json(result.rows[0]);
} catch (err) {
console.error('Error fetching user:', err);
return res.status(500).json({ error: 'Failed to fetch user' });
}
});
//update a user's display name (stub)
router.put('/:id/displayname', async (req, res) => {
// Stub: not implemented yet
return res.status(501).json({ error: 'Update display name not implemented' });
});
module.exports = router;

31
api/src/routes/ranks.js Normal file
View File

@@ -0,0 +1,31 @@
const express = require('express');
const r = express.Router();
const ur = express.Router();
const { getAllRanks, insertMemberRank } = require('../services/rankService')
//insert a new latest rank for a user
ur.post('/', async (req, res) => {3
try {
const change = req.body?.change;
await insertMemberRank(change.member_id, change.rank_id, change.date);
res.sendStatus(201);
} catch (err) {
console.error('Insert failed:', err);
res.status(500).json({ error: 'Failed to update ranks' });
}
});
//get all ranks
r.get('/', async (req, res) => {
try {
const ranks = await getAllRanks();
res.json(ranks);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Internal server error' });
}
});
module.exports.ranks = r;
module.exports.memberRanks = ur;

116
api/src/routes/roles.js Normal file
View File

@@ -0,0 +1,116 @@
const express = require('express');
const r = express.Router();
const ur = express.Router();
import pool from '../db';
import { assignUserGroup, createGroup } from '../services/rolesService';
//manually assign a member to a group
ur.post('/', async (req, res) => {
try {
const body = req.body;
assignUserGroup(body.member_id, body.role_id);
res.sendStatus(201);
} catch (err) {
console.error('Insert failed:', err);
res.status(500).json({ error: 'Failed to add to group' });
}
});
//manually remove member from group
ur.delete('/', async (req, res) => {
try {
const body = req.body;
console.log(body);
const sql = 'DELETE FROM members_roles WHERE member_id = ? AND role_id = ?'
await pool.query(sql, [body.member_id, body.role_id])
res.sendStatus(200);
}
catch (err) {
console.error("delete failed: ", err)
res.status(500).json({ error: 'Failed to remove from group' });
}
})
//get all roles
r.get('/', async (req, res) => {
try {
const con = await pool.getConnection();
// Get all roles
const roles = await con.query('SELECT * FROM roles;');
// Get all members for each role
const membersRoles = await con.query(`
SELECT mr.role_id, v.*
FROM members_roles mr
JOIN view_member_rank_status_all v ON mr.member_id = v.member_id
`);
// Group members by role_id
const roleIdToMembers = {};
for (const row of membersRoles) {
if (!roleIdToMembers[row.role_id]) roleIdToMembers[row.role_id] = [];
// Remove role_id from member object
const { role_id, ...member } = row;
roleIdToMembers[role_id].push(member);
}
// Attach members to each role
const result = roles.map(role => ({
...role,
members: roleIdToMembers[role.id] || []
}));
con.release();
res.json(result);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Internal server error' });
}
});
//create a new role
r.post('/', async (req, res) => {
try {
const { name, color, description } = req.body;
console.log('Creating role:', { name, color, description });
if (!name || !color) {
return res.status(400).json({ error: 'Name and color are required' });
}
const hexColorRegex = /^#([0-9A-Fa-f]{6})$/;
if (!hexColorRegex.test(color)) {
return res.status(400).json({ error: 'Color must be a valid hex color (#ffffff)' });
}
await createGroup(name, color, description);
res.sendStatus(201);
} catch (err) {
console.error('Insert failed:', err);
res.status(500).json({ error: 'Failed to create role' });
}
})
r.delete('/:id', async (req, res) => {
try {
const id = req.params.id;
const sql = 'DELETE FROM roles WHERE id = ?';
const res = await pool.query(sql, [id]);
res.sendStatus(200);
} catch (error) {
console.log(error);
res.sendStatus(500);
}
})
module.exports.roles = r;
module.exports.memberRoles = ur;

View File

@@ -0,0 +1,46 @@
const express = require('express');
const status = express.Router();
const memberStatus = express.Router();
import pool from '../db';
//insert a new latest rank for a user
memberStatus.post('/', async (req, res) => {
// try {
// const App = req.body?.App || {};
// // TODO: replace with current user ID
// const memberId = 1;
// const sql = `INSERT INTO applications (member_id, app_version, app_data) VALUES (?, ?, ?);`;
// const appVersion = 1;
// const params = [memberId, appVersion, JSON.stringify(App)]
// console.log(params)
// await pool.query(sql, params);
// res.sendStatus(201);
// } catch (err) {
// console.error('Insert failed:', err);
// res.status(500).json({ error: 'Failed to save application' });
// }
res.status(501).json({ error: 'Not implemented' });
});
//get all statuses
status.get('/', async (req, res) => {
try {
const result = await pool.query('SELECT * FROM statuses;');
res.json(result);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Internal server error' });
}
});
module.exports.status = status;
module.exports.memberStatus = memberStatus;
// TODO, implement get all ranks route with SQL stirng SELECT id, name, short_name, category, sort_id FROM ranks;

View File

@@ -0,0 +1,71 @@
import { ApplicationListRow, ApplicationRow, CommentRow } from "@app/shared/types/application";
import pool from "../db";
export async function createApplication(memberID: number, appVersion: number, app: string) {
const sql = `INSERT INTO applications (member_id, app_version, app_data) VALUES (?, ?, ?);`;
const params = [memberID, appVersion, JSON.stringify(app)]
return await pool.query(sql, params);
}
export async function getMemberApplication(memberID: number): Promise<ApplicationRow> {
const sql = `SELECT app.*,
member.name AS member_name
FROM applications AS app
INNER JOIN members AS member ON member.id = app.member_id
WHERE app.member_id = ?;`;
let app: ApplicationRow[] = await pool.query(sql, [memberID]);
return app[0];
}
export async function getApplicationByID(appID: number): Promise<ApplicationRow> {
const sql =
`SELECT app.*,
member.name AS member_name
FROM applications AS app
INNER JOIN members AS member ON member.id = app.member_id
WHERE app.id = ?;`;
let app: ApplicationRow[] = await pool.query(sql, [appID]);
return app[0];
}
export async function getApplicationList(): Promise<ApplicationListRow[]> {
const sql = `SELECT
member.name AS member_name,
app.id,
app.member_id,
app.submitted_at,
app.app_status
FROM applications AS app
LEFT JOIN members AS member
ON member.id = app.member_id;`
const rows: ApplicationListRow[] = await pool.query(sql);
return rows;
}
export async function approveApplication(id) {
const sql = `
UPDATE applications
SET approved_at = NOW()
WHERE id = ?
AND approved_at IS NULL
AND denied_at IS NULL
`;
const result = await pool.execute(sql, id);
return result;
}
export async function getApplicationComments(appID: number): Promise<CommentRow[]> {
return await pool.query(`SELECT app.id AS comment_id,
app.post_content,
app.poster_id,
app.post_time,
app.last_modified,
member.name AS poster_name
FROM application_comments AS app
INNER JOIN members AS member ON member.id = app.poster_id
WHERE app.application_id = ?;`,
[appID]);
}

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

@@ -0,0 +1,150 @@
import pool from '../db';
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'>) {
const sql = `
INSERT INTO calendar_events
(name, start, end, location, color, description, creator)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`;
const params = [
eventObject.name,
eventObject.start,
eventObject.end,
eventObject.location,
eventObject.color,
eventObject.description ?? null,
eventObject.creator,
];
const result = await pool.query(sql, params);
return { id: result.insertId, ...eventObject };
}
export async function updateEvent(eventObject: CalendarEvent) {
if (!eventObject.id) {
throw new Error("updateEvent: Missing event ID.");
}
const sql = `
UPDATE calendar_events
SET
name = ?,
start = ?,
end = ?,
location = ?,
color = ?,
description = ?,
WHERE id = ?
`;
const params = [
eventObject.name,
eventObject.start,
eventObject.end,
eventObject.location,
eventObject.color,
eventObject.description ?? null,
eventObject.id
];
await pool.query(sql, params);
return { success: true };
}
export async function cancelEvent(eventID: number) {
const sql = `
UPDATE calendar_events
SET cancelled = 1
WHERE id = ?
`;
await pool.query(sql, [eventID]);
return { success: true };
}
export async function getShortEventsInRange(startDate: Date, endDate: Date) {
const sql = `
SELECT id, name, start, end, color
FROM calendar_events
WHERE start BETWEEN ? AND ?
ORDER BY start ASC
`;
return await pool.query(sql, [startDate, endDate]);
}
export async function getEventDetails(eventID: number) {
const sql = `
SELECT
e.id,
e.name,
e.start,
e.end,
e.location,
e.color,
e.description,
e.cancelled,
e.created_at,
e.updated_at,
m.id AS creator_id,
m.name AS creator_name
FROM calendar_events e
LEFT JOIN members m ON e.creator = m.id
WHERE e.id = ?
`;
return await pool.query(sql, [eventID])
}
export async function getUpcomingEvents(date: Date, limit: number) {
const sql = `
SELECT id, name, start, end, color
FROM calendar_events
WHERE start >= ?
AND cancelled = 0
ORDER BY start ASC
LIMIT ?
`;
return await pool.query(sql, [date, limit]);
}
export async function setAttendanceStatus(memberID: number, eventID: number, status: Attendance) {
const sql = `
INSERT INTO calendar_events_signups (member_id, event_id, status)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE status = VALUES(status), updated_at = CURRENT_TIMESTAMP
`;
await pool.query(sql, [memberID, eventID, status]);
return { success: true }
}
export async function getEventAttendance(eventID: number) {
const sql = `
SELECT
s.member_id,
s.status,
m.name AS member_name
FROM calendar_events_signups s
LEFT JOIN members m ON s.member_id = m.id
WHERE s.event_id = ?
`;
return await pool.query(sql, [eventID]);
}

View File

@@ -0,0 +1,23 @@
import pool from "../db";
export enum MemberState {
Guest = "guest",
Applicant = "applicant",
Member = "member",
Retired = "retired",
Banned = "banned",
Denied = "denied"
}
export async function getUserData(userID: number) {
const sql = `SELECT * FROM members WHERE id = ?`;
const res = await pool.query(sql, [userID]);
return res[0] ?? null;
}
export async function setUserState(userID: number, state: MemberState) {
const sql = `UPDATE members
SET state = ?
WHERE id = ?;`;
return await pool.query(sql, [state, userID]);
}

View File

@@ -0,0 +1,32 @@
import pool from "../db";
export async function getAllRanks() {
const rows = await pool.query(
'SELECT id, name, short_name, sort_id FROM ranks;'
);
return rows;
}
export async function getRankByName(name: string) {
const rows = await pool.query(`SELECT id, name, short_name, sort_id FROM ranks WHERE name = ?`, [name]);
if (rows.length === 0)
throw new Error("Could not find rank: " + name);
return rows[0];
}
export async function insertMemberRank(member_id: number, rank_id: number, date: Date): Promise<void>;
export async function insertMemberRank(member_id: number, rank_id: number): Promise<void>;
export async function insertMemberRank(member_id: number, rank_id: number, date?: Date): Promise<void> {
const sql = date
? `INSERT INTO members_ranks (member_id, rank_id, event_date) VALUES (?, ?, ?);`
: `INSERT INTO members_ranks (member_id, rank_id, event_date) VALUES (?, ?, NOW());`;
const params = date
? [member_id, rank_id, date]
: [member_id, rank_id];
await pool.query(sql, params);
}

View File

@@ -0,0 +1,26 @@
import pool from '../db';
export async function assignUserGroup(userID: number, roleID: number) {
const sql = `INSERT INTO members_roles (member_id, role_id) VALUES (?, ?);`;
const params = [userID, roleID];
return await pool.query(sql, params);
}
export async function createGroup(name: string, color: string, description: string) {
const sql = `INSERT INTO roles (name, color, description) VALUES (?, ?, ?)`;
const params = [name, color, description];
const result = await pool.query(sql, params);
return { id: result.insertId, name, color, description };
}
export async function getUserRoles(userID: number) {
const sql = `SELECT r.id, r.name
FROM members_roles mr
INNER JOIN roles r ON mr.role_id = r.id
WHERE mr.member_id = 190;`;
return await pool.query(sql, [userID]);
}

View File

@@ -0,0 +1,6 @@
import pool from "../db"
export async function assignUserToStatus(userID: number, statusID: number) {
const sql = `INSERT INTO members_statuses (member_id, status_id, event_date) VALUES (?, ?, NOW())`
await pool.execute(sql, [userID, statusID]);
}

17
api/tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"outDir": "./built",
"allowJs": true,
"target": "es5",
"types": [
"node",
"express"
],
"paths": {
"@app/shared/*": ["../shared/*"]
}
},
"include": [
"./src/**/*"
]
}

6
shared/package.json Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "@app/shared",
"version": "1.0.0",
"main": "index.ts",
"type": "module"
}

View File

@@ -0,0 +1,63 @@
export interface ApplicationData {
dob: string;
name: string;
playtime: number;
hobbies: string;
military: boolean;
communities: string;
joinReason: string;
milsimAttraction: string;
referral: string;
steamProfile: string;
timezone: string;
canAttendSaturday: boolean;
interests: string;
aknowledgeRules: boolean;
}
export interface ApplicationRow {
id: number;
member_id: number;
app_version: number;
app_data: ApplicationData;
submitted_at: string; // ISO datetime from DB (e.g., "2025-08-25T18:04:29.000Z")
updated_at: string | null;
approved_at: string | null;
denied_at: string | null;
app_status: ApplicationStatus; // generated column
decision_at: string | null; // generated column
// present when you join members (e.g., SELECT a.*, m.name AS member_name)
member_name: string;
}
export interface CommentRow {
comment_id: number;
post_content: string;
poster_id: number;
post_time: string;
last_modified: string | null;
poster_name: string;
}
export interface ApplicationFull {
application: ApplicationRow;
comments: CommentRow[];
}
//for get all applications route
export interface ApplicationListRow {
id: number;
member_id: number;
member_name: string | null; // because LEFT JOIN means it might be null
submitted_at: Date;
app_status: string; // or enum if you have one
}
export enum ApplicationStatus {
Pending = "Pending",
Accepted = "Accepted",
Denied = "Denied",
}

View File

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

976
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,16 +12,27 @@
"preview": "vite preview"
},
"dependencies": {
"@fullcalendar/core": "^6.1.19",
"@fullcalendar/daygrid": "^6.1.19",
"@fullcalendar/interaction": "^6.1.19",
"@fullcalendar/timegrid": "^6.1.19",
"@fullcalendar/vue3": "^6.1.19",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/vue-table": "^8.21.3",
"@vee-validate/zod": "^4.15.1",
"@vueuse/core": "^13.9.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-vue-next": "^0.539.0",
"pinia": "^3.0.3",
"reka-ui": "^2.5.0",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.11",
"tw-animate-css": "^1.3.6",
"vee-validate": "^4.15.1",
"vue": "^3.5.18",
"vue-router": "^4.5.1"
"vue-router": "^4.5.1",
"zod": "^3.25.76"
},
"devDependencies": {
"@types/node": "^24.2.1",

View File

@@ -1,11 +1,130 @@
<script setup></script>
<script setup>
import { RouterLink, RouterView } from 'vue-router';
import Separator from './components/ui/separator/Separator.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 Alert from './components/ui/alert/Alert.vue';
import AlertDescription from './components/ui/alert/AlertDescription.vue';
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) {
if (!dateStr) return "";
return new Date(dateStr).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
}
</script>
<template>
<h1>You did it!</h1>
<p>
Visit <a href="https://vuejs.org/" target="_blank" rel="noopener">vuejs.org</a> to read the
documentation
</p>
<div>
<div class="flex items-center justify-between px-10">
<div></div>
<div class="h-15 flex items-center justify-center gap-20">
<RouterLink to="/">
<Button variant="link">Home</Button>
</RouterLink>
<RouterLink to="/calendar">
<Button variant="link">Calendar</Button>
</RouterLink>
<RouterLink to="/members">
<Button variant="link">Members</Button>
</RouterLink>
<Popover>
<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>
</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>
</template>
<style scoped></style>

138
ui/src/api/application.ts Normal file
View File

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

42
ui/src/api/calendar.ts Normal file
View File

@@ -0,0 +1,42 @@
export interface CalendarEvent {
name: string,
start: Date,
end: Date,
location: string,
color: string,
description: string,
creator: any | null, // user object
id: number | null
}
export enum CalendarAttendance {
Attending = "attending",
NotAttending = "not_attending",
Maybe = "maybe"
}
export interface CalendarSignup {
memberID: number,
eventID: number,
state: CalendarAttendance
}
export async function createCalendarEvent(eventData: CalendarEvent) {
}
export async function editCalendarEvent(eventData: CalendarEvent) {
}
export async function cancelCalendarEvent(eventID: number) {
}
export async function adminCancelCalendarEvent(eventID: number) {
}
export async function setCalendarEventAttendance(eventID: number, state: CalendarAttendance) {
}

62
ui/src/api/loa.ts Normal file
View File

@@ -0,0 +1,62 @@
export type LOARequest = {
id?: number;
name?: string;
member_id: number;
filed_date: string; // ISO 8601 string
start_date: string; // ISO 8601 string
end_date: string; // ISO 8601 string
reason?: string;
};
// @ts-ignore
const addr = import.meta.env.VITE_APIHOST;
export async function submitLOA(request: LOARequest): Promise<{ id?: number; error?: string }> {
const res = await fetch(`${addr}/loa`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(request),
});
if (res.ok) {
return res.json();
} else {
return { error: "Failed to submit LOA" };
}
}
export async function getMyLOA(): Promise<LOARequest | null> {
const res = await fetch(`${addr}/loa/me`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
if (res.ok) {
const out = res.json();
if (!out) {
return null;
}
return out;
} else {
return null;
}
}
export function getAllLOAs(): Promise<LOARequest[]> {
return fetch(`${addr}/loa/all`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
}).then((res) => {
if (res.ok) {
return res.json();
} else {
return [];
}
});
}

22
ui/src/api/member.ts Normal file
View File

@@ -0,0 +1,22 @@
export type Member = {
member_id: number;
member_name: string;
rank: string | null;
rank_date: string | null;
status: string | null;
status_date: string | null;
on_loa: boolean | null;
};
// @ts-ignore
const addr = import.meta.env.VITE_APIHOST;
export async function getMembers(): Promise<Member[]> {
const response = await fetch(`${addr}/members`, {
credentials: 'include'
});
if (!response.ok) {
throw new Error("Failed to fetch members");
}
return response.json();
}

38
ui/src/api/rank.ts Normal file
View File

@@ -0,0 +1,38 @@
export type Rank = {
id: number
name: string
short_name: string
sortOrder: number
}
// @ts-ignore
const addr = import.meta.env.VITE_APIHOST;
export async function getRanks(): Promise<Rank[]> {
const res = await fetch(`${addr}/ranks`)
if (res.ok) {
return res.json()
} else {
console.error("Something went wrong approving the application")
}
}
// Placeholder: submit a rank change
export async function submitRankChange(member_id: number, rank_id: number, date: string): Promise<{ ok: boolean }> {
const res = await fetch(`${addr}/memberRanks`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ change: { member_id, rank_id, date } }),
})
if (res.ok) {
return { ok: true }
} else {
console.error("Failed to submit rank change")
return { ok: false }
}
}

95
ui/src/api/roles.ts Normal file
View File

@@ -0,0 +1,95 @@
export type Role = {
id: number;
name: string;
color: string;
description: string | null;
members: any[];
};
// @ts-ignore
const addr = import.meta.env.VITE_APIHOST;
export async function getRoles(): Promise<Role[]> {
const res = await fetch(`${addr}/roles`)
if (res.ok) {
return res.json() as Promise<Role[]>;
} else {
console.error("Something went wrong")
return [];
}
}
export async function createRole(name: string, color: string, description: string | null): Promise<Role | null> {
const res = await fetch(`${addr}/roles`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
name,
color,
description
})
});
if (res.ok) {
return res.json() as Promise<Role>;
} else {
console.error("Something went wrong creating the role");
return null;
}
}
export async function addMemberToRole(member_id: number, role_id: number): Promise<boolean> {
const res = await fetch(`${addr}/memberRoles`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
member_id,
role_id
})
});
if (res.ok) {
return true;
} else {
console.error("Something went wrong adding the member to the role");
return false;
}
}
export async function removeMemberFromRole(member_id: number, role_id: number): Promise<boolean> {
const res = await fetch(`${addr}/memberRoles`, {
method: "DELETE",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
member_id,
role_id
})
});
if (res.ok) {
return true;
} else {
console.error("Something went wrong removing the member from the role");
return false;
}
}
export async function deleteRole(role_id: number): Promise<boolean> {
const res = await fetch(`${addr}/roles/${role_id}`, {
method: "DELETE"
});
if (res.ok) {
return true;
} else {
console.error("Something went wrong deleting the role");
return false;
}
}

34
ui/src/api/status.ts Normal file
View File

@@ -0,0 +1,34 @@
export type Status = {
id: number;
name: string;
created_at: string; // datetime as ISO string
updated_at: string; // datetime as ISO string
deleted?: boolean; // tinyint, optional if nullable
};
// @ts-ignore
const addr = import.meta.env.VITE_APIHOST;
export async function getAllStatuses(): Promise<Status[]> {
const res = await fetch(`${addr}/status`)
if (res.ok) {
return res.json()
} else {
console.error("Something went wrong getting statuses")
}
}
export async function assignStatus(userId: number, statusId: number, rankId: number): Promise<void> {
const res = await fetch(`${addr}/memberStatus`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ userId, statusId, rankId })
})
if (!res.ok) {
console.error("Something went wrong assigning the status")
}
}

View File

@@ -1,102 +1,107 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(0.9911 0 0);
--foreground: oklch(0.2046 0 0);
--card: oklch(0.9911 0 0);
--card-foreground: oklch(0.2046 0 0);
--popover: oklch(0.9911 0 0);
--popover-foreground: oklch(0.4386 0 0);
--primary: oklch(1.0000 0 0);
--primary-foreground: oklch(0.3709 0.0313 95.1202);
--secondary: oklch(0.9940 0 0);
--secondary-foreground: oklch(0.2046 0 0);
--muted: oklch(0.9461 0 0);
--muted-foreground: oklch(0.2435 0 0);
--accent: oklch(0.9461 0 0);
--accent-foreground: oklch(0.2435 0 0);
--destructive: oklch(0.6704 0.2070 300.0793);
--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);
--border: oklch(0.9037 0 0);
--input: oklch(0.9731 0 0);
--ring: oklch(1.0000 0 0);
--chart-1: oklch(1.0000 0 0);
--chart-2: oklch(0.9936 0.0111 141.2643);
--chart-3: oklch(1.0000 0 0);
--chart-4: oklch(0.8317 0.1444 322.3270);
--chart-5: oklch(0.9397 0.1746 103.9958);
--sidebar: oklch(0.9911 0 0);
--sidebar-foreground: oklch(0.5452 0 0);
--sidebar-primary: oklch(1.0000 0 0);
--sidebar-primary-foreground: oklch(0.3709 0.0313 95.1202);
--sidebar-accent: oklch(0.9461 0 0);
--sidebar-accent-foreground: oklch(0.2435 0 0);
--sidebar-border: oklch(0.9037 0 0);
--sidebar-ring: oklch(1.0000 0 0);
--font-sans: Outfit, sans-serif;
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--font-mono: monospace;
--radius: 0.5rem;
--shadow-2xs: 0px 1px 3px 0px hsl(0 0% 0% / 0.09);
--shadow-xs: 0px 1px 3px 0px hsl(0 0% 0% / 0.09);
--shadow-sm: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 1px 2px -1px hsl(0 0% 0% / 0.17);
--shadow: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 1px 2px -1px hsl(0 0% 0% / 0.17);
--shadow-md: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 2px 4px -1px hsl(0 0% 0% / 0.17);
--shadow-lg: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 4px 6px -1px hsl(0 0% 0% / 0.17);
--shadow-xl: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 8px 10px -1px hsl(0 0% 0% / 0.17);
--shadow-2xl: 0px 1px 3px 0px hsl(0 0% 0% / 0.43);
--tracking-normal: 0.025em;
--spacing: 0.25rem;
--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.1822 0 0);
--foreground: oklch(1.0000 0 0);
--card: oklch(0.2046 0 0);
--card-foreground: oklch(1.0000 0 0);
--popover: oklch(0.2603 0 0);
--popover-foreground: oklch(0.7348 0 0);
--primary: oklch(0.6277 0.1287 94.1115);
--primary-foreground: oklch(1.0000 0 0);
--secondary: oklch(0.2603 0 0);
--secondary-foreground: oklch(0.9851 0 0);
--muted: oklch(0.2393 0 0);
--muted-foreground: oklch(0.7122 0 0);
--accent: oklch(0.3132 0 0);
--accent-foreground: oklch(0.9851 0 0);
--destructive: oklch(0.3712 0.2143 284.7713);
--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);
--border: oklch(0.2809 0 0);
--input: oklch(0.2603 0 0);
--ring: oklch(0.9786 0.0203 81.7829);
--chart-1: oklch(0.9786 0.0203 81.7829);
--chart-2: oklch(1.0000 0 0);
--chart-3: oklch(1.0000 0 0);
--chart-4: oklch(0.9312 0.0608 325.1964);
--chart-5: oklch(0.9724 0.1085 114.3821);
--sidebar: oklch(0.1822 0 0);
--sidebar-foreground: oklch(0.6301 0 0);
--sidebar-primary: oklch(0.6277 0.1287 94.1115);
--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.3132 0 0);
--sidebar-accent-foreground: oklch(0.9851 0 0);
--sidebar-border: oklch(0.2809 0 0);
--sidebar-ring: oklch(0.9786 0.0203 81.7829);
--font-sans: Outfit, sans-serif;
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--font-mono: monospace;
--radius: 0.5rem;
--shadow-2xs: 0px 1px 3px 0px hsl(0 0% 0% / 0.09);
--shadow-xs: 0px 1px 3px 0px hsl(0 0% 0% / 0.09);
--shadow-sm: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 1px 2px -1px hsl(0 0% 0% / 0.17);
--shadow: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 1px 2px -1px hsl(0 0% 0% / 0.17);
--shadow-md: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 2px 4px -1px hsl(0 0% 0% / 0.17);
--shadow-lg: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 4px 6px -1px hsl(0 0% 0% / 0.17);
--shadow-xl: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 8px 10px -1px hsl(0 0% 0% / 0.17);
--shadow-2xl: 0px 1px 3px 0px hsl(0 0% 0% / 0.43);
--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 {
--color-background: var(--background);
--color-foreground: var(--foreground);
@@ -114,6 +119,8 @@
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
@@ -148,15 +155,14 @@
--shadow-lg: var(--shadow-lg);
--shadow-xl: var(--shadow-xl);
--shadow-2xl: var(--shadow-2xl);
}
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
--tracking-normal: var(--tracking-normal);
--tracking-wide: calc(var(--tracking-normal) + 0.025em);
--tracking-wider: calc(var(--tracking-normal) + 0.05em);
--tracking-widest: calc(var(--tracking-normal) + 0.1em);
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
letter-spacing: var(--tracking-normal);
@apply bg-background text-foreground;
}
}

View File

@@ -0,0 +1,75 @@
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import { Form } from 'vee-validate'
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import Textarea from '@/components/ui/textarea/Textarea.vue'
import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod'
const props = defineProps<{
messages: Array<Record<string, any>>
}>()
const emit = defineEmits<{
(e: 'post', text: string): void
}>()
const commentSchema = toTypedSchema(
z.object({
text: z.string().trim().min(1, 'Required').max(2000, 'Max 2000 characters'),
})
)
// vee-validate passes (values, actions) to @submit
function onSubmit(values: { text: string }, { resetForm }: { resetForm: () => void }) {
emit('post', values.text.trim())
resetForm()
}
</script>
<template>
<div class="space-y-4">
<Form :validation-schema="commentSchema" @submit="onSubmit">
<FormField name="text" v-slot="{ componentField }">
<FormItem>
<FormLabel class="sr-only">Comment</FormLabel>
<FormControl>
<Textarea v-bind="componentField" rows="3" placeholder="Write a comment…"
class="bg-neutral-800 resize-none w-full" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Button below, right-aligned -->
<div class="mt-2 flex justify-end">
<Button type="submit">Post</Button>
</div>
</Form>
<!-- Existing posts -->
<div class="space-y-3">
<div v-for="(message, i) in props.messages" :key="message.id ?? i"
class="rounded-md border border-neutral-800 p-3 space-y-5">
<!-- Comment header -->
<div class="flex justify-between">
<p>{{ message.poster_name }}</p>
<p class="text-muted-foreground">{{ new Date(message.post_time).toLocaleString("EN-us", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit"
}) }}</p>
</div>
<p>{{ message.post_content }}</p>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,258 @@
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue';
import Checkbox from '@/components/ui/checkbox/Checkbox.vue';
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import Input from '@/components/ui/input/Input.vue';
import Textarea from '@/components/ui/textarea/Textarea.vue';
import { toTypedSchema } from '@vee-validate/zod';
import { Form } from 'vee-validate';
import { onMounted, ref } from 'vue';
import * as z from 'zod';
import DateInput from '../form/DateInput.vue';
import { ApplicationData } from '@/api/application';
const formSchema = toTypedSchema(z.object({
dob: z.string().refine(v => v, { message: "A date of birth is required." }),
name: z.string(),
playtime: z.coerce.number({ invalid_type_error: "Must be a number", }).min(0, "Cannot be less than 0"),
hobbies: z.string(),
military: z.boolean(),
communities: z.string(),
joinReason: z.string(),
milsimAttraction: z.string(),
referral: z.string(),
steamProfile: z.string(),
timezone: z.string(),
canAttendSaturday: z.boolean(),
interests: z.string(),
aknowledgeRules: z.literal(true, {
errorMap: () => ({ message: "Required" })
}),
}))
const fallbackInitials = {
military: false,
canAttendSaturday: false,
aknowledgeRules: false,
}
const props = defineProps<{
readOnly: boolean,
data: ApplicationData,
}>()
const emit = defineEmits(['submit']);
const initialValues = ref<Record<string, unknown> | null>(null);
async function onSubmit(val: any) {
emit('submit', val);
}
onMounted(() => {
if (props.data !== null) {
const parsed = typeof props.data === "string"
? JSON.parse(props.data)
: props.data;
initialValues.value = { ...parsed };
} else {
initialValues.value = { ...fallbackInitials };
}
})
</script>
<template>
<Form v-if="initialValues" :validation-schema="formSchema" :initial-values="initialValues" @submit="onSubmit"
class="space-y-6">
<!-- Age -->
<FormField name="dob" v-slot="{ value, handleChange }">
<FormItem>
<FormLabel>What is your date of birth?</FormLabel>
<FormControl>
<DateInput :model-value="(value as string) ?? ''" :disabled="readOnly" @update:model-value="handleChange" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Name -->
<FormField name="name" v-slot="{ value, handleChange }">
<FormItem>
<FormLabel>What name will you be going by within the community?</FormLabel>
<FormDescription>This name must be consistent across platforms.</FormDescription>
<FormControl>
<Input :model-value="value" @update:model-value="handleChange" :disabled="readOnly" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Playtime -->
<FormField name="playtime" v-slot="{ value, handleChange }">
<FormItem>
<FormLabel>How long have you played Arma 3 for (in hours)?</FormLabel>
<FormControl>
<Input type="number" :model-value="value" @update:model-value="handleChange" :disabled="readOnly" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Hobbies -->
<FormField name="hobbies" v-slot="{ value, handleChange }">
<FormItem>
<FormLabel>What hobbies do you like to participate in outside of gaming?</FormLabel>
<FormControl>
<Textarea rows="4" class="resize-none" :model-value="value" @update:model-value="handleChange"
:disabled="readOnly" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Military (boolean) -->
<FormField name="military" v-slot="{ value, handleChange }">
<FormItem>
<FormLabel>Have you ever served in the military?</FormLabel>
<FormControl>
<div class="flex items-center gap-2">
<Checkbox :checked="value ?? false" @update:checked="handleChange" :disabled="readOnly" />
<span>Yes (checked) / No (unchecked)</span>
</div>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Other communities (freeform) -->
<FormField name="communities" v-slot="{ value, handleChange }">
<FormItem>
<FormLabel>Are you a part of any other communities? If so, which ones? If none, type "No"</FormLabel>
<FormControl>
<Input :model-value="value" @update:model-value="handleChange" :disabled="readOnly" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Why join -->
<FormField name="joinReason" v-slot="{ value, handleChange }">
<FormItem>
<FormLabel>Why do you want to join our community?</FormLabel>
<FormControl>
<Textarea rows="4" class="resize-none" :model-value="value" @update:model-value="handleChange"
:disabled="readOnly" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Attraction to milsim -->
<FormField name="milsimAttraction" v-slot="{ value, handleChange }">
<FormItem>
<FormLabel>What attracts you to the Arma 3 milsim playstyle?</FormLabel>
<FormControl>
<Textarea rows="4" class="resize-none" :model-value="value" @update:model-value="handleChange"
:disabled="readOnly" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Referral (freeform) -->
<FormField name="referral" v-slot="{ value, handleChange }">
<FormItem>
<FormLabel>Where did you hear about us? (If another member, who?)</FormLabel>
<FormControl>
<Input placeholder="e.g., Reddit / Member: Alice" :model-value="value" @update:model-value="handleChange"
:disabled="readOnly" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Steam profile -->
<FormField name="steamProfile" v-slot="{ value, handleChange }">
<FormItem>
<FormLabel>Steam profile link</FormLabel>
<FormDescription>
Format: <code>https://steamcommunity.com/id/USER/</code> or
<code>https://steamcommunity.com/profiles/STEAMID64/</code>
</FormDescription>
<FormControl>
<Input type="url" placeholder="https://steamcommunity.com/profiles/7656119..." :model-value="value"
@update:model-value="handleChange" :disabled="readOnly" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Timezone -->
<FormField name="timezone" v-slot="{ value, handleChange }">
<FormItem>
<FormLabel>What time zone are you in?</FormLabel>
<FormControl>
<Input placeholder="e.g., AEST, EST, UTC+10" :model-value="value" @update:model-value="handleChange"
:disabled="readOnly" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Attendance (boolean) -->
<FormField name="canAttendSaturday" v-slot="{ value, handleChange }">
<FormItem>
<FormLabel>Are you able to attend weekly operations Saturdays @ 7pm CST?</FormLabel>
<FormControl>
<div class="flex items-center gap-2">
<Checkbox :model-value="value ?? false" @update:model-value="handleChange" :disabled="readOnly" />
<span>Yes (checked) / No (unchecked)</span>
</div>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Interests / Playstyle (freeform) -->
<FormField name="interests" v-slot="{ value, handleChange }">
<FormItem>
<FormLabel>Which playstyles interest you?</FormLabel>
<FormControl>
<Input placeholder="e.g., Rifleman; Medic; Pilot" :model-value="value" @update:model-value="handleChange"
:disabled="readOnly" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Code of Conduct (boolean, field name kept as-is) -->
<FormField name="aknowledgeRules" v-slot="{ value, handleChange }">
<FormItem>
<FormLabel>Community Code of Conduct</FormLabel>
<FormControl>
<div class="flex items-center gap-2">
<Checkbox :model-value="value" @update:model-value="handleChange" :disabled="readOnly" />
<span>By checking this box, you accept the <Button variant="link" class="p-0">Code of
Conduct</Button>.</span>
</div>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<div class="pt-2" v-if="!readOnly">
<Button type="submit" :onClick="() => console.log('hi')" :disabled="readOnly">Submit Application</Button>
</div>
</Form>
</template>

View File

@@ -0,0 +1,257 @@
<script setup lang="ts">
import { toTypedSchema } from "@vee-validate/zod"
import { ref, defineExpose, watch } from "vue"
import * as z from "zod"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import Textarea from "../ui/textarea/Textarea.vue"
import { CalendarEvent } from "@/api/calendar"
// ---------- 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) {
// yyyy-MM-dd with local time zone
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, "0")
const day = String(d.getDate()).padStart(2, "0")
return `${y}-${m}-${day}`
}
function toLocalTimeString(d: Date) {
const hh = String(d.getHours()).padStart(2, "0")
const mm = String(d.getMinutes()).padStart(2, "0")
return `${hh}:${mm}`
}
function roundToNextHour(d = new Date()) {
const t = new Date(d)
t.setMinutes(0, 0, 0)
t.setHours(t.getHours() + 1)
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)
}
// ---------- schema ----------
const zEvent = 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
})
}
})
const formSchema = toTypedSchema(zEvent)
// ---------- dialog state & defaults ----------
const dialogOpen = ref(false)
function openDialog() { dialogOpen.value = true }
defineExpose({ openDialog })
function makeInitialValues() {
const start = roundToNextHour()
const end = new Date(start.getTime() + 60 * 60 * 1000)
return {
name: "",
startDate: toLocalDateString(start),
startTime: toLocalTimeString(start),
endDate: toLocalDateString(end),
endTime: toLocalTimeString(end),
location: "",
color: "#3b82f6",
description: "",
id: null as number | null,
}
}
const initialValues = ref(makeInitialValues())
const formKey = ref(0)
watch(dialogOpen, (isOpen) => {
if (!isOpen) {
formKey.value++ // remounts the form -> picks up fresh initialValues
}
})
// ---------- submit ----------
function onSubmit(vals: z.infer<typeof zEvent>) {
const start = parseLocalDateTime(vals.startDate, vals.startTime)
const end = parseLocalDateTime(vals.endDate, vals.endTime)
const event: CalendarEvent = {
name: vals.name,
start,
end,
location: vals.location,
color: vals.color,
description: vals.description,
id: null,
creator: null
}
console.log("Submitting CalendarEvent:", event)
// close after success
dialogOpen.value = false
}
</script>
<template>
<Form :key="formKey" v-slot="{ handleSubmit, resetForm }" :validation-schema="formSchema"
:initial-values="initialValues" keep-values as="">
<Dialog v-model:open="dialogOpen">
<DialogContent class="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle>Create Event</DialogTitle>
</DialogHeader>
<form id="dialogForm" class="grid grid-cols-1 gap-4"
@submit="handleSubmit($event, (vals) => { onSubmit(vals); resetForm({ values: initialValues }); })">
<div class="flex gap-3 items-start w-full">
<!-- Name -->
<div class="flex-1">
<FormField v-slot="{ componentField }" name="name">
<FormItem>
<FormLabel>Event Name</FormLabel>
<FormControl>
<Input type="text" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
<!-- Color -->
<div class="w-[60px]">
<FormField v-slot="{ componentField }" name="color">
<FormItem>
<FormLabel>Color</FormLabel>
<FormControl>
<Input type="color" class="h-[38px] p-1 cursor-pointer"
v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
</div>
<!-- Start: date + time -->
<div class="grid grid-cols-2 gap-3">
<FormField v-slot="{ componentField }" name="startDate">
<FormItem>
<FormLabel>Start Date</FormLabel>
<FormControl>
<Input type="date" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="startTime">
<FormItem>
<FormLabel>Start Time</FormLabel>
<FormControl>
<Input type="text" v-bind="componentField" />
<!-- If you ever want native picker: type="time" -->
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
<!-- End: date + time -->
<div class="grid grid-cols-2 gap-3">
<FormField v-slot="{ componentField }" name="endDate">
<FormItem>
<FormLabel>End Date</FormLabel>
<FormControl>
<Input type="date" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="endTime">
<FormItem>
<FormLabel>End Time</FormLabel>
<FormControl>
<Input type="text" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
<!-- Location -->
<FormField v-slot="{ componentField }" name="location">
<FormItem>
<FormLabel>Location</FormLabel>
<FormControl>
<Input type="text" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Description -->
<FormField v-slot="{ componentField }" name="description">
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea class="resize-none h-32" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Hidden id -->
<FormField v-slot="{ componentField }" name="id">
<input type="hidden" v-bind="componentField" />
</FormField>
</form>
<DialogFooter>
<Button type="submit" form="dialogForm">Create</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Form>
</template>

View File

@@ -0,0 +1,56 @@
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue';
import { toTypedSchema } from '@vee-validate/zod';
import { Form } from 'vee-validate';
import { onMounted, ref } from 'vue';
import * as z from 'zod';
import FormInput from './FormInput.vue';
import FormCheckboxs from './FormCheckbox.vue';
const formSchema = toTypedSchema(z.object({
name: z.string(),
other: z.boolean(),
}))
const initialValues = ref<Record<string, unknown> | null>(null);
async function onSubmit(val: any) {
console.log(val);
// emit('submit', val);
}
enum FormTypes {
Checkbox = "FormCheckbox",
Text = "FormInput"
}
const schema = [
{
name: "name",
label: "What is your name?",
description: "Something something name name",
readOnly: false,
type: FormTypes.Text
},
{
name: "other",
label: "This is a checkbox",
description: "This is a description",
readOnly: false,
type: FormTypes.Checkbox
}
]
</script>
<template>
<Form :validation-schema="formSchema" :initial-values="initialValues" @submit="onSubmit" class="space-y-6">
<component v-for="field in schema" :is="field.type" :description="field.description" :label="field.label"
:read-only="field.readOnly" :name="field.name"></component>
<!-- <FormInput v-for="field in schema" :description="field.description" :label="field.label"
:read-only="field.readOnly" :name="field.name"></FormInput> -->
<div class="pt-2">
<Button type="submit" :onClick="() => console.log('hi')">Submit</Button>
</div>
</Form>
</template>

View File

@@ -0,0 +1,102 @@
<!-- SegmentedDob.vue -->
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue';
const props = defineProps<{
modelValue: string | null | undefined, // expected "MM/DD/YYYY" or ""
disabled?: boolean
}>();
const emit = defineEmits<{
(e: 'update:modelValue', v: string): void
}>();
const mm = ref(''); const dd = ref(''); const yyyy = ref('');
const mmRef = ref<HTMLInputElement>();
const ddRef = ref<HTMLInputElement>();
const yyyyRef = ref<HTMLInputElement>();
function digitsOnly(s: string) { return s.replace(/\D/g, ''); }
function syncFromModel(v?: string | null) {
const s = v || '';
const m = s.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
if (m) { mm.value = m[1]; dd.value = m[2]; yyyy.value = m[3]; }
else { mm.value = ''; dd.value = ''; yyyy.value = ''; }
}
function emitCombined() {
const hasAll = mm.value.length === 2 && dd.value.length === 2 && yyyy.value.length === 4;
const partial = [mm.value, dd.value, yyyy.value]
.filter((seg, idx) => seg.length > 0 || idx === 0) // keep first slash if month exists
.join('/');
emit('update:modelValue', hasAll ? `${mm.value}/${dd.value}/${yyyy.value}` : partial);
}
watch(() => props.modelValue, (v) => {
const s = v || '';
const m = s.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
if (!m) return; // don't overwrite partial typing
if (mm.value !== m[1] || dd.value !== m[2] || yyyy.value !== m[3]) {
mm.value = m[1]; dd.value = m[2]; yyyy.value = m[3];
}
}, { immediate: true });
function onInput(seg: 'mm' | 'dd' | 'yyyy', e: Event) {
const el = e.target as HTMLInputElement;
let val = digitsOnly(el.value);
if (seg !== 'yyyy') val = val.slice(0, 2); else val = val.slice(0, 4);
if (seg === 'mm') mm.value = val;
if (seg === 'dd') dd.value = val;
if (seg === 'yyyy') yyyy.value = val;
emitCombined();
// auto-advance
if (seg === 'mm' && val.length === 2) ddRef.value?.focus();
if (seg === 'dd' && val.length === 2) yyyyRef.value?.focus();
}
function onKeydown(seg: 'mm' | 'dd' | 'yyyy', e: KeyboardEvent) {
const el = e.target as HTMLInputElement;
if (e.key === 'Backspace' && el.selectionStart === 0 && el.selectionEnd === 0) {
if (seg === 'dd') mmRef.value?.focus();
if (seg === 'yyyy') ddRef.value?.focus();
}
}
function onPaste(e: ClipboardEvent) {
const text = e.clipboardData?.getData('text') ?? '';
const num = text.replace(/\D/g, '').slice(0, 8); // MMDDYYYY
if (num.length === 8) {
e.preventDefault();
mm.value = num.slice(0, 2);
dd.value = num.slice(2, 4);
yyyy.value = num.slice(4, 8);
emitCombined();
yyyyRef.value?.focus();
}
}
</script>
<template>
<div class="
inline-flex flex-none w-fit items-center gap-2 *:font-mono *:pl-2 pr-5 *:tabular-nums min-w-0 h-9 rounded-md px-3 py-1
border bg-transparent shadow-xs transition-[color,box-shadow] outline-none
dark:bg-input/30 border-input
focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]
aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive
disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50
" :class="disabled ? 'opacity-50' : ''">
<input ref="mmRef" :disabled="disabled" inputmode="numeric" autocomplete="bday-month" placeholder="MM"
class="focus:outline-0 w-[3ch] bg-transparent:" :value="mm" @input="onInput('mm', $event)"
@keydown="onKeydown('mm', $event)" maxlength="2" @paste="onPaste" />
<span class="text-muted-foreground">/</span>
<input ref="ddRef" :disabled="disabled" inputmode="numeric" autocomplete="bday-day" placeholder="DD"
class="focus:outline-0 w-[3ch] bg-transparent" :value="dd" @input="onInput('dd', $event)"
@keydown="onKeydown('dd', $event)" maxlength="2" @paste="onPaste" />
<span class="text-muted-foreground">/</span>
<input ref="yyyyRef" :disabled="disabled" inputmode="numeric" autocomplete="bday-year" placeholder="YYYY"
class="focus:outline-0 w-[5ch] bg-transparent" :value="yyyy" @input="onInput('yyyy', $event)" maxlength="4"
@keydown="onKeydown('yyyy', $event)" @paste="onPaste" />
</div>
</template>

View File

@@ -0,0 +1,45 @@
<script setup>
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import Input from '../ui/input/Input.vue';
import Checkbox from '../ui/checkbox/Checkbox.vue';
const props = defineProps({
name: {
type: String,
required: true
},
description: {
type: String,
default: ''
},
label: {
type: String,
default: ''
},
readOnly: {
type: Boolean,
default: false
}
});
</script>
<template>
<FormField :name="props.name" v-slot="{ value, handleChange }">
<FormItem>
<FormLabel>{{ props.label }}</FormLabel>
<FormDescription>{{ props.description }}</FormDescription>
<FormControl>
<Checkbox :checked="value ?? false" @update:checked="handleChange" :disabled="props.readOnly" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</template>

View File

@@ -0,0 +1,44 @@
<script setup>
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import Input from '../ui/input/Input.vue';
const props = defineProps({
name: {
type: String,
required: true
},
description: {
type: String,
default: ''
},
label: {
type: String,
default: ''
},
readOnly: {
type: Boolean,
default: false
}
});
</script>
<template>
<FormField :name="props.name" v-slot="{ value, handleChange }">
<FormItem>
<FormLabel>{{ props.label }}</FormLabel>
<FormDescription>{{ props.description }}</FormDescription>
<FormControl>
<Input :model-value="value" @update:model-value="handleChange" :disabled="props.readOnly" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</template>

View File

@@ -0,0 +1,172 @@
<script setup lang="ts">
import { Check, Search } from "lucide-vue-next"
import { Combobox, ComboboxAnchor, ComboboxEmpty, ComboboxGroup, ComboboxInput, ComboboxItem, ComboboxItemIndicator, ComboboxList } from "@/components/ui/combobox"
import { onMounted, ref } from "vue";
import { Member, getMembers } from "@/api/member";
import Button from "@/components/ui/button/Button.vue";
import {
CalendarDate,
DateFormatter,
getLocalTimeZone,
} from "@internationalized/date"
import type { DateRange } from "reka-ui"
import type { Ref } from "vue"
import Popover from "@/components/ui/popover/Popover.vue";
import PopoverTrigger from "@/components/ui/popover/PopoverTrigger.vue";
import PopoverContent from "@/components/ui/popover/PopoverContent.vue";
import { RangeCalendar } from "@/components/ui/range-calendar"
import { cn } from "@/lib/utils";
import { CalendarIcon } from "lucide-vue-next"
import Textarea from "@/components/ui/textarea/Textarea.vue";
import { LOARequest, submitLOA } from "@/api/loa"; // <-- import the submit function
const members = ref<Member[]>([])
const currentMember = ref<Member | null>(null);
const props = withDefaults(defineProps<{
adminMode?: boolean;
member?: Member | null;
}>(), {
adminMode: false,
member: null,
});
const df = new DateFormatter("en-US", {
dateStyle: "medium",
})
const value = ref({
// start: new CalendarDate(2022, 1, 20),
// end: new CalendarDate(2022, 1, 20).add({ days: 20 }),
}) as Ref<DateRange>
const reason = ref(""); // <-- reason for LOA
const submitting = ref(false);
const submitError = ref<string | null>(null);
const submitSuccess = ref(false);
onMounted(async () => {
if (props.member) {
currentMember.value = props.member;
}
if (props.adminMode) {
members.value = await getMembers();
}
members.value = await getMembers();
});
// Submit handler
async function handleSubmit() {
submitError.value = null;
submitSuccess.value = false;
submitting.value = true;
// Use currentMember if adminMode, otherwise use your own member id (stubbed as 89 here)
const member_id = currentMember.value?.member_id ?? 89;
// Format dates as ISO strings
const filed_date = toMariaDBDatetime(new Date());
const start_date = toMariaDBDatetime(value.value.start?.toDate(getLocalTimeZone()));
const end_date = toMariaDBDatetime(value.value.end?.toDate(getLocalTimeZone()));
if (!member_id || !filed_date || !start_date || !end_date) {
submitError.value = "Missing required fields";
submitting.value = false;
return;
}
const req: LOARequest = {
filed_date,
start_date,
end_date,
reason: reason.value,
member_id
};
const result = await submitLOA(req);
submitting.value = false;
if (result.id) {
submitSuccess.value = true;
reason.value = "";
} else {
submitError.value = result.error || "Failed to submit LOA";
}
}
function toMariaDBDatetime(date: Date): string {
return date.toISOString().slice(0, 19).replace('T', ' ');
}
</script>
<template>
<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 class="flex-2 space-y-1">
<p class="text-sm font-medium leading-none">
LOA Policy
</p>
<p class="text-sm text-muted-foreground">
Policy goes here.
</p>
</div>
</div>
<div class="flex-1 flex flex-col gap-5">
<div class="flex w-full gap-5 ">
<Combobox class="w-1/2" v-model="currentMember" :disabled="!adminMode">
<ComboboxAnchor class="w-full">
<ComboboxInput placeholder="Search members..." class="w-full pl-9"
:display-value="(v) => v ? v.member_name : ''" />
</ComboboxAnchor>
<ComboboxList class="w-full">
<ComboboxEmpty class="text-muted-foreground">No results</ComboboxEmpty>
<ComboboxGroup>
<template v-for="member in members" :key="member.member_id">
<ComboboxItem :value="member"
class="data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative cursor-pointer select-none px-2 py-1.5">
{{ member.member_name }}
<ComboboxItemIndicator class="absolute left-2 inline-flex items-center">
<Check class="h-4 w-4" />
</ComboboxItemIndicator>
</ComboboxItem>
</template>
</ComboboxGroup>
</ComboboxList>
</Combobox>
<Popover>
<PopoverTrigger as-child>
<Button variant="outline" :class="cn(
'w-1/2 justify-start text-left font-normal',
!value && 'text-muted-foreground',
)">
<CalendarIcon class="mr-2 h-4 w-4" />
<template v-if="value.start">
<template v-if="value.end">
{{ df.format(value.start.toDate(getLocalTimeZone())) }} - {{
df.format(value.end.toDate(getLocalTimeZone())) }}
</template>
<template v-else>
{{ df.format(value.start.toDate(getLocalTimeZone())) }}
</template>
</template>
<template v-else>
Pick a date
</template>
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-0">
<RangeCalendar v-model="value" initial-focus :number-of-months="2"
@update:start-value="(startDate) => value.start = startDate" />
</PopoverContent>
</Popover>
</div>
<Textarea v-model="reason" placeholder="Reason for LOA" class="w-full resize-none" />
<div class="flex justify-end">
<Button :onClick="handleSubmit" :disabled="submitting" class="w-min">Submit</Button>
</div>
<div v-if="submitError" class="text-red-500 text-sm mt-2">{{ submitError }}</div>
<div v-if="submitSuccess" class="text-green-500 text-sm mt-2">LOA submitted successfully!</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,107 @@
<script setup lang="ts">
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Badge } from '@/components/ui/badge'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Ellipsis } from "lucide-vue-next";
import { getAllLOAs, LOARequest } from "@/api/loa";
import { onMounted, ref, computed } from "vue";
const LOAList = ref<LOARequest[]>([]);
onMounted(async () => {
LOAList.value = await getAllLOAs();
});
function formatDate(dateStr: string): string {
if (!dateStr) return "";
return new Date(dateStr).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
}
function loaStatus(loa: {
start_date: string;
end_date: string;
deleted?: number;
}): "Upcoming" | "Active" | "Expired" | "Cancelled" {
if (loa.deleted) return "Cancelled";
const now = new Date();
const start = new Date(loa.start_date);
const end = new Date(loa.end_date);
if (now < start) return "Upcoming";
if (now >= start && now <= end) return "Active";
if (now > end) return "Expired";
return "Expired"; // fallback
}
function sortByStartDate(loas: LOARequest[]): LOARequest[] {
return [...loas].sort(
(a, b) => new Date(b.start_date).getTime() - new Date(a.start_date).getTime()
);
}
const sortedLoas = computed(() => sortByStartDate(LOAList.value));
</script>
<template>
<div class="w-5xl mx-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead class="w-[100px]">Member</TableHead>
<TableHead>Start</TableHead>
<TableHead>End</TableHead>
<TableHead>Reason</TableHead>
<TableHead>Posted on</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="post in sortedLoas" :key="post.id" class="hover:bg-muted/50">
<TableCell class="font-medium">
{{ post.name }}
</TableCell>
<TableCell>{{ formatDate(post.start_date) }}</TableCell>
<TableCell>{{ formatDate(post.end_date) }}</TableCell>
<TableCell>{{ post.reason }}</TableCell>
<TableCell>{{ formatDate(post.filed_date) }}</TableCell>
<TableCell>
<Badge v-if="loaStatus(post) === 'Upcoming'" class="bg-blue-500">Upcoming</Badge>
<Badge v-else-if="loaStatus(post) === 'Active'" class="bg-green-500">Active</Badge>
<Badge v-else-if="loaStatus(post) === 'Expired'" class="bg-gray-400">Expired</Badge>
<Badge v-else class="bg-red-500">Cancelled</Badge>
</TableCell>
<TableCell @click.stop="console.log('hi')" class="text-right">
<DropdownMenu>
<DropdownMenuTrigger class="cursor-pointer">
<Ellipsis></Ellipsis>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem :variant="'destructive'">Cancel</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</template>

View File

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

View File

@@ -0,0 +1,21 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="alert-description"
:class="
cn(
'text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed',
props.class,
)
"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,21 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="alert-title"
:class="
cn(
'col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight',
props.class,
)
"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,23 @@
import { cva } from "class-variance-authority";
export { default as Alert } from "./Alert.vue";
export { default as AlertDescription } from "./AlertDescription.vue";
export { default as AlertTitle } from "./AlertTitle.vue";
export const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
info:
"text-background bg-sidebar-primary [&>svg]:text-current *:data-[slot=alert-description]:text-background/90",
},
},
defaultVariants: {
variant: "default",
},
},
);

View File

@@ -0,0 +1,25 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { Primitive } from "reka-ui";
import { cn } from "@/lib/utils";
import { badgeVariants } from ".";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: [String, Object, Function], required: false },
variant: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<Primitive
data-slot="badge"
:class="cn(badgeVariants({ variant }), props.class)"
v-bind="delegatedProps"
>
<slot />
</Primitive>
</template>

View File

@@ -0,0 +1,24 @@
import { cva } from "class-variance-authority";
export { default as Badge } from "./Badge.vue";
export const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);

View File

@@ -0,0 +1,24 @@
<script setup>
import { Primitive } from "reka-ui";
import { cn } from "@/lib/utils";
import { buttonVariants } from ".";
const props = defineProps({
variant: { type: null, required: false },
size: { type: null, required: false },
class: { type: null, required: false },
asChild: { type: Boolean, required: false },
as: { type: [String, Object, Function], required: false, default: "button" },
});
</script>
<template>
<Primitive
data-slot="button"
:as="as"
:as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class)"
>
<slot />
</Primitive>
</template>

View File

@@ -0,0 +1,36 @@
import { cva } from "class-variance-authority";
export { default as Button } from "./Button.vue";
export const buttonVariants = cva(
"cursor-pointer inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
success:
"bg-success text-success-foreground shadow-xs hover:bg-success/90",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-8 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-4 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-10 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);

View File

@@ -0,0 +1,98 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { CalendarRoot, useForwardPropsEmits } from "reka-ui";
import { cn } from "@/lib/utils";
import {
CalendarCell,
CalendarCellTrigger,
CalendarGrid,
CalendarGridBody,
CalendarGridHead,
CalendarGridRow,
CalendarHeadCell,
CalendarHeader,
CalendarHeading,
CalendarNextButton,
CalendarPrevButton,
} from ".";
const props = defineProps({
defaultValue: { type: null, required: false },
defaultPlaceholder: { type: null, required: false },
placeholder: { type: null, required: false },
pagedNavigation: { type: Boolean, required: false },
preventDeselect: { type: Boolean, required: false },
weekStartsOn: { type: Number, required: false },
weekdayFormat: { type: String, required: false },
calendarLabel: { type: String, required: false },
fixedWeeks: { type: Boolean, required: false },
maxValue: { type: null, required: false },
minValue: { type: null, required: false },
locale: { type: String, required: false },
numberOfMonths: { type: Number, required: false },
disabled: { type: Boolean, required: false },
readonly: { type: Boolean, required: false },
initialFocus: { type: Boolean, required: false },
isDateDisabled: { type: Function, required: false },
isDateUnavailable: { type: Function, required: false },
dir: { type: String, required: false },
nextPage: { type: Function, required: false },
prevPage: { type: Function, required: false },
modelValue: { type: null, required: false },
multiple: { type: Boolean, required: false },
disableDaysOutsideCurrentView: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: [String, Object, Function], required: false },
class: { type: null, required: false },
});
const emits = defineEmits(["update:modelValue", "update:placeholder"]);
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<CalendarRoot
v-slot="{ grid, weekDays }"
data-slot="calendar"
:class="cn('p-3', props.class)"
v-bind="forwarded"
>
<CalendarHeader>
<CalendarHeading />
<div class="flex items-center gap-1">
<CalendarPrevButton />
<CalendarNextButton />
</div>
</CalendarHeader>
<div class="flex flex-col gap-y-4 mt-4 sm:flex-row sm:gap-x-4 sm:gap-y-0">
<CalendarGrid v-for="month in grid" :key="month.value.toString()">
<CalendarGridHead>
<CalendarGridRow>
<CalendarHeadCell v-for="day in weekDays" :key="day">
{{ day }}
</CalendarHeadCell>
</CalendarGridRow>
</CalendarGridHead>
<CalendarGridBody>
<CalendarGridRow
v-for="(weekDates, index) in month.rows"
:key="`weekDate-${index}`"
class="mt-2 w-full"
>
<CalendarCell
v-for="weekDate in weekDates"
:key="weekDate.toString()"
:date="weekDate"
>
<CalendarCellTrigger :day="weekDate" :month="month.value" />
</CalendarCell>
</CalendarGridRow>
</CalendarGridBody>
</CalendarGrid>
</div>
</CalendarRoot>
</template>

View File

@@ -0,0 +1,31 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { CalendarCell, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
date: { type: null, required: true },
asChild: { type: Boolean, required: false },
as: { type: [String, Object, Function], required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<CalendarCell
data-slot="calendar-cell"
:class="
cn(
'relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([data-selected])]:rounded-md [&:has([data-selected])]:bg-accent',
props.class,
)
"
v-bind="forwardedProps"
>
<slot />
</CalendarCell>
</template>

View File

@@ -0,0 +1,43 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { CalendarCellTrigger, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
import { buttonVariants } from '@/components/ui/button';
const props = defineProps({
day: { type: null, required: true },
month: { type: null, required: true },
asChild: { type: Boolean, required: false },
as: { type: [String, Object, Function], required: false, default: "button" },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<CalendarCellTrigger
data-slot="calendar-cell-trigger"
:class="
cn(
buttonVariants({ variant: 'ghost' }),
'size-8 p-0 font-normal aria-selected:opacity-100 cursor-default',
'[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground',
// Selected
'data-[selected]:bg-primary data-[selected]:text-primary-foreground data-[selected]:opacity-100 data-[selected]:hover:bg-primary data-[selected]:hover:text-primary-foreground data-[selected]:focus:bg-primary data-[selected]:focus:text-primary-foreground',
// Disabled
'data-[disabled]:text-muted-foreground data-[disabled]:opacity-50',
// Unavailable
'data-[unavailable]:text-destructive-foreground data-[unavailable]:line-through',
// Outside months
'data-[outside-view]:text-muted-foreground',
props.class,
)
"
v-bind="forwardedProps"
>
<slot />
</CalendarCellTrigger>
</template>

View File

@@ -0,0 +1,25 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { CalendarGrid, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: [String, Object, Function], required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<CalendarGrid
data-slot="calendar-grid"
:class="cn('w-full border-collapse space-x-1', props.class)"
v-bind="forwardedProps"
>
<slot />
</CalendarGrid>
</template>

View File

@@ -0,0 +1,14 @@
<script setup>
import { CalendarGridBody } from "reka-ui";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: [String, Object, Function], required: false },
});
</script>
<template>
<CalendarGridBody data-slot="calendar-grid-body" v-bind="props">
<slot />
</CalendarGridBody>
</template>

View File

@@ -0,0 +1,15 @@
<script setup>
import { CalendarGridHead } from "reka-ui";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: [String, Object, Function], required: false },
class: { type: null, required: false },
});
</script>
<template>
<CalendarGridHead data-slot="calendar-grid-head" v-bind="props">
<slot />
</CalendarGridHead>
</template>

View File

@@ -0,0 +1,25 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { CalendarGridRow, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: [String, Object, Function], required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<CalendarGridRow
data-slot="calendar-grid-row"
:class="cn('flex', props.class)"
v-bind="forwardedProps"
>
<slot />
</CalendarGridRow>
</template>

View File

@@ -0,0 +1,30 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { CalendarHeadCell, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: [String, Object, Function], required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<CalendarHeadCell
data-slot="calendar-head-cell"
:class="
cn(
'text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]',
props.class,
)
"
v-bind="forwardedProps"
>
<slot />
</CalendarHeadCell>
</template>

View File

@@ -0,0 +1,27 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { CalendarHeader, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: [String, Object, Function], required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<CalendarHeader
data-slot="calendar-header"
:class="
cn('flex justify-center pt-1 relative items-center w-full', props.class)
"
v-bind="forwardedProps"
>
<slot />
</CalendarHeader>
</template>

View File

@@ -0,0 +1,30 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { CalendarHeading, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: [String, Object, Function], required: false },
class: { type: null, required: false },
});
defineSlots();
const delegatedProps = reactiveOmit(props, "class");
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<CalendarHeading
v-slot="{ headingValue }"
data-slot="calendar-heading"
:class="cn('text-sm font-medium', props.class)"
v-bind="forwardedProps"
>
<slot :heading-value>
{{ headingValue }}
</slot>
</CalendarHeading>
</template>

View File

@@ -0,0 +1,37 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { ChevronRight } from "lucide-vue-next";
import { CalendarNext, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
import { buttonVariants } from '@/components/ui/button';
const props = defineProps({
nextPage: { type: Function, required: false },
asChild: { type: Boolean, required: false },
as: { type: [String, Object, Function], required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<CalendarNext
data-slot="calendar-next-button"
:class="
cn(
buttonVariants({ variant: 'outline' }),
'absolute right-1',
'size-7 bg-transparent p-0 opacity-50 hover:opacity-100',
props.class,
)
"
v-bind="forwardedProps"
>
<slot>
<ChevronRight class="size-4" />
</slot>
</CalendarNext>
</template>

View File

@@ -0,0 +1,37 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { ChevronLeft } from "lucide-vue-next";
import { CalendarPrev, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
import { buttonVariants } from '@/components/ui/button';
const props = defineProps({
prevPage: { type: Function, required: false },
asChild: { type: Boolean, required: false },
as: { type: [String, Object, Function], required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<CalendarPrev
data-slot="calendar-prev-button"
:class="
cn(
buttonVariants({ variant: 'outline' }),
'absolute left-1',
'size-7 bg-transparent p-0 opacity-50 hover:opacity-100',
props.class,
)
"
v-bind="forwardedProps"
>
<slot>
<ChevronLeft class="size-4" />
</slot>
</CalendarPrev>
</template>

View File

@@ -0,0 +1,12 @@
export { default as Calendar } from "./Calendar.vue";
export { default as CalendarCell } from "./CalendarCell.vue";
export { default as CalendarCellTrigger } from "./CalendarCellTrigger.vue";
export { default as CalendarGrid } from "./CalendarGrid.vue";
export { default as CalendarGridBody } from "./CalendarGridBody.vue";
export { default as CalendarGridHead } from "./CalendarGridHead.vue";
export { default as CalendarGridRow } from "./CalendarGridRow.vue";
export { default as CalendarHeadCell } from "./CalendarHeadCell.vue";
export { default as CalendarHeader } from "./CalendarHeader.vue";
export { default as CalendarHeading } from "./CalendarHeading.vue";
export { default as CalendarNextButton } from "./CalendarNextButton.vue";
export { default as CalendarPrevButton } from "./CalendarPrevButton.vue";

View File

@@ -0,0 +1,21 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="card"
:class="
cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
props.class,
)
"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,21 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="card-action"
:class="
cn(
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
props.class,
)
"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,13 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div data-slot="card-content" :class="cn('px-6', props.class)">
<slot />
</div>
</template>

View File

@@ -0,0 +1,16 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<p
data-slot="card-description"
:class="cn('text-muted-foreground text-sm', props.class)"
>
<slot />
</p>
</template>

View File

@@ -0,0 +1,16 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="card-footer"
:class="cn('flex items-center px-6 [.border-t]:pt-6', props.class)"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,21 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="card-header"
:class="
cn(
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
props.class,
)
"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,16 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<h3
data-slot="card-title"
:class="cn('leading-none font-semibold', props.class)"
>
<slot />
</h3>
</template>

View File

@@ -0,0 +1,7 @@
export { default as Card } from "./Card.vue";
export { default as CardAction } from "./CardAction.vue";
export { default as CardContent } from "./CardContent.vue";
export { default as CardDescription } from "./CardDescription.vue";
export { default as CardFooter } from "./CardFooter.vue";
export { default as CardHeader } from "./CardHeader.vue";
export { default as CardTitle } from "./CardTitle.vue";

View File

@@ -0,0 +1,46 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { Check } from "lucide-vue-next";
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
defaultValue: { type: [Boolean, String], required: false },
modelValue: { type: [Boolean, String, null], required: false },
disabled: { type: Boolean, required: false },
value: { type: null, required: false },
id: { type: String, required: false },
asChild: { type: Boolean, required: false },
as: { type: [String, Object, Function], required: false },
name: { type: String, required: false },
required: { type: Boolean, required: false },
class: { type: null, required: false },
});
const emits = defineEmits(["update:modelValue"]);
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<CheckboxRoot
data-slot="checkbox"
v-bind="forwarded"
:class="
cn(
'peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)
"
>
<CheckboxIndicator
data-slot="checkbox-indicator"
class="flex items-center justify-center text-current transition-none"
>
<slot>
<Check class="size-3.5" />
</slot>
</CheckboxIndicator>
</CheckboxRoot>
</template>

View File

@@ -0,0 +1 @@
export { default as Checkbox } from "./Checkbox.vue";

View File

@@ -0,0 +1,33 @@
<script setup>
import { ComboboxRoot, useForwardPropsEmits } from "reka-ui";
const props = defineProps({
open: { type: Boolean, required: false },
defaultOpen: { type: Boolean, required: false },
resetSearchTermOnBlur: { type: Boolean, required: false },
resetSearchTermOnSelect: { type: Boolean, required: false },
openOnFocus: { type: Boolean, required: false },
openOnClick: { type: Boolean, required: false },
ignoreFilter: { type: Boolean, required: false },
modelValue: { type: null, required: false },
defaultValue: { type: null, required: false },
multiple: { type: Boolean, required: false },
dir: { type: String, required: false },
disabled: { type: Boolean, required: false },
highlightOnHover: { type: Boolean, required: false },
by: { type: [String, Function], required: false },
asChild: { type: Boolean, required: false },
as: { type: [String, Object, Function], required: false },
name: { type: String, required: false },
required: { type: Boolean, required: false },
});
const emits = defineEmits(["update:modelValue", "highlight", "update:open"]);
const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>
<ComboboxRoot data-slot="combobox" v-bind="forwarded">
<slot />
</ComboboxRoot>
</template>

View File

@@ -0,0 +1,26 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { ComboboxAnchor, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
reference: { type: null, required: false },
asChild: { type: Boolean, required: false },
as: { type: [String, Object, Function], required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardProps(delegatedProps);
</script>
<template>
<ComboboxAnchor
data-slot="combobox-anchor"
v-bind="forwarded"
:class="cn('w-[200px]', props.class)"
>
<slot />
</ComboboxAnchor>
</template>

View File

@@ -0,0 +1,23 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { ComboboxEmpty } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: [String, Object, Function], required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<ComboboxEmpty
data-slot="combobox-empty"
v-bind="delegatedProps"
:class="cn('py-6 text-center text-sm', props.class)"
>
<slot />
</ComboboxEmpty>
</template>

View File

@@ -0,0 +1,30 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { ComboboxGroup, ComboboxLabel } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: [String, Object, Function], required: false },
class: { type: null, required: false },
heading: { type: String, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<ComboboxGroup
data-slot="combobox-group"
v-bind="delegatedProps"
:class="cn('overflow-hidden p-1 text-foreground', props.class)"
>
<ComboboxLabel
v-if="heading"
class="px-2 py-1.5 text-xs font-medium text-muted-foreground"
>
{{ heading }}
</ComboboxLabel>
<slot />
</ComboboxGroup>
</template>

View File

@@ -0,0 +1,47 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { SearchIcon } from "lucide-vue-next";
import { ComboboxInput, useForwardPropsEmits } from "reka-ui";
import { cn } from "@/lib/utils";
defineOptions({
inheritAttrs: false,
});
const props = defineProps({
displayValue: { type: Function, required: false },
modelValue: { type: String, required: false },
autoFocus: { type: Boolean, required: false },
disabled: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: [String, Object, Function], required: false },
class: { type: null, required: false },
});
const emits = defineEmits(["update:modelValue"]);
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<div
data-slot="command-input-wrapper"
class="flex h-9 items-center border rounded-lg px-3"
>
<SearchIcon class="size-4 shrink-0 opacity-50" />
<ComboboxInput
data-slot="command-input"
:class="
cn(
'placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)
"
v-bind="{ ...forwarded, ...$attrs }"
>
<slot />
</ComboboxInput>
</div>
</template>

View File

@@ -0,0 +1,34 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { ComboboxItem, useForwardPropsEmits } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
textValue: { type: String, required: false },
value: { type: null, required: true },
disabled: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: [String, Object, Function], required: false },
class: { type: null, required: false },
});
const emits = defineEmits(["select"]);
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<ComboboxItem
data-slot="combobox-item"
v-bind="forwarded"
:class="
cn(
`data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 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`,
props.class,
)
"
>
<slot />
</ComboboxItem>
</template>

View File

@@ -0,0 +1,25 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { ComboboxItemIndicator, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: [String, Object, Function], required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardProps(delegatedProps);
</script>
<template>
<ComboboxItemIndicator
data-slot="combobox-item-indicator"
v-bind="forwarded"
:class="cn('ml-auto', props.class)"
>
<slot />
</ComboboxItemIndicator>
</template>

View File

@@ -0,0 +1,58 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { ComboboxContent, ComboboxPortal, useForwardPropsEmits } from "reka-ui";
import { cn } from "@/lib/utils";
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, default: 4 },
sideFlip: { type: Boolean, required: false },
align: { type: null, required: false, default: "center" },
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: [String, Object, Function], required: false },
disableOutsidePointerEvents: { type: Boolean, 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>
<ComboboxPortal>
<ComboboxContent
data-slot="combobox-list"
v-bind="forwarded"
:class="
cn(
'z-50 w-[200px] rounded-md border bg-popover text-popover-foreground origin-(--reka-combobox-content-transform-origin) overflow-hidden shadow-md outline-none 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',
props.class,
)
"
>
<slot />
</ComboboxContent>
</ComboboxPortal>
</template>

View File

@@ -0,0 +1,23 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { ComboboxSeparator } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: [String, Object, Function], required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<ComboboxSeparator
data-slot="combobox-separator"
v-bind="delegatedProps"
:class="cn('bg-border -mx-1 h-px', props.class)"
>
<slot />
</ComboboxSeparator>
</template>

View File

@@ -0,0 +1,27 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { ComboboxTrigger, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
disabled: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: [String, Object, Function], required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardProps(delegatedProps);
</script>
<template>
<ComboboxTrigger
data-slot="combobox-trigger"
v-bind="forwarded"
:class="cn('', props.class)"
tabindex="0"
>
<slot />
</ComboboxTrigger>
</template>

View File

@@ -0,0 +1,31 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { ComboboxViewport, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
nonce: { type: String, required: false },
asChild: { type: Boolean, required: false },
as: { type: [String, Object, Function], required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardProps(delegatedProps);
</script>
<template>
<ComboboxViewport
data-slot="combobox-viewport"
v-bind="forwarded"
:class="
cn(
'max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto',
props.class,
)
"
>
<slot />
</ComboboxViewport>
</template>

View File

@@ -0,0 +1,12 @@
export { default as Combobox } from "./Combobox.vue";
export { default as ComboboxAnchor } from "./ComboboxAnchor.vue";
export { default as ComboboxEmpty } from "./ComboboxEmpty.vue";
export { default as ComboboxGroup } from "./ComboboxGroup.vue";
export { default as ComboboxInput } from "./ComboboxInput.vue";
export { default as ComboboxItem } from "./ComboboxItem.vue";
export { default as ComboboxItemIndicator } from "./ComboboxItemIndicator.vue";
export { default as ComboboxList } from "./ComboboxList.vue";
export { default as ComboboxSeparator } from "./ComboboxSeparator.vue";
export { default as ComboboxViewport } from "./ComboboxViewport.vue";
export { ComboboxCancel, ComboboxTrigger } from "reka-ui";

View File

@@ -0,0 +1,18 @@
<script setup>
import { DialogRoot, useForwardPropsEmits } from "reka-ui";
const props = defineProps({
open: { type: Boolean, required: false },
defaultOpen: { type: Boolean, required: false },
modal: { type: Boolean, required: false },
});
const emits = defineEmits(["update:open"]);
const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>
<DialogRoot data-slot="dialog" v-bind="forwarded">
<slot />
</DialogRoot>
</template>

View File

@@ -0,0 +1,14 @@
<script setup>
import { DialogClose } from "reka-ui";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: [String, Object, Function], required: false },
});
</script>
<template>
<DialogClose data-slot="dialog-close" v-bind="props">
<slot />
</DialogClose>
</template>

View File

@@ -0,0 +1,57 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { X } from "lucide-vue-next";
import {
DialogClose,
DialogContent,
DialogPortal,
useForwardPropsEmits,
} from "reka-ui";
import { cn } from "@/lib/utils";
import DialogOverlay from "./DialogOverlay.vue";
const props = defineProps({
forceMount: { type: Boolean, required: false },
disableOutsidePointerEvents: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: [String, Object, Function], required: false },
class: { type: null, required: false },
});
const emits = defineEmits([
"escapeKeyDown",
"pointerDownOutside",
"focusOutside",
"interactOutside",
"openAutoFocus",
"closeAutoFocus",
]);
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<DialogPortal>
<DialogOverlay />
<DialogContent
data-slot="dialog-content"
v-bind="forwarded"
:class="
cn(
'bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
props.class,
)
"
>
<slot />
<DialogClose
class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<X />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogPortal>
</template>

View File

@@ -0,0 +1,25 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { DialogDescription, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: [String, Object, Function], required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<DialogDescription
data-slot="dialog-description"
v-bind="forwardedProps"
:class="cn('text-muted-foreground text-sm', props.class)"
>
<slot />
</DialogDescription>
</template>

View File

@@ -0,0 +1,18 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="dialog-footer"
:class="
cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)
"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,16 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="dialog-header"
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,29 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { DialogOverlay } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
forceMount: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: [String, Object, Function], required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<DialogOverlay
data-slot="dialog-overlay"
v-bind="delegatedProps"
:class="
cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80',
props.class,
)
"
>
<slot />
</DialogOverlay>
</template>

View File

@@ -0,0 +1,71 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { X } from "lucide-vue-next";
import {
DialogClose,
DialogContent,
DialogOverlay,
DialogPortal,
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: [String, Object, Function], required: false },
class: { type: null, required: false },
});
const emits = defineEmits([
"escapeKeyDown",
"pointerDownOutside",
"focusOutside",
"interactOutside",
"openAutoFocus",
"closeAutoFocus",
]);
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
>
<DialogContent
:class="
cn(
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
props.class,
)
"
v-bind="forwarded"
@pointer-down-outside="
(event) => {
const originalEvent = event.detail.originalEvent;
const target = originalEvent.target;
if (
originalEvent.offsetX > target.clientWidth ||
originalEvent.offsetY > target.clientHeight
) {
event.preventDefault();
}
}
"
>
<slot />
<DialogClose
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
>
<X class="w-4 h-4" />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogOverlay>
</DialogPortal>
</template>

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