Merge literally everything into main so I stop just working on one branch #14
4
.gitignore
vendored
4
.gitignore
vendored
@@ -28,3 +28,7 @@ coverage
|
|||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
*.sql
|
||||||
|
.env
|
||||||
|
*.db
|
||||||
1
api/.gitignore
vendored
Normal file
1
api/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
built
|
||||||
11
api/index.js
11
api/index.js
@@ -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
1904
api/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -7,9 +7,25 @@
|
|||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
|
"dev": "tsc && node ./built/api/src/index.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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
19
api/src/db.ts
Normal 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
67
api/src/index.js
Normal 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} `)
|
||||||
|
})
|
||||||
174
api/src/routes/applications.ts
Normal file
174
api/src/routes/applications.ts
Normal 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
131
api/src/routes/auth.js
Normal 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;
|
||||||
50
api/src/routes/calendar.ts
Normal file
50
api/src/routes/calendar.ts
Normal 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
56
api/src/routes/loa.js
Normal 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
84
api/src/routes/members.js
Normal 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
31
api/src/routes/ranks.js
Normal 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
116
api/src/routes/roles.js
Normal 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;
|
||||||
46
api/src/routes/statuses.js
Normal file
46
api/src/routes/statuses.js
Normal 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;
|
||||||
71
api/src/services/applicationService.ts
Normal file
71
api/src/services/applicationService.ts
Normal 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
6
api/src/services/calendarService.d.ts
vendored
Normal 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
|
||||||
1
api/src/services/calendarService.d.ts.map
Normal file
1
api/src/services/calendarService.d.ts.map
Normal 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"}
|
||||||
19
api/src/services/calendarService.js
Normal file
19
api/src/services/calendarService.js
Normal 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
|
||||||
1
api/src/services/calendarService.js.map
Normal file
1
api/src/services/calendarService.js.map
Normal 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"}
|
||||||
150
api/src/services/calendarService.ts
Normal file
150
api/src/services/calendarService.ts
Normal 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]);
|
||||||
|
}
|
||||||
23
api/src/services/memberService.ts
Normal file
23
api/src/services/memberService.ts
Normal 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]);
|
||||||
|
}
|
||||||
32
api/src/services/rankService.ts
Normal file
32
api/src/services/rankService.ts
Normal 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);
|
||||||
|
}
|
||||||
26
api/src/services/rolesService.ts
Normal file
26
api/src/services/rolesService.ts
Normal 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]);
|
||||||
|
}
|
||||||
6
api/src/services/statusService.ts
Normal file
6
api/src/services/statusService.ts
Normal 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
17
api/tsconfig.json
Normal 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
6
shared/package.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "@app/shared",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "index.ts",
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
63
shared/types/application.ts
Normal file
63
shared/types/application.ts
Normal 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",
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"],
|
||||||
|
"@shared": ["../shared/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"]
|
||||||
|
|||||||
976
ui/package-lock.json
generated
976
ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,16 +12,27 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"@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",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-vue-next": "^0.539.0",
|
"lucide-vue-next": "^0.539.0",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
|
"reka-ui": "^2.5.0",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss": "^4.1.11",
|
"tailwindcss": "^4.1.11",
|
||||||
"tw-animate-css": "^1.3.6",
|
"tw-animate-css": "^1.3.6",
|
||||||
|
"vee-validate": "^4.15.1",
|
||||||
"vue": "^3.5.18",
|
"vue": "^3.5.18",
|
||||||
"vue-router": "^4.5.1"
|
"vue-router": "^4.5.1",
|
||||||
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.2.1",
|
"@types/node": "^24.2.1",
|
||||||
|
|||||||
131
ui/src/App.vue
131
ui/src/App.vue
@@ -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>
|
<template>
|
||||||
<h1>You did it!</h1>
|
<div>
|
||||||
<p>
|
<div class="flex items-center justify-between px-10">
|
||||||
Visit <a href="https://vuejs.org/" target="_blank" rel="noopener">vuejs.org</a> to read the
|
<div></div>
|
||||||
documentation
|
<div class="h-15 flex items-center justify-center gap-20">
|
||||||
</p>
|
<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>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|||||||
138
ui/src/api/application.ts
Normal file
138
ui/src/api/application.ts
Normal 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
42
ui/src/api/calendar.ts
Normal 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
62
ui/src/api/loa.ts
Normal 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
22
ui/src/api/member.ts
Normal 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
38
ui/src/api/rank.ts
Normal 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
95
ui/src/api/roles.ts
Normal 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
34
ui/src/api/status.ts
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,102 +1,107 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "tw-animate-css";
|
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: oklch(0.9911 0 0);
|
--background: oklch(0.2046 0 0);
|
||||||
--foreground: oklch(0.2046 0 0);
|
--foreground: oklch(0.9219 0 0);
|
||||||
--card: oklch(0.9911 0 0);
|
--card: oklch(23.075% 0.00003 271.152);
|
||||||
--card-foreground: oklch(0.2046 0 0);
|
--card-foreground: oklch(0.9219 0 0);
|
||||||
--popover: oklch(0.9911 0 0);
|
--popover: oklch(0.2686 0 0);
|
||||||
--popover-foreground: oklch(0.4386 0 0);
|
--popover-foreground: oklch(0.9219 0 0);
|
||||||
--primary: oklch(1.0000 0 0);
|
--primary: oklch(0.7686 0.1647 70.0804);
|
||||||
--primary-foreground: oklch(0.3709 0.0313 95.1202);
|
--primary-foreground: oklch(0 0 0);
|
||||||
--secondary: oklch(0.9940 0 0);
|
--secondary: oklch(0.2686 0 0);
|
||||||
--secondary-foreground: oklch(0.2046 0 0);
|
--secondary-foreground: oklch(0.9219 0 0);
|
||||||
--muted: oklch(0.9461 0 0);
|
--muted: oklch(0.2686 0 0);
|
||||||
--muted-foreground: oklch(0.2435 0 0);
|
--muted-foreground: oklch(0.7155 0 0);
|
||||||
--accent: oklch(0.9461 0 0);
|
--accent: oklch(0.4732 0.1247 46.2007);
|
||||||
--accent-foreground: oklch(0.2435 0 0);
|
--accent-foreground: oklch(0.9243 0.1151 95.7459);
|
||||||
--destructive: oklch(0.6704 0.2070 300.0793);
|
--destructive: oklch(0.6368 0.2078 25.3313);
|
||||||
--destructive-foreground: oklch(1.0000 0 0);
|
--destructive-foreground: oklch(1.0000 0 0);
|
||||||
--border: oklch(0.9037 0 0);
|
--success: oklch(66.104% 0.16937 144.153);
|
||||||
--input: oklch(0.9731 0 0);
|
--success-foreground: oklch(1.0000 0 0);
|
||||||
--ring: oklch(1.0000 0 0);
|
--border: oklch(0.3715 0 0);
|
||||||
--chart-1: oklch(1.0000 0 0);
|
--input: oklch(0.3715 0 0);
|
||||||
--chart-2: oklch(0.9936 0.0111 141.2643);
|
--ring: oklch(0.7686 0.1647 70.0804);
|
||||||
--chart-3: oklch(1.0000 0 0);
|
--chart-1: oklch(0.8369 0.1644 84.4286);
|
||||||
--chart-4: oklch(0.8317 0.1444 322.3270);
|
--chart-2: oklch(0.6658 0.1574 58.3183);
|
||||||
--chart-5: oklch(0.9397 0.1746 103.9958);
|
--chart-3: oklch(0.4732 0.1247 46.2007);
|
||||||
--sidebar: oklch(0.9911 0 0);
|
--chart-4: oklch(0.5553 0.1455 48.9975);
|
||||||
--sidebar-foreground: oklch(0.5452 0 0);
|
--chart-5: oklch(0.4732 0.1247 46.2007);
|
||||||
--sidebar-primary: oklch(1.0000 0 0);
|
--sidebar: oklch(0.1684 0 0);
|
||||||
--sidebar-primary-foreground: oklch(0.3709 0.0313 95.1202);
|
--sidebar-foreground: oklch(0.9219 0 0);
|
||||||
--sidebar-accent: oklch(0.9461 0 0);
|
--sidebar-primary: oklch(0.7686 0.1647 70.0804);
|
||||||
--sidebar-accent-foreground: oklch(0.2435 0 0);
|
--sidebar-primary-foreground: oklch(1.0000 0 0);
|
||||||
--sidebar-border: oklch(0.9037 0 0);
|
--sidebar-accent: oklch(0.4732 0.1247 46.2007);
|
||||||
--sidebar-ring: oklch(1.0000 0 0);
|
--sidebar-accent-foreground: oklch(0.9243 0.1151 95.7459);
|
||||||
--font-sans: Outfit, sans-serif;
|
--sidebar-border: oklch(0.3715 0 0);
|
||||||
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
|
--sidebar-ring: oklch(0.7686 0.1647 70.0804);
|
||||||
--font-mono: monospace;
|
--font-sans: Inter, sans-serif;
|
||||||
--radius: 0.5rem;
|
--font-serif: Source Serif 4, serif;
|
||||||
--shadow-2xs: 0px 1px 3px 0px hsl(0 0% 0% / 0.09);
|
--font-mono: JetBrains Mono, monospace;
|
||||||
--shadow-xs: 0px 1px 3px 0px hsl(0 0% 0% / 0.09);
|
--radius: 0.375rem;
|
||||||
--shadow-sm: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 1px 2px -1px hsl(0 0% 0% / 0.17);
|
--shadow-2xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.07);
|
||||||
--shadow: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 1px 2px -1px hsl(0 0% 0% / 0.17);
|
--shadow-xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.07);
|
||||||
--shadow-md: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 2px 4px -1px hsl(0 0% 0% / 0.17);
|
--shadow-sm: 0px 4px 8px -1px hsl(0 0% 0% / 0.14), 0px 1px 2px -2px hsl(0 0% 0% / 0.14);
|
||||||
--shadow-lg: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 4px 6px -1px hsl(0 0% 0% / 0.17);
|
--shadow: 0px 4px 8px -1px hsl(0 0% 0% / 0.14), 0px 1px 2px -2px hsl(0 0% 0% / 0.14);
|
||||||
--shadow-xl: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 8px 10px -1px hsl(0 0% 0% / 0.17);
|
--shadow-md: 0px 4px 8px -1px hsl(0 0% 0% / 0.14), 0px 2px 4px -2px hsl(0 0% 0% / 0.14);
|
||||||
--shadow-2xl: 0px 1px 3px 0px hsl(0 0% 0% / 0.43);
|
--shadow-lg: 0px 4px 8px -1px hsl(0 0% 0% / 0.14), 0px 4px 6px -2px hsl(0 0% 0% / 0.14);
|
||||||
--tracking-normal: 0.025em;
|
--shadow-xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.14), 0px 8px 10px -2px hsl(0 0% 0% / 0.14);
|
||||||
--spacing: 0.25rem;
|
--shadow-2xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: oklch(0.1822 0 0);
|
--background: oklch(0.2046 0 0);
|
||||||
--foreground: oklch(1.0000 0 0);
|
--foreground: oklch(0.9219 0 0);
|
||||||
--card: oklch(0.2046 0 0);
|
--card: oklch(23.075% 0.00003 271.152);
|
||||||
--card-foreground: oklch(1.0000 0 0);
|
--card-foreground: oklch(0.9219 0 0);
|
||||||
--popover: oklch(0.2603 0 0);
|
--popover: oklch(0.2686 0 0);
|
||||||
--popover-foreground: oklch(0.7348 0 0);
|
--popover-foreground: oklch(0.9219 0 0);
|
||||||
--primary: oklch(0.6277 0.1287 94.1115);
|
--primary: oklch(0.7686 0.1647 70.0804);
|
||||||
--primary-foreground: oklch(1.0000 0 0);
|
--primary-foreground: oklch(0 0 0);
|
||||||
--secondary: oklch(0.2603 0 0);
|
--secondary: oklch(0.2686 0 0);
|
||||||
--secondary-foreground: oklch(0.9851 0 0);
|
--secondary-foreground: oklch(0.9219 0 0);
|
||||||
--muted: oklch(0.2393 0 0);
|
--muted: oklch(0.2686 0 0);
|
||||||
--muted-foreground: oklch(0.7122 0 0);
|
--muted-foreground: oklch(0.7155 0 0);
|
||||||
--accent: oklch(0.3132 0 0);
|
--accent: oklch(0.4732 0.1247 46.2007);
|
||||||
--accent-foreground: oklch(0.9851 0 0);
|
--accent-foreground: oklch(0.9243 0.1151 95.7459);
|
||||||
--destructive: oklch(0.3712 0.2143 284.7713);
|
--destructive: oklch(0.6368 0.2078 25.3313);
|
||||||
--destructive-foreground: oklch(1.0000 0 0);
|
--destructive-foreground: oklch(1.0000 0 0);
|
||||||
--border: oklch(0.2809 0 0);
|
--success: oklch(66.104% 0.16937 144.153);
|
||||||
--input: oklch(0.2603 0 0);
|
--success-foreground: oklch(1.0000 0 0);
|
||||||
--ring: oklch(0.9786 0.0203 81.7829);
|
--border: oklch(0.3715 0 0);
|
||||||
--chart-1: oklch(0.9786 0.0203 81.7829);
|
--input: oklch(0.3715 0 0);
|
||||||
--chart-2: oklch(1.0000 0 0);
|
--ring: oklch(0.7686 0.1647 70.0804);
|
||||||
--chart-3: oklch(1.0000 0 0);
|
--chart-1: oklch(0.8369 0.1644 84.4286);
|
||||||
--chart-4: oklch(0.9312 0.0608 325.1964);
|
--chart-2: oklch(0.6658 0.1574 58.3183);
|
||||||
--chart-5: oklch(0.9724 0.1085 114.3821);
|
--chart-3: oklch(0.4732 0.1247 46.2007);
|
||||||
--sidebar: oklch(0.1822 0 0);
|
--chart-4: oklch(0.5553 0.1455 48.9975);
|
||||||
--sidebar-foreground: oklch(0.6301 0 0);
|
--chart-5: oklch(0.4732 0.1247 46.2007);
|
||||||
--sidebar-primary: oklch(0.6277 0.1287 94.1115);
|
--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-primary-foreground: oklch(1.0000 0 0);
|
||||||
--sidebar-accent: oklch(0.3132 0 0);
|
--sidebar-accent: oklch(0.4732 0.1247 46.2007);
|
||||||
--sidebar-accent-foreground: oklch(0.9851 0 0);
|
--sidebar-accent-foreground: oklch(0.9243 0.1151 95.7459);
|
||||||
--sidebar-border: oklch(0.2809 0 0);
|
--sidebar-border: oklch(0.3715 0 0);
|
||||||
--sidebar-ring: oklch(0.9786 0.0203 81.7829);
|
--sidebar-ring: oklch(0.7686 0.1647 70.0804);
|
||||||
--font-sans: Outfit, sans-serif;
|
--font-sans: Inter, sans-serif;
|
||||||
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
|
--font-serif: Source Serif 4, serif;
|
||||||
--font-mono: monospace;
|
--font-mono: JetBrains Mono, monospace;
|
||||||
--radius: 0.5rem;
|
--radius: 0.375rem;
|
||||||
--shadow-2xs: 0px 1px 3px 0px hsl(0 0% 0% / 0.09);
|
--shadow-2xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.07);
|
||||||
--shadow-xs: 0px 1px 3px 0px hsl(0 0% 0% / 0.09);
|
--shadow-xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.07);
|
||||||
--shadow-sm: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 1px 2px -1px hsl(0 0% 0% / 0.17);
|
--shadow-sm: 0px 4px 8px -1px hsl(0 0% 0% / 0.14), 0px 1px 2px -2px hsl(0 0% 0% / 0.14);
|
||||||
--shadow: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 1px 2px -1px hsl(0 0% 0% / 0.17);
|
--shadow: 0px 4px 8px -1px hsl(0 0% 0% / 0.14), 0px 1px 2px -2px hsl(0 0% 0% / 0.14);
|
||||||
--shadow-md: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 2px 4px -1px hsl(0 0% 0% / 0.17);
|
--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 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 4px 6px -1px hsl(0 0% 0% / 0.17);
|
--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 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 8px 10px -1px hsl(0 0% 0% / 0.17);
|
--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 1px 3px 0px hsl(0 0% 0% / 0.43);
|
--shadow-2xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
@@ -114,6 +119,8 @@
|
|||||||
--color-accent-foreground: var(--accent-foreground);
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
--color-destructive: var(--destructive);
|
--color-destructive: var(--destructive);
|
||||||
--color-destructive-foreground: var(--destructive-foreground);
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
|
--color-success: var(--success);
|
||||||
|
--color-success-foreground: var(--success-foreground);
|
||||||
--color-border: var(--border);
|
--color-border: var(--border);
|
||||||
--color-input: var(--input);
|
--color-input: var(--input);
|
||||||
--color-ring: var(--ring);
|
--color-ring: var(--ring);
|
||||||
@@ -148,15 +155,14 @@
|
|||||||
--shadow-lg: var(--shadow-lg);
|
--shadow-lg: var(--shadow-lg);
|
||||||
--shadow-xl: var(--shadow-xl);
|
--shadow-xl: var(--shadow-xl);
|
||||||
--shadow-2xl: var(--shadow-2xl);
|
--shadow-2xl: var(--shadow-2xl);
|
||||||
|
}
|
||||||
|
|
||||||
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
|
@layer base {
|
||||||
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
|
* {
|
||||||
--tracking-normal: var(--tracking-normal);
|
@apply border-border outline-ring/50;
|
||||||
--tracking-wide: calc(var(--tracking-normal) + 0.025em);
|
|
||||||
--tracking-wider: calc(var(--tracking-normal) + 0.05em);
|
|
||||||
--tracking-widest: calc(var(--tracking-normal) + 0.1em);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
letter-spacing: var(--tracking-normal);
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
75
ui/src/components/application/ApplicationChat.vue
Normal file
75
ui/src/components/application/ApplicationChat.vue
Normal 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>
|
||||||
258
ui/src/components/application/ApplicationForm.vue
Normal file
258
ui/src/components/application/ApplicationForm.vue
Normal 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>
|
||||||
257
ui/src/components/calendar/CreateCalendarEvent.vue
Normal file
257
ui/src/components/calendar/CreateCalendarEvent.vue
Normal 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>
|
||||||
56
ui/src/components/form/AutoForm.vue
Normal file
56
ui/src/components/form/AutoForm.vue
Normal 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>
|
||||||
102
ui/src/components/form/DateInput.vue
Normal file
102
ui/src/components/form/DateInput.vue
Normal 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>
|
||||||
45
ui/src/components/form/FormCheckbox.vue
Normal file
45
ui/src/components/form/FormCheckbox.vue
Normal 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>
|
||||||
44
ui/src/components/form/FormInput.vue
Normal file
44
ui/src/components/form/FormInput.vue
Normal 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>
|
||||||
172
ui/src/components/loa/loaForm.vue
Normal file
172
ui/src/components/loa/loaForm.vue
Normal 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>
|
||||||
107
ui/src/components/loa/loaList.vue
Normal file
107
ui/src/components/loa/loaList.vue
Normal 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>
|
||||||
19
ui/src/components/ui/alert/Alert.vue
Normal file
19
ui/src/components/ui/alert/Alert.vue
Normal 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>
|
||||||
21
ui/src/components/ui/alert/AlertDescription.vue
Normal file
21
ui/src/components/ui/alert/AlertDescription.vue
Normal 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>
|
||||||
21
ui/src/components/ui/alert/AlertTitle.vue
Normal file
21
ui/src/components/ui/alert/AlertTitle.vue
Normal 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>
|
||||||
23
ui/src/components/ui/alert/index.js
Normal file
23
ui/src/components/ui/alert/index.js
Normal 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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
25
ui/src/components/ui/badge/Badge.vue
Normal file
25
ui/src/components/ui/badge/Badge.vue
Normal 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>
|
||||||
24
ui/src/components/ui/badge/index.js
Normal file
24
ui/src/components/ui/badge/index.js
Normal 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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
24
ui/src/components/ui/button/Button.vue
Normal file
24
ui/src/components/ui/button/Button.vue
Normal 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>
|
||||||
36
ui/src/components/ui/button/index.js
Normal file
36
ui/src/components/ui/button/index.js
Normal 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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
98
ui/src/components/ui/calendar/Calendar.vue
Normal file
98
ui/src/components/ui/calendar/Calendar.vue
Normal 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>
|
||||||
31
ui/src/components/ui/calendar/CalendarCell.vue
Normal file
31
ui/src/components/ui/calendar/CalendarCell.vue
Normal 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>
|
||||||
43
ui/src/components/ui/calendar/CalendarCellTrigger.vue
Normal file
43
ui/src/components/ui/calendar/CalendarCellTrigger.vue
Normal 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>
|
||||||
25
ui/src/components/ui/calendar/CalendarGrid.vue
Normal file
25
ui/src/components/ui/calendar/CalendarGrid.vue
Normal 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>
|
||||||
14
ui/src/components/ui/calendar/CalendarGridBody.vue
Normal file
14
ui/src/components/ui/calendar/CalendarGridBody.vue
Normal 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>
|
||||||
15
ui/src/components/ui/calendar/CalendarGridHead.vue
Normal file
15
ui/src/components/ui/calendar/CalendarGridHead.vue
Normal 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>
|
||||||
25
ui/src/components/ui/calendar/CalendarGridRow.vue
Normal file
25
ui/src/components/ui/calendar/CalendarGridRow.vue
Normal 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>
|
||||||
30
ui/src/components/ui/calendar/CalendarHeadCell.vue
Normal file
30
ui/src/components/ui/calendar/CalendarHeadCell.vue
Normal 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>
|
||||||
27
ui/src/components/ui/calendar/CalendarHeader.vue
Normal file
27
ui/src/components/ui/calendar/CalendarHeader.vue
Normal 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>
|
||||||
30
ui/src/components/ui/calendar/CalendarHeading.vue
Normal file
30
ui/src/components/ui/calendar/CalendarHeading.vue
Normal 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>
|
||||||
37
ui/src/components/ui/calendar/CalendarNextButton.vue
Normal file
37
ui/src/components/ui/calendar/CalendarNextButton.vue
Normal 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>
|
||||||
37
ui/src/components/ui/calendar/CalendarPrevButton.vue
Normal file
37
ui/src/components/ui/calendar/CalendarPrevButton.vue
Normal 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>
|
||||||
12
ui/src/components/ui/calendar/index.js
Normal file
12
ui/src/components/ui/calendar/index.js
Normal 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";
|
||||||
21
ui/src/components/ui/card/Card.vue
Normal file
21
ui/src/components/ui/card/Card.vue
Normal 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>
|
||||||
21
ui/src/components/ui/card/CardAction.vue
Normal file
21
ui/src/components/ui/card/CardAction.vue
Normal 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>
|
||||||
13
ui/src/components/ui/card/CardContent.vue
Normal file
13
ui/src/components/ui/card/CardContent.vue
Normal 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>
|
||||||
16
ui/src/components/ui/card/CardDescription.vue
Normal file
16
ui/src/components/ui/card/CardDescription.vue
Normal 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>
|
||||||
16
ui/src/components/ui/card/CardFooter.vue
Normal file
16
ui/src/components/ui/card/CardFooter.vue
Normal 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>
|
||||||
21
ui/src/components/ui/card/CardHeader.vue
Normal file
21
ui/src/components/ui/card/CardHeader.vue
Normal 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>
|
||||||
16
ui/src/components/ui/card/CardTitle.vue
Normal file
16
ui/src/components/ui/card/CardTitle.vue
Normal 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>
|
||||||
7
ui/src/components/ui/card/index.js
Normal file
7
ui/src/components/ui/card/index.js
Normal 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";
|
||||||
46
ui/src/components/ui/checkbox/Checkbox.vue
Normal file
46
ui/src/components/ui/checkbox/Checkbox.vue
Normal 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>
|
||||||
1
ui/src/components/ui/checkbox/index.js
Normal file
1
ui/src/components/ui/checkbox/index.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as Checkbox } from "./Checkbox.vue";
|
||||||
33
ui/src/components/ui/combobox/Combobox.vue
Normal file
33
ui/src/components/ui/combobox/Combobox.vue
Normal 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>
|
||||||
26
ui/src/components/ui/combobox/ComboboxAnchor.vue
Normal file
26
ui/src/components/ui/combobox/ComboboxAnchor.vue
Normal 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>
|
||||||
23
ui/src/components/ui/combobox/ComboboxEmpty.vue
Normal file
23
ui/src/components/ui/combobox/ComboboxEmpty.vue
Normal 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>
|
||||||
30
ui/src/components/ui/combobox/ComboboxGroup.vue
Normal file
30
ui/src/components/ui/combobox/ComboboxGroup.vue
Normal 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>
|
||||||
47
ui/src/components/ui/combobox/ComboboxInput.vue
Normal file
47
ui/src/components/ui/combobox/ComboboxInput.vue
Normal 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>
|
||||||
34
ui/src/components/ui/combobox/ComboboxItem.vue
Normal file
34
ui/src/components/ui/combobox/ComboboxItem.vue
Normal 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>
|
||||||
25
ui/src/components/ui/combobox/ComboboxItemIndicator.vue
Normal file
25
ui/src/components/ui/combobox/ComboboxItemIndicator.vue
Normal 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>
|
||||||
58
ui/src/components/ui/combobox/ComboboxList.vue
Normal file
58
ui/src/components/ui/combobox/ComboboxList.vue
Normal 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>
|
||||||
23
ui/src/components/ui/combobox/ComboboxSeparator.vue
Normal file
23
ui/src/components/ui/combobox/ComboboxSeparator.vue
Normal 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>
|
||||||
27
ui/src/components/ui/combobox/ComboboxTrigger.vue
Normal file
27
ui/src/components/ui/combobox/ComboboxTrigger.vue
Normal 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>
|
||||||
31
ui/src/components/ui/combobox/ComboboxViewport.vue
Normal file
31
ui/src/components/ui/combobox/ComboboxViewport.vue
Normal 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>
|
||||||
12
ui/src/components/ui/combobox/index.js
Normal file
12
ui/src/components/ui/combobox/index.js
Normal 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";
|
||||||
18
ui/src/components/ui/dialog/Dialog.vue
Normal file
18
ui/src/components/ui/dialog/Dialog.vue
Normal 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>
|
||||||
14
ui/src/components/ui/dialog/DialogClose.vue
Normal file
14
ui/src/components/ui/dialog/DialogClose.vue
Normal 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>
|
||||||
57
ui/src/components/ui/dialog/DialogContent.vue
Normal file
57
ui/src/components/ui/dialog/DialogContent.vue
Normal 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>
|
||||||
25
ui/src/components/ui/dialog/DialogDescription.vue
Normal file
25
ui/src/components/ui/dialog/DialogDescription.vue
Normal 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>
|
||||||
18
ui/src/components/ui/dialog/DialogFooter.vue
Normal file
18
ui/src/components/ui/dialog/DialogFooter.vue
Normal 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>
|
||||||
16
ui/src/components/ui/dialog/DialogHeader.vue
Normal file
16
ui/src/components/ui/dialog/DialogHeader.vue
Normal 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>
|
||||||
29
ui/src/components/ui/dialog/DialogOverlay.vue
Normal file
29
ui/src/components/ui/dialog/DialogOverlay.vue
Normal 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>
|
||||||
71
ui/src/components/ui/dialog/DialogScrollContent.vue
Normal file
71
ui/src/components/ui/dialog/DialogScrollContent.vue
Normal 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
Reference in New Issue
Block a user