diff --git a/.github/workflows/formatter.yml b/.github/workflows/formatter.yml index 9e3d7ee..47c6e83 100644 --- a/.github/workflows/formatter.yml +++ b/.github/workflows/formatter.yml @@ -9,17 +9,17 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.10", "3.11", "3.12"] + python-version: [ "3.10", "3.11", "3.12" ] steps: - - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install black - - name: Formatting the code with black - run: | - black $(git ls-files '*.py') \ No newline at end of file + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install black + - name: Formatting the code with black + run: | + black $(git ls-files '*.py') \ No newline at end of file diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml index c3e47eb..8dd7ede 100644 --- a/.github/workflows/pylint.yml +++ b/.github/workflows/pylint.yml @@ -1,22 +1,23 @@ name: Pylint run-name: Pylint Checker 🐧 -on: [push] +on: [ push ] jobs: build: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.10", "3.11", "3.12"] + python-version: [ "3.10", "3.11", "3.12" ] steps: - - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r ./api/requirements_dev.txt - - name: Analysing the code with pylint - run: | - pylint $(git ls-files '*.py') \ No newline at end of file + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r ./api/requirements.txt + pip install -r ./api/utils.txt + - name: Analysing the code with pylint + run: | + pylint $(git ls-files '*.py') \ No newline at end of file diff --git a/.gitignore b/.gitignore index 0a3fcd0..908ec38 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ .venv/ **/__pycache__/ -.idea/ \ No newline at end of file +.idea/.DS_Store diff --git a/api/.env b/api/.env new file mode 100644 index 0000000..54ef94a --- /dev/null +++ b/api/.env @@ -0,0 +1,3 @@ +API_MODE=dev +DB=relational +SQLITE_DB_PATH="./moh.sqlite" \ No newline at end of file diff --git a/api/auth/controller.py b/api/auth/controller.py new file mode 100644 index 0000000..78de89d --- /dev/null +++ b/api/auth/controller.py @@ -0,0 +1,19 @@ +from api.database.db import db +import os + +AUTOLAB_SECRET = os.getenv("AUTOLAB_CLIENT_SECRET") +AUTOLAB_ID = os.getenv("AUTOLAB_CLIENT_ID") +REDIRECT_URI = os.getenv("AUTOLAB_REDIRECT_URI") + +def create_account(username, numeric_identifier, auth_level="student"): + account_id = db.create_account(username, numeric_identifier) + db.add_to_roster(account_id, auth_level) + return account_id + +def get_user(cookies): + if "auth_token" not in cookies: + return None + + return db.get_authenticated_user(cookies["auth_token"]) + + diff --git a/api/auth/routes.py b/api/auth/routes.py index 3c6bc30..08097f1 100644 --- a/api/auth/routes.py +++ b/api/auth/routes.py @@ -1,31 +1,101 @@ """Authentication Blueprint for MOH""" -from flask import Blueprint +import json +import urllib.parse +import requests + +from flask import Blueprint, request, make_response +from api.database.db import db +from api.auth.controller import AUTOLAB_ID, AUTOLAB_SECRET, REDIRECT_URI blueprint = Blueprint("auth", __name__) + @blueprint.route("/login", methods=["POST"]) def login(): """Checks if the current user has the right credentials to log in Args: - email: forum data field of email - password: forum data field of password + ubit: form data field of ubit + password: form data field of password Returns: The status of the login attempt """ - return "Login arrived" + + ubit = request.form.get("ubit") + pw = request.form.get("password") + + auth_token = db.sign_in(ubit, pw) + + if not auth_token: + return {"message": "Incorrect username or password"}, 400 + + res = make_response(json.dumps({"message": "Successfully logged in"}), 200) + res.content_type = "application/json" + res.set_cookie( + "auth_token", auth_token, max_age=int(2.592e6), httponly=True, secure=True + ) + + return res @blueprint.route("/signup", methods=["POST"]) def signup(): """Creates an account using the given credentials, - fails if email already registered for an account + fails if ubit already registered for an account + or if ubit is not in the roster Args: - email: forum data field of email + ubit: forum data field of ubit password: forum data field of password Returns: The status of the sign-up attempt """ - return "Signup arrived" + + ubit = request.form.get("ubit") + pw = request.form.get("password") + + if not db.lookup_identifier(ubit): + return { + "message": "You are not in the roster. If this is an error, please contact the course staff." + }, 400 + + if not (auth_token := db.sign_up(ubit, pw)): + return {"message": "Sign-in already exists"}, 400 + + res = make_response(json.dumps({"message": "Successfully created account"}), 200) + res.content_type = "application/json" + res.set_cookie( + "auth_token", auth_token, max_age=int(2.592e6), httponly=True, secure=True + ) + return res + + +@blueprint.route("/signout", methods=["POST"]) +def signout(): + """Signs out the currently logged-in user, invalidating their auth token + Args: + Request.cookie: the auth token of the currently logged-in user + + Returns: + 400 if no auth token is set + 200 on success + """ + + auth_token = request.cookies.get("auth_token") + + if not auth_token: + return {"message": "You are not logged in."}, 400 + + db.sign_out(auth_token) + + res = make_response(json.dumps({"message": "Logged out"}), 200) + res.content_type = "application/json" + res.set_cookie("auth_token", "", max_age=0, httponly=True, secure=True) + + return res + + +# TODO: update preferred name + +# TODO: account has UBIT (For AL lookups) and pn (For card swipes) diff --git a/api/config/config.py b/api/config/config.py index d7414d4..05aff57 100644 --- a/api/config/config.py +++ b/api/config/config.py @@ -4,7 +4,8 @@ class Config: - """Configuration class for MOJ api server, stores current configuration state of flask api""" + """Configuration class for MOH api server, stores current configuration state of flask api""" def __init__(self): self.API_MODE = os.getenv("API_MODE", "Can not find mode") + self.MAX_CONTENT_LENGTH = 16 * 1000 * 1000 \ No newline at end of file diff --git a/api/database/db.py b/api/database/db.py new file mode 100644 index 0000000..0120e52 --- /dev/null +++ b/api/database/db.py @@ -0,0 +1,23 @@ +import os + +from api.database.relational_db.relational_db import RelationalDB +from api.database.testing_db.testing_db import TestingDB +from api.database.mock_db.mock_db import MockDB + + +def create_db(): + db_type = os.getenv("DB") + match db_type: + case "relational": + return RelationalDB() + case "testing": + return TestingDB() + case "mock": + return MockDB() + case None: + raise EnvironmentError('environment variable "DB" not set') + case _: + raise ModuleNotFoundError("Could not find database named " + db_type) + + +db = create_db() diff --git a/api/database/db_interface.py b/api/database/db_interface.py new file mode 100644 index 0000000..e3e2e82 --- /dev/null +++ b/api/database/db_interface.py @@ -0,0 +1,18 @@ +from abc import ABC, abstractmethod + +from api.database.idb_queue import IQueue +from api.database.idb_ratings import IRatings +from api.database.idb_accounts import IAccounts +from api.database.idb_roster import IRoster + + +class DBInterface(IQueue, IRatings, IAccounts, IRoster, ABC): + + # All database implements must extend this class + + def __init__(self): + super().__init__() + + @abstractmethod + def connect(self): + pass diff --git a/api/database/idb_accounts.py b/api/database/idb_accounts.py new file mode 100644 index 0000000..432c580 --- /dev/null +++ b/api/database/idb_accounts.py @@ -0,0 +1,55 @@ +from abc import ABC, abstractmethod + + +class IAccounts(ABC): + + def __init__(self): + super().__init__() + + @abstractmethod + def create_account(self, ubit, pn): + # Creates an account with the provided ubit and pn. Generates, and returns, a unique id for the new account + raise NotImplementedError() + + @abstractmethod + def lookup_person_number(self, person_number) -> dict[str, str]: + # Returns the database entry for the user with the specified person number. + raise NotImplementedError() + + @abstractmethod + def lookup_identifier(self, identifier) -> dict[str, str]: + # Returns the database entry for the user with the specified identifier. + # resolves UBIT -> person number -> unique id + raise NotImplementedError() + + @abstractmethod + def get_authenticated_user(self, auth_token) -> dict[str, str]: + # Returns the database entry for the user with the specified auth token. + raise NotImplementedError() + + @abstractmethod + def sign_up(self, username, pw) -> str | None: + # creates a sign in for the requested user + # returns None if the user's ubit isn't in the system + # returns an auth token for the user + raise NotImplementedError() + + @abstractmethod + def sign_in(self, username, pw) -> str | None: + # generates and returns a valid auth token for the user if the username and password match + # returns None on error + raise NotImplementedError() + + @abstractmethod + def sign_out(self, auth_token): + # invalidates the specified auth token + raise NotImplementedError() + + @abstractmethod + def set_preferred_name(self, identifier, name): + # set the user's preferred name based on identifier + raise NotImplementedError() + + @abstractmethod + def set_name(self, identifier, first_name, last_name): + raise NotImplementedError() \ No newline at end of file diff --git a/api/database/idb_queue.py b/api/database/idb_queue.py new file mode 100644 index 0000000..03c7e7b --- /dev/null +++ b/api/database/idb_queue.py @@ -0,0 +1,39 @@ +from abc import ABC, abstractmethod + + +class IQueue(ABC): + + def __init__(self): + super().__init__() + + @abstractmethod + def enqueue_student(self, student): + raise NotImplementedError() + + @abstractmethod + def enqueue_student_front(self, student): + raise NotImplementedError() + + @abstractmethod + def dequeue_student(self): + raise NotImplementedError() + + @abstractmethod + def get_queue(self): + raise NotImplementedError() + + @abstractmethod + def remove_student(self, student): + raise NotImplementedError() + + @abstractmethod + def clear_queue(self): + raise NotImplementedError() + + @abstractmethod + def set_reason(self, student, reason): + raise NotImplementedError() + + @abstractmethod + def move_to_end(self, student): + raise NotImplementedError() \ No newline at end of file diff --git a/api/database/idb_ratings.py b/api/database/idb_ratings.py new file mode 100644 index 0000000..49f3a0b --- /dev/null +++ b/api/database/idb_ratings.py @@ -0,0 +1,11 @@ +from abc import ABC, abstractmethod + + +class IRatings(ABC): + + def __init__(self): + super().__init__() + + @abstractmethod + def rate_student(self, student, rating, feedback): + raise NotImplementedError() diff --git a/api/database/idb_roster.py b/api/database/idb_roster.py new file mode 100644 index 0000000..91cec71 --- /dev/null +++ b/api/database/idb_roster.py @@ -0,0 +1,15 @@ +from abc import ABC, abstractmethod + + +class IRoster(ABC): + + def __init__(self): + super().__init__() + + @abstractmethod + def add_to_roster(self, user_id, role): + raise NotImplementedError() + + @abstractmethod + def get_roster(self): + raise NotImplementedError() \ No newline at end of file diff --git a/api/database/idb_visits.py b/api/database/idb_visits.py new file mode 100644 index 0000000..6daa962 --- /dev/null +++ b/api/database/idb_visits.py @@ -0,0 +1,43 @@ +from abc import ABC, abstractmethod + +class IVisits(ABC): + + def __init__(self): + super().__init__() + + @abstractmethod + def create_visit(self, student, ta, enqueue_time, visit_reason) -> int: + """Create a database entry for the ongoing visit + between the specified student and TA. + + enqueue_time should be in format "YYYY-MM-DD HH:MM:SS" + Should indicate the time the visit was created + + :param student: The user ID of the student + :param ta: The user ID of the TA + :param enqueue_time: Timestamp when the student joined the queue, + in the format "YYYY-MM-DD HH:MM:SS" + :param visit_reason: Student-supplied reason for joining the queue + :return: A numeric ID representing the specific visit + """ + raise NotImplementedError() + + @abstractmethod + def end_visit(self, visit_id, reason): + """Mark in the database that the specified visit + has ended. + + :param visit_id: The numeric ID representing the specific visit + :param reason: The resolution of the visit + """ + raise NotImplementedError() + + @abstractmethod + def get_in_progress_visits(self): + """ Return all database entries for visits that have + not ended. + + + :return: + """ + raise NotImplementedError() \ No newline at end of file diff --git a/api/database/mock_db/mock_db.py b/api/database/mock_db/mock_db.py new file mode 100644 index 0000000..2aba52a --- /dev/null +++ b/api/database/mock_db/mock_db.py @@ -0,0 +1,22 @@ +from api.database.db_interface import DBInterface + + +class MockDB(DBInterface): + + def connect(self): + pass + + def enqueue_student(self, student): + pass + + def dequeue_student(self): + pass + + def rate_student(self, student, rating, feedback): + pass + + def create_account(self, ubit, pn): + pass + + def add_to_roster(self, user_id, role): + pass diff --git a/api/database/relational_db/relational_db.py b/api/database/relational_db/relational_db.py new file mode 100644 index 0000000..526d7a0 --- /dev/null +++ b/api/database/relational_db/relational_db.py @@ -0,0 +1,80 @@ +import os + +from api.database.db_interface import DBInterface +from api.database.relational_db.relational_db_cursor import RelationalDBCursor + +from api.database.relational_db.relational_db_queue import RelationalDBQueue +from api.database.relational_db.relational_db_accounts import RelationalDBAccounts +from api.database.relational_db.relational_db_ratings import RelationalDBRatings +from api.database.relational_db.relational_db_visits import RelationalDBVisits + + +class RelationalDB(DBInterface, RelationalDBAccounts, RelationalDBQueue, RelationalDBRatings, RelationalDBVisits): + + def __init__(self): + super().__init__() + self.filename = os.getenv("SQLITE_DB_PATH", "./moh.sqlite") + self.initialize() + + def initialize(self): + with self.cursor() as c: + c.execute( + """ + CREATE TABLE IF NOT EXISTS users + ( + user_id INTEGER PRIMARY KEY, + preferred_name VARCHAR(64), + last_name VARCHAR(64), + ubit VARCHAR(16) UNIQUE, + person_num INTEGER UNIQUE, + course_role VARCHAR(16) + ); + """ + ) + + c.execute( + """ + CREATE TABLE IF NOT EXISTS queue + ( + user_id INTEGER UNIQUE, + joined TEXT DEFAULT (datetime('now', 'localtime')), + priority INTEGER, + enqueue_reason TEXT + ); + """ + ) + + c.execute( + """ + CREATE TABLE IF NOT EXISTS auth + ( + user_id INTEGER UNIQUE, + auth_token VARCHAR(255), + pw VARCHAR(255), + expires_at TEXT DEFAULT (datetime('now','+30 days')) + ); + """ + ) + + c.execute( + """ + CREATE TABLE IF NOT EXISTS visits + ( + visit_id INTEGER PRIMARY KEY, + student_id INTEGER, + ta_id INTEGER, + student_visit_reason TEXT, + session_start TEXT DEFAULT (datetime('now','localtime')), + session_end TEXT, + session_end_reason TEXT, + enqueue_time TEXT + ); + """ + ) + + def cursor(self): + return RelationalDBCursor(self) + + def connect(self): + pass + diff --git a/api/database/relational_db/relational_db_accounts.py b/api/database/relational_db/relational_db_accounts.py new file mode 100644 index 0000000..a6a3bc9 --- /dev/null +++ b/api/database/relational_db/relational_db_accounts.py @@ -0,0 +1,242 @@ +import hashlib +import secrets + +from api.database.idb_accounts import IAccounts +import bcrypt +import hashlib + +from api.database.idb_roster import IRoster + + +class RelationalDBAccounts(IAccounts, IRoster): + + def create_account(self, ubit, pn): + + with self.cursor() as cursor: + + user_id = cursor.execute( + """ + SELECT user_id FROM users WHERE ubit=? or person_num=? + """, + (ubit, pn), + ).fetchone() + + if user_id is None: + user_id = cursor.execute( + """ + INSERT into users (ubit, person_num, course_role) VALUES ( + ?, ?, "student" + ) + RETURNING user_id; + """, + (ubit, pn), + ).fetchone()[0] + else: + user_id = user_id[0] + + return user_id + + def lookup_person_number(self, person_number): + + with self.cursor() as cursor: + user = cursor.execute( + """ + SELECT preferred_name, last_name, ubit, person_num, course_role, user_id from users + WHERE person_num = ? + """, + (person_number,), + ).fetchone() + + if user is None: + return None + + return { + "preferred_name": user[0], + "last_name": user[1], + "ubit": user[2], + "person_num": user[3], + "course_role": user[4], + "user_id": user[5], + } + + def lookup_identifier(self, identifier): + with self.cursor() as cursor: + user = cursor.execute( + """ + SELECT preferred_name, last_name, ubit, person_num, course_role, user_id from users + WHERE ubit = ? OR person_num = ? OR user_id = ? + """, + (identifier, identifier, identifier), + ).fetchone() + + if user is None: + return None + + return { + "preferred_name": user[0], + "last_name": user[1], + "ubit": user[2], + "person_num": user[3], + "course_role": user[4], + "user_id": user[5], + } + + def get_authenticated_user(self, auth_token): + hashed_token = hashlib.sha256(auth_token.encode()).digest() + with self.cursor() as cursor: + user = cursor.execute( + """ + SELECT preferred_name, last_name, ubit, person_num, course_role, users.user_id + FROM users + INNER JOIN auth ON users.user_id = auth.user_id + WHERE auth_token = ? + AND expires_at > CURRENT_TIMESTAMP + """, + (hashed_token,), + ).fetchone() + + if not user: + return None + + return { + "preferred_name": user[0], + "last_name": user[1], + "ubit": user[2], + "person_num": user[3], + "course_role": user[4], + "user_id": user[5], + } + + def sign_up(self, username, pw) -> str | None: + + hashed = bcrypt.hashpw(pw.encode(), bcrypt.gensalt()) + auth = secrets.token_urlsafe(32) + hashed_auth = hashlib.sha256(auth.encode()).digest() + + with self.cursor() as cursor: + auth_token = cursor.execute( + """ + INSERT OR IGNORE + INTO auth (user_id, pw, auth_token) + SELECT user_id, ?, ? + FROM users + WHERE users.ubit = ? + RETURNING auth_token + """, + (hashed, hashed_auth, username), + ).fetchone() + + if not auth_token: + return None + + return auth + + def sign_in(self, username, pw) -> str | None: + with self.cursor() as cursor: + hashed = cursor.execute( + """ + SELECT users.user_id, pw FROM auth + INNER JOIN users on users.user_id = auth.user_id + WHERE users.ubit = ? + """, + (username,), + ).fetchone() + + if not hashed: + return None + + user_id = hashed[0] + hashed = hashed[1] + if not bcrypt.checkpw(pw.encode(), hashed): + return None + + auth_token = secrets.token_urlsafe(32) + hashed_auth = hashlib.sha256(auth_token.encode()).digest() + + + with self.cursor() as cursor: + cursor.execute( + """ + UPDATE auth + SET auth_token = ?, expires_at = datetime('now', '+30 days') + WHERE user_id = ? + """, + (hashed_auth, user_id), + ) + + return auth_token + + def sign_out(self, auth_token): + hashed_auth = hashlib.sha256(auth_token.encode()).digest() + with self.cursor() as cursor: + cursor.execute( + """ + UPDATE auth + SET auth_token = "", expires_at = CURRENT_TIMESTAMP + WHERE auth_token = ? + """, + (hashed_auth,), + ) + + def add_to_roster(self, user_id, role): + + with self.cursor() as cursor: + cursor.execute( + """ + UPDATE users + SET course_role = ? + WHERE user_id = ? + """, + (role, user_id), + ) + + def get_roster(self): + with self.cursor() as cursor: + users = cursor.execute(""" + SELECT user_id, preferred_name, last_name, ubit, person_num, course_role FROM users + ORDER BY ubit + """).fetchall() + result = [] + for user in users: + result.append({ + "user_id": user[0], + "preferred_name": user[1], + "last_name": user[2], + "ubit": user[3], + "person_num": user[4], + "course_role": user[5] + }) + + return result + + + def set_preferred_name(self, identifier, name): + with self.cursor() as cursor: + + user = cursor.execute( + """ + UPDATE users SET preferred_name = ? + WHERE ubit = ? OR person_num = ? OR user_id = ? + RETURNING user_id + """, (name, identifier, identifier, identifier)).fetchone() + + if user is None: + return None + + return user[0] + + def set_name(self, user_id, first_name, last_name): + with self.cursor() as cursor: + user = cursor.execute( + """ + UPDATE users SET + preferred_name = ?, last_name = ? + WHERE user_id = ? + RETURNING user_id + """, (first_name, last_name, user_id) + ).fetchone() + + if user is None: + return None + + return user[0] \ No newline at end of file diff --git a/api/database/relational_db/relational_db_cursor.py b/api/database/relational_db/relational_db_cursor.py new file mode 100644 index 0000000..f8bb4b1 --- /dev/null +++ b/api/database/relational_db/relational_db_cursor.py @@ -0,0 +1,15 @@ +import sqlite3 + + +class RelationalDBCursor: + def __init__(self, db): + self.db = db + self.connection = None + + def __enter__(self): + self.connection = sqlite3.connect(self.db.filename) + return self.connection.cursor() + + def __exit__(self, exc_type, exc_value, traceback): + self.connection.commit() + self.connection.close() diff --git a/api/database/relational_db/relational_db_queue.py b/api/database/relational_db/relational_db_queue.py new file mode 100644 index 0000000..0e46f58 --- /dev/null +++ b/api/database/relational_db/relational_db_queue.py @@ -0,0 +1,138 @@ +import datetime + +from api.database.idb_queue import IQueue + + +class RelationalDBQueue(IQueue): + + def enqueue_student(self, student): + with self.cursor() as cursor: + cursor.execute( + """ + INSERT OR IGNORE INTO queue (user_id, priority) VALUES (?, 0) + """, + (student,), + ) + + def enqueue_student_front(self, student): + with self.cursor() as cursor: + priority = cursor.execute("SELECT MAX(priority) FROM queue").fetchone()[0] + if priority is None: + priority = 0 + else: + priority += 1 + + cursor.execute( + """ + INSERT OR IGNORE INTO queue (user_id, priority) + VALUES (?, ?) + """, + (student, priority), + ) + + def dequeue_student(self): + with self.cursor() as cursor: + rows = cursor.execute("SELECT COUNT(*) from queue").fetchone()[0] + if rows == 0: + return None + + user = cursor.execute( + """ + SELECT users.user_id, preferred_name, ubit, person_num, joined + FROM queue + INNER JOIN users ON queue.user_id = users.user_id + ORDER BY priority DESC, joined + """ + ).fetchone() + cursor.execute("DELETE FROM queue WHERE user_id = ?", (user[0],)) + + return { + "user_id": user[0], + "preferred_name": user[1], + "ubit": user[2], + "person_num": str(user[3]), + "enqueue_time": user[4] + } + + def dequeue_specified_student(self, student_id): + with self.cursor() as cursor: + user = cursor.execute( + """ + SELECT users.user_id, preferred_name, ubit, person_num, joined, enqueue_reason + FROM queue + INNER JOIN users ON queue.user_id = users.user_id + WHERE users.user_id = ? + """, (student_id,) + ).fetchone() + + if user is None: + return None + + cursor.execute("DELETE FROM queue WHERE user_id = ?", (user[0],)) + + return { + "user_id": user[0], + "preferred_name": user[1], + "ubit": user[2], + "person_num": str(user[3]), + "enqueue_time": user[4], + "enqueue_reason": user[5] + } + + def get_queue(self): + with self.cursor() as cursor: + users = cursor.execute( + """ + SELECT users.user_id, preferred_name, ubit, person_num + FROM queue INNER JOIN users ON queue.user_id = users.user_id + ORDER BY priority DESC, joined + """ + ) + + users_l = list() + + for user in users: + users_l.append( + { + "id": user[0], + "preferred_name": user[1], + "ubit": user[2], + "pn": user[3], + } + ) + + return users_l + + def clear_queue(self): + with self.cursor() as cursor: + cursor.execute( + "DELETE FROM queue" + ) + + def remove_student(self, student): + with self.cursor() as cursor: + queue_info = cursor.execute("SELECT * FROM queue WHERE user_id = ?", (student, )).fetchone() + + if queue_info is None: + return None + + cursor.execute("DELETE FROM queue WHERE user_id = ?", (student, )) + + return {"user_id": queue_info[0], "joined": queue_info[1]} + + def set_reason(self, student, reason): + with self.cursor() as cursor: + cursor.execute( + "UPDATE queue SET enqueue_reason = ? WHERE user_id = ?", (reason, student) + ) + + def move_to_end(self, student): + now = str(datetime.datetime.now().isoformat(' ', timespec="seconds")) + + with self.cursor() as cursor: + res = cursor.execute( + "UPDATE queue SET joined = ?, priority = 0 WHERE user_id = ? RETURNING user_id", (now, student) + ).fetchone() + if res is None: + return False + return True \ No newline at end of file diff --git a/api/database/relational_db/relational_db_ratings.py b/api/database/relational_db/relational_db_ratings.py new file mode 100644 index 0000000..ef41529 --- /dev/null +++ b/api/database/relational_db/relational_db_ratings.py @@ -0,0 +1,8 @@ +from api.database.idb_ratings import IRatings + + +class RelationalDBRatings(IRatings): + + def rate_student(self, student, rating, feedback): + pass + # do database stuff diff --git a/api/database/relational_db/relational_db_visits.py b/api/database/relational_db/relational_db_visits.py new file mode 100644 index 0000000..b69716a --- /dev/null +++ b/api/database/relational_db/relational_db_visits.py @@ -0,0 +1,55 @@ +import datetime + +from api.database.idb_visits import IVisits + + +class RelationalDBVisits(IVisits): + + def __init__(self): + super().__init__() + + def create_visit(self, student, ta, enqueue_time, visit_reason) -> int: + with self.cursor() as cursor: + visit_id = cursor.execute(""" + INSERT INTO visits (student_id, ta_id, enqueue_time, student_visit_reason) VALUES ( + ?, ?, ?, ?) + RETURNING visit_id + """, (student, ta, enqueue_time, visit_reason)).fetchone()[0] + + return visit_id + + + + def end_visit(self, visit_id, reason): + # YYYY-MM-DD HH:MM:SS + now = str(datetime.datetime.now().isoformat(' ', timespec="seconds")) + + with self.cursor() as cursor: + cursor.execute(""" + UPDATE visits + SET session_end = ?, session_end_reason = ? + WHERE visit_id = ? AND session_end is null + """, (now, reason, visit_id)) + + + def get_in_progress_visits(self): + with self.cursor() as cursor: + result = cursor.execute(""" + SELECT visit_id, student_id, student_visit_reason, ta_id, enqueue_time, session_start FROM visits + WHERE session_end IS NULL + """).fetchall() + + visits = [] + for row in result: + visits.append({ + "visit_id": row[0], + "student_id": row[1], + "student_visit_reason": row[2], + "ta_id": row[3], + "enqueue_time": row[4], + "session_start": row[5] + }) + return visits + + + diff --git a/api/database/testing_db/testing_db.py b/api/database/testing_db/testing_db.py new file mode 100644 index 0000000..841704d --- /dev/null +++ b/api/database/testing_db/testing_db.py @@ -0,0 +1,17 @@ +from api.database.db_interface import DBInterface + +from api.database.testing_db.testing_db_queue import TestingDBQueue +from api.database.testing_db.testing_db_ratings import TestingDBRatings +from api.database.testing_db.testing_db_accounts import TestingDBAccounts + + +class TestingDB(DBInterface, TestingDBQueue, TestingDBRatings, TestingDBAccounts): + + def __init__(self): + super().__init__() + + def connect(self): + pass + + def add_to_roster(self, user_id, role): + pass diff --git a/api/database/testing_db/testing_db_accounts.py b/api/database/testing_db/testing_db_accounts.py new file mode 100644 index 0000000..fcede8c --- /dev/null +++ b/api/database/testing_db/testing_db_accounts.py @@ -0,0 +1,14 @@ +from api.database.idb_accounts import IAccounts + + +class TestingDBAccounts(IAccounts): + + def __init__(self): + super().__init__() + self.queue = [] + self.next_id = 0 + + def create_account(self, ubit, pn): + account_id = self.next_id + self.next_id += 1 + return account_id diff --git a/api/database/testing_db/testing_db_queue.py b/api/database/testing_db/testing_db_queue.py new file mode 100644 index 0000000..52ebb48 --- /dev/null +++ b/api/database/testing_db/testing_db_queue.py @@ -0,0 +1,14 @@ +from api.database.idb_queue import IQueue + + +class TestingDBQueue(IQueue): + + def __init__(self): + super().__init__() + self.queue = [] + + def enqueue_student(self, student): + self.queue.append(student) + + def dequeue_student(self): + return self.queue.pop(0) diff --git a/api/database/testing_db/testing_db_ratings.py b/api/database/testing_db/testing_db_ratings.py new file mode 100644 index 0000000..73f1a58 --- /dev/null +++ b/api/database/testing_db/testing_db_ratings.py @@ -0,0 +1,11 @@ +from api.database.idb_ratings import IRatings + + +class TestingDBRatings(IRatings): + + def __init__(self): + super().__init__() + + def rate_student(self, student, rating, feedback): + pass + # do database stuff diff --git a/api/models/live_queue.py b/api/models/live_queue.py deleted file mode 100644 index 2b96d41..0000000 --- a/api/models/live_queue.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Office hour student queue""" - - -class LiveQueue: - """A presentation of all the students currently in office hour waiting for TA assistant""" - - def __init__(self): - pass - - def enqueue(self): - """Enqueue given student into LiveQueue""" - - def dequeue(self): - """dequeue student from LiveQueue""" - - def remove(self): - """Remove student from LiveQueue by given identifier""" diff --git a/api/models/visits.py b/api/models/visits.py deleted file mode 100644 index c1c0cdc..0000000 --- a/api/models/visits.py +++ /dev/null @@ -1 +0,0 @@ -# All the infor for an OH visit \ No newline at end of file diff --git a/api/queue/controller.py b/api/queue/controller.py new file mode 100644 index 0000000..705c6a3 --- /dev/null +++ b/api/queue/controller.py @@ -0,0 +1,48 @@ +from api.database.db import db + + +def decode_pn(raw): + try: + return raw.split("/^")[1][14:22] + except Exception: + return "" + + +def add_to_queue_by_card_swipe(swipe_data): + pn = decode_pn(swipe_data) + student = db.lookup_person_number(pn) + if student is not None: + add_to_queue(student) + return True + return False + + +def add_to_queue_by_ta_override(identifier, front=False): + student = db.lookup_identifier(identifier) + if student is not None: + if front: + add_to_front_of_queue(student) + else: + add_to_queue(student) + return True + return False + + +def add_to_queue(user_account): + user_id = user_account["user_id"] + db.enqueue_student(user_id) + +def add_to_front_of_queue(user_account): + user_id = user_account["user_id"] + db.enqueue_student_front(user_id) + +def remove_from_queue_without_visit(student, reason): + queue_info = db.remove_student(student) + + if queue_info is None: + return False + + visit = db.create_visit(student, None, queue_info["joined"], "") + db.end_visit(visit, reason) + return True + diff --git a/api/queue/routes.py b/api/queue/routes.py index 40da68f..2da00d8 100644 --- a/api/queue/routes.py +++ b/api/queue/routes.py @@ -2,79 +2,433 @@ from flask import Blueprint, request +import api.queue.controller as controller +from api.auth.controller import get_user +from api.queue.controller import remove_from_queue_without_visit +from api.roster.controller import min_level +from api.database.db import db + blueprint = Blueprint("queue", __name__) @blueprint.route("/enqueue-card-swipe", methods=["POST"]) def enqueue_card_swipe(): """ + role: hardware + Add student to the current live queue for office hours Args: - Request.cookie: A HTTP Cookie with the name `id` for the student being added. - Cookie Example - - "id": "12344567890" # only one field seems weird maybe more? + body.swipe_data: The raw data from the card swipe as a string + + Body: + { + "swipe_data": + } Returns: - A JSON of request status and possible wait time in seconds + 200 OK - Student was added to the queue + 404 Not Found - No student matching the card swipe was found { - "message": "You are enqueued", - "wait_time": "5000" + "message": } """ - return f"{request.path} hit 😎, enqueue method is used" + + body = request.get_json() + swipe_data = body["swipe_data"] + + if controller.add_to_queue_by_card_swipe(swipe_data): + return {"message": "Student was added to the queue"} + + return {"message": "No student matching the card swipe was found"}, 404 @blueprint.route("/enqueue-ta-override", methods=["POST"]) +@min_level("ta") def enqueue_ta_override(): """ - Force enqueue a student into the queue. Only usable by TAs and instructors + role: TA + + Force enqueue a student. + + Resolving the id will be done in the order: UBIT -> pn -> id (Although, these _should_ all be unique so the order + shouldn't matter) + + Args: + body.identifier: A unique identifier for the student This can either be their UBIT, pn, or the id of their account + + Body: + { + "identifier": + } + + Returns: + 200 OK - Student was added to the queue + 403 Unauthorized - Requester does not have TA permissions + 404 Not Found - No student matching provided identifier + { + "message": , + } Use case: A student didn't bring their card to OH so they can't swipe in. The TA can force add them to the queue """ - return "" + body = request.get_json() + identifier = body["identifier"] + + if controller.add_to_queue_by_ta_override(identifier): + return {"message": "Student was added to the queue"} + + return {"message": "No student matching provided identifier"}, 404 + +@blueprint.route("/restore-visit", methods=["GET"]) +@min_level("ta") +def restore_visit(): + """ + Returns a visit in the database involving the user that hasn't + ended yet, if such a visit exists. + + i.e. if a TA refreshes the queue before ending the visit + + + Returns: + 200 OK - visit found + { + "id": , + "username": , + "pn": , + "preferred_name: , + "visitID": , + "visit_reason": + } + + 404 Not Found - no such visit exists + """ + user = get_user(request.cookies) + + if user is None: + return {"message": "You are not authenticated!"}, 403 + + user_id = user["user_id"] + + in_progress = db.get_in_progress_visits() + in_progress = list(filter(lambda v: v["ta_id"] == user_id, in_progress)) + + if len(in_progress) == 0: + return {"message": "You have no in-progress visits."}, 404 + + visit = in_progress[0] + student = db.lookup_identifier(visit["student_id"]) + + return { + "id": visit["student_id"], + "username": student["ubit"], + "pn": student["person_num"], + "preferred_name": student["preferred_name"], + "visitID": visit["visit_id"], + "visit_reason": visit["student_visit_reason"] + } -@blueprint.route("/dequeue", methods=["DELETE"]) +@blueprint.route("/help-a-student", methods=["POST"]) +@min_level("ta") def dequeue(): """ - Remove the first student from the queue and create a Visit in the DB + role: TA + + Remove the specified student from the queue and create a Visit in the DB + + Not allowed if TA is already in a visit + + Args: + body.id: The ID of the account to dequeue + + Body: + { + "id": + } + + Returns: + 200 OK - Student was dequeued + { + "id": , + "username": , + "pn": , + "preferred_name: , + "visitID": , + "visit_reason": + } + + 400 Bad Request - The queue is empty or user is not in the queue + 403 Unauthorized - Requester does not have TA permissions + { + "message": + } """ - return "" + + body = request.get_json() + + if not (auth_token := request.cookies.get("auth_token")): + return {"message": "You are not logged in!"}, 403 + + user = db.get_authenticated_user(auth_token) + user_id = user["user_id"] + + in_progress = db.get_in_progress_visits() + in_progress = list(filter(lambda v: v["ta_id"] == user_id, in_progress)) + + if len(in_progress) != 0: + return {"message": "You have a visit in progress."}, 400 + + student = db.dequeue_specified_student(body["id"]) + + if student is None: + return {"message": "The queue is empty"}, 400 + + visit = db.create_visit(body["id"], user_id, student["enqueue_time"], student["enqueue_reason"]) + + return { + "id": int(student["user_id"]), + "username": student["ubit"], + "pn": str(student["person_num"]), + "preferred_name": student["preferred_name"], + "visitID": visit, + "visit_reason": student["enqueue_reason"] + } @blueprint.route("/get-queue", methods=["GET"]) +@min_level("ta") def get_queue(): """ - Returns all information about the queue. Only accessible by TAs and instructors + role: TA + + Returns all student accounts in the queue starting with the front of the queue + + Returns: + 200 OK + [ + { + "id": , + "username": , + "pn": , + "preferred_name: + }, + ... + ] + + 403 Unauthorized - Requester does not have TA permissions + { + "message": + } """ - return "" + + return db.get_queue() -@blueprint.route("/get-anonymous-queue", methods=["GET"]) +@blueprint.route("/get-my-position", methods=["GET"]) def get_anon_queue(): """ - Returns the queue with all private information hidden. Only preferred names are displayed (Or no name for - students who did not enter a preferred name) + role: self - Contains time estimates. This can predict based on tags for the question (eg. a "task 5" tag might have - a higher estimate than a "lecture question" tag) + Returns the position in the queue of the requester. + + Args: + Request.cookie: The auth token used to identify the requester + + Returns: + 200 OK - You're in the queue and here's your position + { + "position": , + "length": + } + + 400 Bad Request - You are not in the queue + { + "message": , + "length": + } + """ + if not (auth_token := request.cookies.get("auth_token")): + return {"message": "You are not logged in!"}, 403 + + user = db.get_authenticated_user(auth_token) + + if not user: + return {"message": "You are not logged in!"}, 403 + + user_id = user["user_id"] + + queue = db.get_queue() + + for i, entry in enumerate(queue, 1): + if entry["id"] == user_id: + return {"position": i, "length": len(queue)} + + return {"message": "You are not in the queue!", "length": len(queue)}, 400 + + +@blueprint.route("/remove-self-from-queue", methods=["POST"]) +def remove_self(): + """ + role: self + + Remove the requester from the queue. Creates a visit in the db to store the reason for the removal + + Args: + Request.cookie: The auth token used to identify the requester + body.reason: a text reason for removing the user from the queue + + Body: + { + "reason": + } + + Returns: + 200 OK - You were removed from the queue and a visit was created + 400 Bad Request - You were not in the queue + { + "message": + } """ - return "" + if not (auth_token := request.cookies.get("auth_token")): + return {"message": "You are not logged in!"}, 403 + + user = db.get_authenticated_user(auth_token) + + if not user: + return {"message": "You are not logged in!"}, 403 + + user_id = user["user_id"] + body = request.get_json() + + if remove_from_queue_without_visit(user_id, f"[SELF-REMOVE]: {body["reason"]}"): + return {"message":"Removed self from queue."} + else: + return {"message": "You are not in the queue!"}, 400 -@blueprint.route("/remove", methods=["DELETE"]) + +@blueprint.route("/remove-from-queue", methods=["POST"]) +@min_level('ta') def remove(): - """Removing students from the queue based on id + """ + role: TA + + Removing students from the queue by id. Creates a visit in the db to store the reason for the removal + + Args: + body.reason: a text reason for removing the user from the queue (eg. "No show") + body.user_id: user ID of the student being removed + + Body: + { + "reason": , + "user_id": + } + + Returns: + 200 OK - Student was removed from the queue and a visit was created + 400 Bad Request - Student with user_id was not in the queue + 403 Unauthorized - Requester does not have TA permissions + { + "message": + } + """ + + body = request.get_json() + + if body.get("user_id") is None or body.get("reason") is None: + return {"message": "Malformed request"}, 400 + + + user_id = body.get("user_id") + reason = body.get("reason") + + if remove_from_queue_without_visit(user_id, f"[REMOVED BY TA]: {reason}"): + return {"message": "Removed student from queue"} + return {"message": "Student is not in queue"}, 400 + + +@blueprint.route("/clear-queue", methods=["DELETE"]) +@min_level('ta') +def clear_queue(): + db.clear_queue() + return {"message": "Successfully cleared the queue."} + + +@blueprint.route("/enqueue-override-front", methods=["POST"]) +@min_level('ta') +def enqueue_override_front(): + """ Exact same behavior as /enqueue-ta-override, except it sends the student to the front. + Args: - Request.cookie: A HTTP Cookie with the name `id` for the student bring removed. - Cookie Example - - "id": "12344567890" + body.identifier: A unique identifier for the student This can either be their UBIT, pn, or the id of their account + + Body: + { + "identifier": + } Returns: - A JSON of request status + 200 OK - Student was added to the queue + 403 Unauthorized - Requester does not have TA permissions + 404 Not Found - No student matching provided identifier { - "message": "You are removed from the queue" + "message": , } """ - return f"{request.path} hit 😎, remove method is used." + body = request.get_json() + identifier = body["identifier"] + + if controller.add_to_queue_by_ta_override(identifier, True): + return {"message": "Student was added to the front of the queue"} + + return {"message": "No student matching provided identifier"}, 404 + +@blueprint.route("/end-visit", methods=["POST"]) +@min_level('ta') +def end_visit(): + body = request.get_json() + + visit = body.get("id") + reason = body.get("reason") + + if visit is None or reason is None: + return {"message": "Malformed request"}, 400 + + db.end_visit(visit, reason) + + return {"message": "Ended the visit"} + +@blueprint.route("/update-reason", methods=["PATCH"]) +@min_level('student') +def update_reason(): + body = request.get_json() + + user = get_user(request.cookies) + + if user is None: + return {"message": "You are not authenticated"}, 401 + + reason = body.get("reason") + + if not reason: + return {"message": "Malformed request"}, 400 + + db.set_reason(user["user_id"], reason) + + return {"message": "Reason updated"} + +@blueprint.route("/move-to-end", methods=["PATCH"]) +@min_level('ta') +def move_to_end(): + body = request.get_json() + + if (user_id := body.get("user_id")) is None: + return {"message": "Malformed request"}, 400 + + if db.move_to_end(user_id): + return {"message": "Moved student to end of the queue"} + + return {"message": "Specified user is not in queue"}, 400 + diff --git a/api/ratings/controller.py b/api/ratings/controller.py new file mode 100644 index 0000000..2ae2839 --- /dev/null +++ b/api/ratings/controller.py @@ -0,0 +1 @@ +pass diff --git a/api/ratings/routes.py b/api/ratings/routes.py index be8bc78..bd86aec 100644 --- a/api/ratings/routes.py +++ b/api/ratings/routes.py @@ -4,5 +4,10 @@ blueprint = Blueprint("ratings", __name__) +# TODO: All the end points for students and TAs. Rating contains rater/ratee/rating/feedback/visitID -# TODO: All the end points for students and TAs \ No newline at end of file +# TODO: create_rating + +# TODO: update rating (must be author) + +# TODO: get ratings (instructor/admin only) diff --git a/api/requirements.txt b/api/requirements.txt index 2f904d0..c14c9ac 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -1,3 +1,6 @@ Flask gunicorn requests +bcrypt + +python-dotenv \ No newline at end of file diff --git a/api/roster/controller.py b/api/roster/controller.py new file mode 100644 index 0000000..99144b3 --- /dev/null +++ b/api/roster/controller.py @@ -0,0 +1,77 @@ +from api.database.db import db +from functools import wraps +from flask import request, current_app + +def get_power_level(role): + match role: + case "student": + return 0 + case "ta": + return 1 + case "instructor": + return 5 + case "admin": + return 10 + return -1 + +def exact_level(role): + def decorator(f): + @wraps(f) + def check_permission(*args, **kwargs): + if current_app.config.get("API_MODE", "") == "testing": + return f(*args, **kwargs) + + if not (auth_token := request.cookies.get("auth_token")): + return {"message": "You are not authenticated"}, 403 + + user = db.get_authenticated_user(auth_token) + + if not user: + return {"message": "You are not authenticated"}, 403 + + if role == user["role"]: + return f(*args, **kwargs) + + return { + "message": "You do not have permission to access this resource" + }, 403 + + return check_permission + + return decorator + + +def min_level(min_role): + def decorator(f): + @wraps(f) + def check_permission(*args, **kwargs): + if current_app.config.get("API_MODE", "") == "testing": + return f(*args, **kwargs) + + if not (auth_token := request.cookies.get("auth_token")): + return {"message": "You are not authenticated"}, 403 + + required_level = get_power_level(min_role) + + user = db.get_authenticated_user(auth_token) + + if not user: + return {"message": "You are not authenticated"}, 403 + + power_level = get_power_level(user["course_role"]) + + if power_level >= required_level: + return f(*args, **kwargs) + + return { + "message": "You do not have permission to access this resource" + }, 403 + + return check_permission + + return decorator + +def add_to_roster(ubit, pn, first_name, last_name, role): + user_id = db.create_account(ubit, pn) + db.add_to_roster(user_id, role) + db.set_name(user_id, first_name, last_name) \ No newline at end of file diff --git a/api/roster/routes.py b/api/roster/routes.py new file mode 100644 index 0000000..93485ef --- /dev/null +++ b/api/roster/routes.py @@ -0,0 +1,209 @@ +"""Roster Blueprint for MOH""" + +from flask import Blueprint, request + +from api.auth.controller import get_user +from api.roster.controller import min_level, add_to_roster +from api.database.db import db + +blueprint = Blueprint("roster", __name__) + +@min_level('instructor') +@blueprint.route("/upload-roster", methods=["POST"]) +def upload_roster(): + """ + Role: instructor or admin + + Populate the database with the uploaded roster. + Doesn't create log-ins for the users. + + CSV formatted ubit,pn,first_name,last_name,role + + Params: + - "roster": the uploaded CSV file + + Returns: + - 200 if successful + - 401 if unauthorized + - 400 if roster is missing or invalid format + """ + + print(request.files) + if not request.files or request.files.get("roster") is None: + return {"message": "Invalid roster upload (missing file)"}, 400 + + file = request.files.get("roster") + if file.filename == '' or not file.filename.endswith(".csv"): + return {"message": "Invalid roster upload (invalid file)"}, 400 + + buffer = file.read() + buffer = buffer.decode() + + lines = buffer.split("\n") + users = [] + for line in lines: + if line == '': + break + + info = line.split(",") + if len(info) != 5: + return {"message": "Invalid roster upload (bad data length)"}, 400 + # person number needs to be numeric + if not info[1].isnumeric(): + return {"message": "Invalid roster upload (non-numeric PN)"}, 400 + pn = int(info[1]) + # role has to be valid and not above instructor's authority + if info[4] not in {"student", "ta", "instructor"}: + return {"message": "Invalid roster upload (bad role)"}, 400 + + users.append({ + "ubit": info[0], + "pn": pn, + "first_name": info[2], + "last_name": info[3], + "role": info[4] + }) + + for user in users: + add_to_roster(user["ubit"], user["pn"], user["first_name"], user["last_name"], user["role"]) + + return {"message": "Successfully uploaded roster"}, 200 + + +# TODO: get roster + +@min_level('instructor') +@blueprint.route("/get-roster", methods=["GET"]) +def get_roster(): + """ + Role: instructor or admin + + Returns: + 401 if unauthorized + 200 if successful: + { + roster: [ + { + "user_id": + "ubit": , + "pn": , + "preferred_name": , + "last_name": + "role": + } + ] + } + + + """ + roster = db.get_roster() + + return {"roster": roster} + + + +@min_level('student') +@blueprint.route("/update-name", methods=["PATCH"]) +def update_preferred_name(): + user = get_user(request.cookies) + + if user is None: + return {"message": "You are not authenticated!"}, 401 + + body = request.get_json() + + if (name := body.get("name")) is None: + return {"message": "Malformed request."}, 400 + + db.set_preferred_name(user["ubit"], name) + + return {"message": "Updated preferred name."} + + +@min_level('instructor') +@blueprint.route("/enroll", methods=["POST"]) +def enroll_user(): + """ + Enroll a single user. Won't enroll admins. + + + Body: + { + "ubit": + "pn": , + "preferred_name": , + "last_name": + "role": + } + + Returns: + 200, if successful + 400, if malformed + 401, if not instructor or admin + """ + data = request.get_json() + + required_fields = ["ubit", "pn", + "preferred_name", "last_name", + "role"] + + legal_roles = {"student", "ta", "instructor"} + + for field in required_fields: + if data.get(field) is None or data.get(field) == "": + return {"message": "Malformed request"}, 400 + + if data["role"] not in legal_roles: + return {"message": "Malformed request"}, 400 + + + user_id = db.create_account(data["ubit"], data["pn"]) + db.add_to_roster(user_id, data["role"]) + db.set_name(user_id, data["preferred_name"], data["last_name"]) + + return {"message": "Successfully enrolled user", + "id": user_id} + +@min_level('instructor') +@blueprint.route("/visits/", methods=["GET"]) +@blueprint.route("/visits", methods=["GET"], defaults={"user_id": None}) +def get_visits(user_id): + """ + Get a list of visits. If a user_id is specified, only include + visits where the specified user is involved (either as the student + or TA). + + Params: + - user_id: + + Returns: + 200 on success: + { + "visits": [ + { + "visit_id": , + "ta_id": , + "ta_name": + "student_id": , + "student_name": + "start_time": + "end_time": + } + ] + } + + + :return: + """ + + pass + + +# TODO: add to roster - to add an individual to the roster + +# TODO: Remove from roster + +# TODO: Whenever someone is added to the roster, check if they have an account and create one for them if not + +# TODO: handle roles here (Will make it easier to move to multiple courses in the future) + diff --git a/api/run_local.py b/api/run_local.py new file mode 100644 index 0000000..3b8c26a --- /dev/null +++ b/api/run_local.py @@ -0,0 +1,11 @@ +# Runs the app locally without Gunicorn. To be used for dev and testing + +from dotenv import load_dotenv + +load_dotenv() + +from api.server import create_app + +app = create_app() +app.debug = True +app.run() diff --git a/api/server.py b/api/server.py index 81f72de..3c63c07 100644 --- a/api/server.py +++ b/api/server.py @@ -3,28 +3,30 @@ A Flask API server that handles enqueue and dequeuing students from the office hours queue. """ -# TODO: Make the doc string sound better - import datetime import io -import json import requests -from flask import Flask, request +from flask import Flask, render_template, request, redirect from flask import send_file from api.config import config +from api.database.db import db +from api.roster.controller import min_level, get_power_level from api.utils.debug import debug_access_only import api.auth.routes as auth_routes import api.queue.routes as queue_routes import api.ratings.routes as ratings_routes +import api.roster.routes as roster_routes +import api.utils.debug_routes as debug_routes + def create_app(): """Create and return Flask API server This function is used to set up the Flask API server, loading all its dependencies """ - app = Flask(__name__) + app = Flask(__name__, template_folder="../client/templates", static_folder="../client/static") app.config.from_object(config.Config()) @@ -33,11 +35,24 @@ def create_app(): app.register_blueprint(auth_routes.blueprint) app.register_blueprint(queue_routes.blueprint) app.register_blueprint(ratings_routes.blueprint) + app.register_blueprint(roster_routes.blueprint) + app.register_blueprint(debug_routes.blueprint) - @app.route("/", methods=["GET"]) - def home(): - mode = app.config.get("API_MODE", "Can not find API_MODE") - return f"Welcome to the homepage, you are currently in {mode}" + @app.route("/user/", methods=["GET"]) + @min_level('ta') + def get_user_info(user_id): + user = db.lookup_identifier(user_id) + return user + + @app.route("/me", methods=["GET"]) + def get_my_info(): + if not (auth_token := request.cookies.get("auth_token")): + return {"message": "You are not authenticated."}, 401 + + if not (user := db.get_authenticated_user(auth_token)): + return {"message": "You are not authenticated."}, 401 + + return user @app.route("/favicon.ico", methods=["GET"]) @debug_access_only @@ -55,7 +70,8 @@ def health(): """Current health of the API server with metadata of the time""" return {"timestamp": str(datetime.datetime.now())} + @app.route("/", methods=["GET"]) + def home(): + return "The API is running :)" return app - -app = create_app() \ No newline at end of file diff --git a/api/test/.placeholder b/api/test/.placeholder deleted file mode 100644 index e69de29..0000000 diff --git a/api/utils/debug.py b/api/utils/debug.py index 5ee9ee1..62abdcd 100644 --- a/api/utils/debug.py +++ b/api/utils/debug.py @@ -1,7 +1,7 @@ """Util functions that will be used for debugging only within flask debug mode""" from functools import wraps -from flask import current_app, abort +from flask import current_app, abort, request, Blueprint # Referenced: https://stackoverflow.com/a/55729767 diff --git a/api/utils/debug_routes.py b/api/utils/debug_routes.py new file mode 100644 index 0000000..ed38d85 --- /dev/null +++ b/api/utils/debug_routes.py @@ -0,0 +1,25 @@ +from api.database.db import db +from flask import request, Blueprint +from api.auth.controller import create_account +from api.utils.debug import debug_access_only + +blueprint = Blueprint("debug", __name__) + + +@blueprint.route("/force-enroll", methods=["POST"]) +@debug_access_only +def force_enroll(): + body = request.form + ubit = body.get("ubit") + pn = body.get("pn") + role = body.get("role") + + if not ubit or not pn or not role: + return {"message": "Bad request"}, 400 + + if role not in {"student", "ta", "instructor", "admin"}: + return {"message": "Invalid role"}, 400 + + user_id = create_account(ubit, pn, role) + + return {"message": "Successfully enrolled", "id": user_id} diff --git a/api/utils/logging.py b/api/utils/logging.py index 9df057c..4338974 100644 --- a/api/utils/logging.py +++ b/api/utils/logging.py @@ -1 +1,3 @@ """Logging utils for MOH API server""" + +# log levels from an env var diff --git a/client/.gitignore b/client/.gitignore new file mode 100644 index 0000000..a3f7a51 --- /dev/null +++ b/client/.gitignore @@ -0,0 +1,36 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +.DS_Store +dist +dist-ssr +coverage +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +*.tsbuildinfo + +.eslintcache + +# Cypress +/cypress/videos/ +/cypress/screenshots/ + +# Vitest +__screenshots__/ diff --git a/client/Dockerfile b/client/Dockerfile new file mode 100644 index 0000000..ed74afa --- /dev/null +++ b/client/Dockerfile @@ -0,0 +1,6 @@ +FROM node:latest + +WORKDIR client/ + +COPY . . +RUN npm install diff --git a/client/README.md b/client/README.md new file mode 100644 index 0000000..1679f1b --- /dev/null +++ b/client/README.md @@ -0,0 +1,21 @@ +# MakeOfficeHours + +## Getting Started + +Currently, the frontend is built using the Vue framework. + +To run the frontend in development, cd into the client directory and run the following commands: +```bash +npm install +``` +```bash +npm run dev +``` + +This will start the development server on port 3000. + +The frontend server expects the backend to be running on port 5000, and requests for paths starting with `/api/` to be proxied to the backend. Vite is configured to do this; however, in deployment this will have to be done independently. + +### Some Notes + +At the moment, all Vue files are written in Typescript. For consistency, future files should be as well unless this gets changed globally. \ No newline at end of file diff --git a/client/env.d.ts b/client/env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/client/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..ed8cf81 --- /dev/null +++ b/client/index.html @@ -0,0 +1,13 @@ + + + + + + + Make Office Hours + + +
+ + + diff --git a/client/package-lock.json b/client/package-lock.json new file mode 100644 index 0000000..083f27f --- /dev/null +++ b/client/package-lock.json @@ -0,0 +1,2967 @@ +{ + "name": "web", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "web", + "version": "0.0.0", + "dependencies": { + "vue": "^3.5.26", + "vue-router": "^4.6.4" + }, + "devDependencies": { + "@tsconfig/node24": "^24.0.3", + "@types/node": "^24.10.4", + "@vitejs/plugin-vue": "^6.0.3", + "@vue/tsconfig": "^0.8.1", + "npm-run-all2": "^8.0.4", + "typescript": "~5.9.3", + "vite": "^7.3.0", + "vite-plugin-vue-devtools": "^8.0.5", + "vue-tsc": "^3.2.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", + "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz", + "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", + "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz", + "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", + "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.28.6", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", + "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", + "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", + "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.6" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-proposal-decorators": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.28.6.tgz", + "integrity": "sha512-RVdFPPyY9fCRAX68haPmOk2iyKW8PKJFthmm8NeSI3paNxKWGZIn99+VbIf0FrtCpFnPgnpF/L48tadi617ULg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-syntax-decorators": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-decorators": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.28.6.tgz", + "integrity": "sha512-71EYI0ONURHJBL4rSFXnITXqXrrY8q4P0q006DPfN+Rk+ASM+++IBXem/ruokgBZR8YNEWZ8R6B+rCb8VcUTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", + "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz", + "integrity": "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz", + "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", + "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", + "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", + "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz", + "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz", + "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz", + "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz", + "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz", + "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz", + "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz", + "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz", + "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz", + "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz", + "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz", + "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz", + "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz", + "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz", + "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz", + "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz", + "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz", + "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz", + "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz", + "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz", + "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz", + "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz", + "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz", + "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz", + "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tsconfig/node24": { + "version": "24.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node24/-/node24-24.0.3.tgz", + "integrity": "sha512-vcERKtKQKHgzt/vfS3Gjasd8SUI2a0WZXpgJURdJsMySpS5+ctgbPfuLj2z/W+w4lAfTWxoN4upKfu2WzIRYnw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.10.8", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.8.tgz", + "integrity": "sha512-r0bBaXu5Swb05doFYO2kTWHMovJnNVbCsII0fhesM8bNRlLhXIuckley4a2DaD+vOdmm5G+zGkQZAPZsF80+YQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.3.tgz", + "integrity": "sha512-TlGPkLFLVOY3T7fZrwdvKpjprR3s4fxRln0ORDo1VQ7HHyxJwTlrjKU3kpVWTlaAjIEuCTokmjkZnr8Tpc925w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-beta.53" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.27", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.27.tgz", + "integrity": "sha512-DjmjBWZ4tJKxfNC1F6HyYERNHPYS7L7OPFyCrestykNdUZMFYzI9WTyvwPcaNaHlrEUwESHYsfEw3isInncZxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.27" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.27", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.27.tgz", + "integrity": "sha512-ynlcBReMgOZj2i6po+qVswtDUeeBRCTgDurjMGShbm8WYZgJ0PA4RmtebBJ0BCYol1qPv3GQF6jK7C9qoVc7lg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.27", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.27.tgz", + "integrity": "sha512-eWaYCcl/uAPInSK2Lze6IqVWaBu/itVqR5InXcHXFyles4zO++Mglt3oxdgj75BDcv1Knr9Y93nowS8U3wqhxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.27", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/babel-helper-vue-transform-on": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@vue/babel-helper-vue-transform-on/-/babel-helper-vue-transform-on-1.5.0.tgz", + "integrity": "sha512-0dAYkerNhhHutHZ34JtTl2czVQHUNWv6xEbkdF5W+Yrv5pCWsqjeORdOgbtW2I9gWlt+wBmVn+ttqN9ZxR5tzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/babel-plugin-jsx": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@vue/babel-plugin-jsx/-/babel-plugin-jsx-1.5.0.tgz", + "integrity": "sha512-mneBhw1oOqCd2247O0Yw/mRwC9jIGACAJUlawkmMBiNmL4dGA2eMzuNZVNqOUfYTa6vqmND4CtOPzmEEEqLKFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.0", + "@babel/types": "^7.28.2", + "@vue/babel-helper-vue-transform-on": "1.5.0", + "@vue/babel-plugin-resolve-type": "1.5.0", + "@vue/shared": "^3.5.18" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + } + } + }, + "node_modules/@vue/babel-plugin-resolve-type": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@vue/babel-plugin-resolve-type/-/babel-plugin-resolve-type-1.5.0.tgz", + "integrity": "sha512-Wm/60o+53JwJODm4Knz47dxJnLDJ9FnKnGZJbUUf8nQRAtt6P+undLUAVU3Ha33LxOJe6IPoifRQ6F/0RrU31w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/parser": "^7.28.0", + "@vue/compiler-sfc": "^3.5.18" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.26.tgz", + "integrity": "sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/shared": "3.5.26", + "entities": "^7.0.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.26.tgz", + "integrity": "sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.26", + "@vue/shared": "3.5.26" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.26.tgz", + "integrity": "sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/compiler-core": "3.5.26", + "@vue/compiler-dom": "3.5.26", + "@vue/compiler-ssr": "3.5.26", + "@vue/shared": "3.5.26", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.26.tgz", + "integrity": "sha512-lZT9/Y0nSIRUPVvapFJEVDbEXruZh2IYHMk2zTtEgJSlP5gVOqeWXH54xDKAaFS4rTnDeDBQUYDtxKyoW9FwDw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.26", + "@vue/shared": "3.5.26" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/devtools-core": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@vue/devtools-core/-/devtools-core-8.0.5.tgz", + "integrity": "sha512-dpCw8nl0GDBuiL9SaY0mtDxoGIEmU38w+TQiYEPOLhW03VDC0lfNMYXS/qhl4I0YlysGp04NLY4UNn6xgD0VIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^8.0.5", + "@vue/devtools-shared": "^8.0.5", + "mitt": "^3.0.1", + "nanoid": "^5.1.5", + "pathe": "^2.0.3", + "vite-hot-client": "^2.1.0" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/@vue/devtools-core/node_modules/nanoid": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", + "integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.0.5.tgz", + "integrity": "sha512-q2VV6x1U3KJMTQPUlRMyWEKVbcHuxhqJdSr6Jtjz5uAThAIrfJ6WVZdGZm5cuO63ZnSUz0RCsVwiUUb0mDV0Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^8.0.5", + "birpc": "^2.6.1", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^2.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.0.5.tgz", + "integrity": "sha512-bRLn6/spxpmgLk+iwOrR29KrYnJjG9DGpHGkDFG82UM21ZpJ39ztUT9OXX3g+usW7/b2z+h46I9ZiYyB07XMXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/language-core": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.2.2.tgz", + "integrity": "sha512-5DAuhxsxBN9kbriklh3Q5AMaJhyOCNiQJvCskN9/30XOpdLiqZU9Q+WvjArP17ubdGEyZtBzlIeG5nIjEbNOrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.27", + "@vue/compiler-dom": "^3.5.0", + "@vue/shared": "^3.5.0", + "alien-signals": "^3.0.0", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.2" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.26.tgz", + "integrity": "sha512-9EnYB1/DIiUYYnzlnUBgwU32NNvLp/nhxLXeWRhHUEeWNTn1ECxX8aGO7RTXeX6PPcxe3LLuNBFoJbV4QZ+CFQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.26" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.26.tgz", + "integrity": "sha512-xJWM9KH1kd201w5DvMDOwDHYhrdPTrAatn56oB/LRG4plEQeZRQLw0Bpwih9KYoqmzaxF0OKSn6swzYi84e1/Q==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.26", + "@vue/shared": "3.5.26" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.26.tgz", + "integrity": "sha512-XLLd/+4sPC2ZkN/6+V4O4gjJu6kSDbHAChvsyWgm1oGbdSO3efvGYnm25yCjtFm/K7rrSDvSfPDgN1pHgS4VNQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.26", + "@vue/runtime-core": "3.5.26", + "@vue/shared": "3.5.26", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.26.tgz", + "integrity": "sha512-TYKLXmrwWKSodyVuO1WAubucd+1XlLg4set0YoV+Hu8Lo79mp/YMwWV5mC5FgtsDxX3qo1ONrxFaTP1OQgy1uA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.26", + "@vue/shared": "3.5.26" + }, + "peerDependencies": { + "vue": "3.5.26" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.26.tgz", + "integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==", + "license": "MIT" + }, + "node_modules/@vue/tsconfig": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@vue/tsconfig/-/tsconfig-0.8.1.tgz", + "integrity": "sha512-aK7feIWPXFSUhsCP9PFqPyFOcz4ENkb8hZ2pneL6m2UjCkccvaOhC/5KCKluuBufvp2KzkbdA2W2pk20vLzu3g==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": "5.x", + "vue": "^3.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/alien-signals": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.1.2.tgz", + "integrity": "sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ansis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz", + "integrity": "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.14", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.14.tgz", + "integrity": "sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001764", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz", + "integrity": "sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/copy-anything": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-what": "^5.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cross-spawn/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/default-browser": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.4.0.tgz", + "integrity": "sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.0.tgz", + "integrity": "sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-what": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/is-wsl": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", + "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", + "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-4.0.0.tgz", + "integrity": "sha512-lR4MXjGNgkJc7tkQ97kb2nuEMnNCyU//XYVH0MKTGcXEiSudQ5MKGKen3C5QubYy0vmq+JGitUg92uuywGEwIA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kolorist": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz", + "integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/memorystream": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", + "integrity": "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==", + "dev": true, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true, + "license": "MIT" + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/npm-normalize-package-bin": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-4.0.0.tgz", + "integrity": "sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm-run-all2": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/npm-run-all2/-/npm-run-all2-8.0.4.tgz", + "integrity": "sha512-wdbB5My48XKp2ZfJUlhnLVihzeuA1hgBnqB2J9ahV77wLS+/YAJAlN8I+X3DIFIPZ3m5L7nplmlbhNiFDmXRDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "cross-spawn": "^7.0.6", + "memorystream": "^0.3.1", + "picomatch": "^4.0.2", + "pidtree": "^0.6.0", + "read-package-json-fast": "^4.0.0", + "shell-quote": "^1.7.3", + "which": "^5.0.0" + }, + "bin": { + "npm-run-all": "bin/npm-run-all/index.js", + "npm-run-all2": "bin/npm-run-all/index.js", + "run-p": "bin/run-p/index.js", + "run-s": "bin/run-s/index.js" + }, + "engines": { + "node": "^20.5.0 || >=22.0.0", + "npm": ">= 10" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.0.0.tgz", + "integrity": "sha512-fkEH/OBiKrqqI/yIgjR92lMfs2K8105zt/VT6+7eTjNwisrsh47CeIED9z58zI7DfKdH3uHAn25ziRZn3kgAow==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pidtree": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", + "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", + "dev": true, + "license": "MIT", + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/read-package-json-fast": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/read-package-json-fast/-/read-package-json-fast-4.0.0.tgz", + "integrity": "sha512-qpt8EwugBWDw2cgE2W+/3oxC+KTez2uSVR8JU9Q36TXPAGCaozfQUs59v4j4GFpWTaw0i6hAZSvOmu1J0uOEUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "json-parse-even-better-errors": "^4.0.0", + "npm-normalize-package-bin": "^4.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", + "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.55.1", + "@rollup/rollup-android-arm64": "4.55.1", + "@rollup/rollup-darwin-arm64": "4.55.1", + "@rollup/rollup-darwin-x64": "4.55.1", + "@rollup/rollup-freebsd-arm64": "4.55.1", + "@rollup/rollup-freebsd-x64": "4.55.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", + "@rollup/rollup-linux-arm-musleabihf": "4.55.1", + "@rollup/rollup-linux-arm64-gnu": "4.55.1", + "@rollup/rollup-linux-arm64-musl": "4.55.1", + "@rollup/rollup-linux-loong64-gnu": "4.55.1", + "@rollup/rollup-linux-loong64-musl": "4.55.1", + "@rollup/rollup-linux-ppc64-gnu": "4.55.1", + "@rollup/rollup-linux-ppc64-musl": "4.55.1", + "@rollup/rollup-linux-riscv64-gnu": "4.55.1", + "@rollup/rollup-linux-riscv64-musl": "4.55.1", + "@rollup/rollup-linux-s390x-gnu": "4.55.1", + "@rollup/rollup-linux-x64-gnu": "4.55.1", + "@rollup/rollup-linux-x64-musl": "4.55.1", + "@rollup/rollup-openbsd-x64": "4.55.1", + "@rollup/rollup-openharmony-arm64": "4.55.1", + "@rollup/rollup-win32-arm64-msvc": "4.55.1", + "@rollup/rollup-win32-ia32-msvc": "4.55.1", + "@rollup/rollup-win32-x64-gnu": "4.55.1", + "@rollup/rollup-win32-x64-msvc": "4.55.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/unplugin-utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.1.tgz", + "integrity": "sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-dev-rpc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/vite-dev-rpc/-/vite-dev-rpc-1.1.0.tgz", + "integrity": "sha512-pKXZlgoXGoE8sEKiKJSng4hI1sQ4wi5YT24FCrwrLt6opmkjlqPPVmiPWWJn8M8byMxRGzp1CrFuqQs4M/Z39A==", + "dev": true, + "license": "MIT", + "dependencies": { + "birpc": "^2.4.0", + "vite-hot-client": "^2.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vite": "^2.9.0 || ^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.1 || ^7.0.0-0" + } + }, + "node_modules/vite-hot-client": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/vite-hot-client/-/vite-hot-client-2.1.0.tgz", + "integrity": "sha512-7SpgZmU7R+dDnSmvXE1mfDtnHLHQSisdySVR7lO8ceAXvM0otZeuQQ6C8LrS5d/aYyP/QZ0hI0L+dIPrm4YlFQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vite": "^2.6.0 || ^3.0.0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0" + } + }, + "node_modules/vite-plugin-inspect": { + "version": "11.3.3", + "resolved": "https://registry.npmjs.org/vite-plugin-inspect/-/vite-plugin-inspect-11.3.3.tgz", + "integrity": "sha512-u2eV5La99oHoYPHE6UvbwgEqKKOQGz86wMg40CCosP6q8BkB6e5xPneZfYagK4ojPJSj5anHCrnvC20DpwVdRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansis": "^4.1.0", + "debug": "^4.4.1", + "error-stack-parser-es": "^1.0.5", + "ohash": "^2.0.11", + "open": "^10.2.0", + "perfect-debounce": "^2.0.0", + "sirv": "^3.0.1", + "unplugin-utils": "^0.3.0", + "vite-dev-rpc": "^1.1.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "@nuxt/kit": { + "optional": true + } + } + }, + "node_modules/vite-plugin-vue-devtools": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/vite-plugin-vue-devtools/-/vite-plugin-vue-devtools-8.0.5.tgz", + "integrity": "sha512-p619BlKFOqQXJ6uDWS1vUPQzuJOD6xJTfftj57JXBGoBD/yeQCowR7pnWcr/FEX4/HVkFbreI6w2uuGBmQOh6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-core": "^8.0.5", + "@vue/devtools-kit": "^8.0.5", + "@vue/devtools-shared": "^8.0.5", + "sirv": "^3.0.2", + "vite-plugin-inspect": "^11.3.3", + "vite-plugin-vue-inspector": "^5.3.2" + }, + "engines": { + "node": ">=v14.21.3" + }, + "peerDependencies": { + "vite": "^6.0.0 || ^7.0.0-0" + } + }, + "node_modules/vite-plugin-vue-inspector": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/vite-plugin-vue-inspector/-/vite-plugin-vue-inspector-5.3.2.tgz", + "integrity": "sha512-YvEKooQcSiBTAs0DoYLfefNja9bLgkFM7NI2b07bE2SruuvX0MEa9cMaxjKVMkeCp5Nz9FRIdcN1rOdFVBeL6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.23.0", + "@babel/plugin-proposal-decorators": "^7.23.0", + "@babel/plugin-syntax-import-attributes": "^7.22.5", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-transform-typescript": "^7.22.15", + "@vue/babel-plugin-jsx": "^1.1.5", + "@vue/compiler-dom": "^3.3.4", + "kolorist": "^1.8.0", + "magic-string": "^0.30.4" + }, + "peerDependencies": { + "vite": "^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0" + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz", + "integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/compiler-dom": "3.5.26", + "@vue/compiler-sfc": "3.5.26", + "@vue/runtime-dom": "3.5.26", + "@vue/server-renderer": "3.5.26", + "@vue/shared": "3.5.26" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-tsc": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.2.2.tgz", + "integrity": "sha512-r9YSia/VgGwmbbfC06hDdAatH634XJ9nVl6Zrnz1iK4ucp8Wu78kawplXnIDa3MSu1XdQQePTHLXYwPDWn+nyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.27", + "@vue/language-core": "3.2.2" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/which": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..bf375da --- /dev/null +++ b/client/package.json @@ -0,0 +1,31 @@ +{ + "name": "web", + "version": "0.0.0", + "private": true, + "type": "module", + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "scripts": { + "dev": "vite --port 3000 --host 0.0.0.0", + "build": "run-p type-check \"build-only {@}\" --", + "preview": "vite preview", + "build-only": "vite build", + "type-check": "vue-tsc --build" + }, + "dependencies": { + "vue": "^3.5.26", + "vue-router": "^4.6.4" + }, + "devDependencies": { + "@tsconfig/node24": "^24.0.3", + "@types/node": "^24.10.4", + "@vitejs/plugin-vue": "^6.0.3", + "@vue/tsconfig": "^0.8.1", + "npm-run-all2": "^8.0.4", + "typescript": "~5.9.3", + "vite": "^7.3.0", + "vite-plugin-vue-devtools": "^8.0.5", + "vue-tsc": "^3.2.2" + } +} diff --git a/client/src/App.vue b/client/src/App.vue new file mode 100644 index 0000000..1dfa846 --- /dev/null +++ b/client/src/App.vue @@ -0,0 +1,25 @@ + + + + + \ No newline at end of file diff --git a/client/src/assets/css/base.css b/client/src/assets/css/base.css new file mode 100644 index 0000000..50a0446 --- /dev/null +++ b/client/src/assets/css/base.css @@ -0,0 +1,211 @@ +:root { + --accent-color: #0461cf; + --dark-accent-color: #03499e; + --bg-color: #FFFFFF; + --danger-color: #EC221F; +} + +#queue-banner.bad { + background-color: #FDD3D0 +} + +.disabled { + display: none; +} + +#queue-banner { + background-color: #CFF7D3; + height: 5vh; + color: black; + margin-top: -32px; + margin-bottom: 16px; +} + +button { + background-color: #D9D9D9; + border: 0; + border-radius: 25px; + font-size: 1rem; + color: black; + font-weight: bold; + max-width: 100%; + padding: 12px 20px; +} + +button:hover { + cursor: pointer; + background-color: #B9B9B9; +} + +button.danger { + background-color: var(--danger-color); + color: white; +} + +button.danger:hover { + background-color: #B4221F; + color: white; +} + +button:disabled { + cursor: not-allowed; + transition: none; +} + +button.important { + background-color: var(--accent-color); + color: white; +} + +button.important:hover { + background-color: var(--dark-accent-color); +} + +html { + font-size: 18px; +} + +h1 { + font-size: 2.2rem; +} + +h1 { + margin: 6px 0; +} + +h2 { + margin: 4px 0; +} + +h3 { + margin: 2px 0; +} + +header { + text-align: center; + display: flex; + justify-content: center; + align-items:center; +} + +.all-centered { + text-align: center; + display: flex; + justify-content: center; + align-items:center; +} + +.left-al { + align-items: flex-start; +} + +.right-al-item { + align-self: flex-end; +} + +.columns { + flex-direction: column; + display: flex; +} + +.columns > * { + margin-bottom: 8px; +} + +#site-header { + height: 10vh; + background-color: var(--accent-color); + color: white; + box-shadow: 1px 1px 4px gray; +} + +section { + margin: 8px; +} + +* { + margin: 0; + padding: 0; + font-family: sans-serif; +} + +#login-buttons { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.login-button { + margin: 10px; + height: 80px; + width: 360px; + font-size: 1.2rem; + border-radius: 20px; + box-shadow: 1px 3px 4px gray; + border: 0; + font-weight: bold; +} + +.circle { + background-color: #D9D9D9; + height: 128px; + width: 128px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 2.5rem; +} + +input { + font-size: 1rem; + min-height: 2rem; + border-radius: 10px; + border: 2px solid #D9D9D9; + padding: 0 8px; +} + +.flex { + flex: 1; +} + +.no-grow { + flex: 1; + flex-grow: 0; + width: fit-content; +} + +.modal, .small-modal { + margin: auto; + padding: 6%; + border: 2px solid #B9B9B9; + border-radius: 10px; + flex-direction: column; + gap: 8px; +} + +.modal-big-text { + width: 100%; + height: 6rem; + overflow-y: scroll; + resize: none; + border-radius: 10px; + border: 2px solid #D9D9D9; + padding: 8px; + font-size: 1rem; +} + +.button-container { + display: flex; + gap: 4px; +} + +textarea { + font-size: 1rem; +} + +select { + font-size: 1rem; + padding: 8px; +} \ No newline at end of file diff --git a/client/src/assets/css/dev-login.css b/client/src/assets/css/dev-login.css new file mode 100644 index 0000000..49bc4db --- /dev/null +++ b/client/src/assets/css/dev-login.css @@ -0,0 +1,21 @@ +.dev-form { + max-width: fit-content; + border: 2px solid #D9D9D9; + padding: 8px; + border-radius: 10px; + margin: 16px 8px; +} + +.dev-form > * { + margin-bottom: 4px; +} + + +select, input { + padding: 4px; +} + +#forms { + margin: 32px; +} + diff --git a/client/src/assets/css/instructor-queue.css b/client/src/assets/css/instructor-queue.css new file mode 100644 index 0000000..2425958 --- /dev/null +++ b/client/src/assets/css/instructor-queue.css @@ -0,0 +1,62 @@ +#instructor-queue { + display: flex; + flex-direction: column; + margin: 32px 8%; +} + +#queue-buttons { + display: flex; + gap: 4px; +} + +@media screen and (max-width: 991px) { + #instructor-queue { + margin: 32px 2%; + } + + #queue-buttons { + flex-direction: column; + gap: 16px; + } + + #buttons-l { + justify-content: center; + } + + #buttons-r { + justify-content: center; + flex-direction: column; + margin: 0 10%; + } + + h1, h2, h3 { + text-align: center; + } +} + +@media screen and (min-width: 992px) { + #buttons-r { + margin-left: auto; + } +} + +.queue-section { + margin-bottom: 4px; +} + +#queue-container { + margin: 32px 16px; + border: 2px solid #D9D9D9; + border-bottom: 0; +} + +#buttons-l, #buttons-r { + display: flex; + gap: 8px; +} + +.input-modal-container { + display: flex; + gap: 4px; + margin-left: auto; +} \ No newline at end of file diff --git a/client/src/assets/css/student-queue.css b/client/src/assets/css/student-queue.css new file mode 100644 index 0000000..da74824 --- /dev/null +++ b/client/src/assets/css/student-queue.css @@ -0,0 +1,52 @@ +#queue { + display: flex; + justify-content: center; + flex-direction: column; +} + +.queue-section { + justify-content: center; + display: flex; + flex-grow: 0; +} + +.queue-section > * { + margin: 8px; +} + +.circle.pos { + background-color: var(--accent-color); + color: white; + box-shadow: 0 2px 4px gray; +} + +#user { + display: flex; + flex-direction: column; + justify-content: center; +} + +#user > * { + margin-bottom: 8px; +} + +.wide-button { + width: 20rem; +} + +#student-visit-reason { + resize: none; + height: 8rem; + width: 20rem; + padding: 16px; + border: 2px solid #D9D9D9; + border-radius: 10px; +} + +br { + margin-top: 16px; +} + +#student-name { + text-align: center; +} \ No newline at end of file diff --git a/client/src/assets/css/visit.css b/client/src/assets/css/visit.css new file mode 100644 index 0000000..6827bf0 --- /dev/null +++ b/client/src/assets/css/visit.css @@ -0,0 +1,77 @@ +#visit { + padding: 64px; + margin: auto; + flex-direction: row; + display: flex; +} + +@media screen and (max-width: 991px) { + #visit { + flex-direction: column; + padding: 32px; + width: 80vw; + height: 80vh; + } + + #student-info { + margin-top: 32px; + } + + #visit-controls { + margin: 8px 0; + } +} + +@media screen and (min-width: 992px) { + #visit { + height: 50%; + width: 60%; + } + + #student-info { + width: 25rem; + } + + #student-visit-reason { + width: 90%; + margin: 16px; + } + + #visit-controls { + margin: 32px; + } +} + +#student-info { + display: flex; + gap: 8px; + flex-direction: column; +} + +#student-visit-reason { + min-height: 10rem; + resize: none; + border-radius: 10px; + border: 2px solid #D9D9D9; + padding: 16px; +} + +#student-info > br { + margin-top: 48px; +} + +#visit-controls { + display: flex; + flex-direction: column; + justify-content: center; + flex: 1; + gap: 8px; +} + +#ta-visit-notes { + min-height: 10rem; + resize: none; + border-radius: 10px; + border: 2px solid #D9D9D9; + padding: 16px; +} \ No newline at end of file diff --git a/client/src/components/Alert.vue b/client/src/components/Alert.vue new file mode 100644 index 0000000..433b313 --- /dev/null +++ b/client/src/components/Alert.vue @@ -0,0 +1,92 @@ + + + + + \ No newline at end of file diff --git a/client/src/components/ConfirmationDialog.vue b/client/src/components/ConfirmationDialog.vue new file mode 100644 index 0000000..fe8f2c2 --- /dev/null +++ b/client/src/components/ConfirmationDialog.vue @@ -0,0 +1,54 @@ + + + + + \ No newline at end of file diff --git a/client/src/components/EditInfo.vue b/client/src/components/EditInfo.vue new file mode 100644 index 0000000..c476399 --- /dev/null +++ b/client/src/components/EditInfo.vue @@ -0,0 +1,55 @@ + + + + + \ No newline at end of file diff --git a/client/src/components/EnrollmentEntry.vue b/client/src/components/EnrollmentEntry.vue new file mode 100644 index 0000000..ea09fd4 --- /dev/null +++ b/client/src/components/EnrollmentEntry.vue @@ -0,0 +1,47 @@ + + + + + \ No newline at end of file diff --git a/client/src/components/QueueEntry.vue b/client/src/components/QueueEntry.vue new file mode 100644 index 0000000..c344791 --- /dev/null +++ b/client/src/components/QueueEntry.vue @@ -0,0 +1,119 @@ + + + + + + \ No newline at end of file diff --git a/client/src/components/Visit.vue b/client/src/components/Visit.vue new file mode 100644 index 0000000..9590c96 --- /dev/null +++ b/client/src/components/Visit.vue @@ -0,0 +1,102 @@ + + + + + \ No newline at end of file diff --git a/client/src/main.ts b/client/src/main.ts new file mode 100644 index 0000000..7d009d9 --- /dev/null +++ b/client/src/main.ts @@ -0,0 +1,25 @@ + +import { createApp } from 'vue' +import App from './App.vue' +import Home from './pages/Home.vue' +import {createRouter, createWebHistory} from "vue-router"; +import DevLogin from "@/pages/DevLogin.vue"; +import Queue from "@/pages/Queue.vue"; +import ManageCourse from "@/pages/ManageCourse.vue"; + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { path: '/', component: Home}, + { path: '/dev-login', component: DevLogin}, + { path: '/queue', component: Queue}, + { path: '/manage', component: ManageCourse} + ] +}) + +const app = createApp(App) + +app.use(router) + +app.mount('#app') + diff --git a/client/src/pages/DevLogin.vue b/client/src/pages/DevLogin.vue new file mode 100644 index 0000000..fbb05f5 --- /dev/null +++ b/client/src/pages/DevLogin.vue @@ -0,0 +1,101 @@ + + + + + \ No newline at end of file diff --git a/client/src/pages/Home.vue b/client/src/pages/Home.vue new file mode 100644 index 0000000..8d3e6aa --- /dev/null +++ b/client/src/pages/Home.vue @@ -0,0 +1,22 @@ + + + \ No newline at end of file diff --git a/client/src/pages/InstructorQueue.vue b/client/src/pages/InstructorQueue.vue new file mode 100644 index 0000000..33eae33 --- /dev/null +++ b/client/src/pages/InstructorQueue.vue @@ -0,0 +1,264 @@ + + + + + \ No newline at end of file diff --git a/client/src/pages/ManageCourse.vue b/client/src/pages/ManageCourse.vue new file mode 100644 index 0000000..8fbd5aa --- /dev/null +++ b/client/src/pages/ManageCourse.vue @@ -0,0 +1,219 @@ + + + + + \ No newline at end of file diff --git a/client/src/pages/Queue.vue b/client/src/pages/Queue.vue new file mode 100644 index 0000000..46e03d8 --- /dev/null +++ b/client/src/pages/Queue.vue @@ -0,0 +1,36 @@ + + + + + \ No newline at end of file diff --git a/client/src/pages/StudentQueue.vue b/client/src/pages/StudentQueue.vue new file mode 100644 index 0000000..257bdf2 --- /dev/null +++ b/client/src/pages/StudentQueue.vue @@ -0,0 +1,176 @@ + + + + + \ No newline at end of file diff --git a/client/text b/client/text deleted file mode 100644 index dd5d01c..0000000 --- a/client/text +++ /dev/null @@ -1 +0,0 @@ -this is where the front end will go.. \ No newline at end of file diff --git a/client/tsconfig.app.json b/client/tsconfig.app.json new file mode 100644 index 0000000..913b8f2 --- /dev/null +++ b/client/tsconfig.app.json @@ -0,0 +1,12 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], + "exclude": ["src/**/__tests__/*"], + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + + "paths": { + "@/*": ["./src/*"] + } + } +} diff --git a/client/tsconfig.json b/client/tsconfig.json new file mode 100644 index 0000000..66b5e57 --- /dev/null +++ b/client/tsconfig.json @@ -0,0 +1,11 @@ +{ + "files": [], + "references": [ + { + "path": "./tsconfig.node.json" + }, + { + "path": "./tsconfig.app.json" + } + ] +} diff --git a/client/tsconfig.node.json b/client/tsconfig.node.json new file mode 100644 index 0000000..822562d --- /dev/null +++ b/client/tsconfig.node.json @@ -0,0 +1,19 @@ +{ + "extends": "@tsconfig/node24/tsconfig.json", + "include": [ + "vite.config.*", + "vitest.config.*", + "cypress.config.*", + "nightwatch.conf.*", + "playwright.config.*", + "eslint.config.*" + ], + "compilerOptions": { + "noEmit": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + + "module": "ESNext", + "moduleResolution": "Bundler", + "types": ["node"] + } +} diff --git a/client/vite.config.ts b/client/vite.config.ts new file mode 100644 index 0000000..04f410c --- /dev/null +++ b/client/vite.config.ts @@ -0,0 +1,27 @@ +import {fileURLToPath, URL} from 'node:url' + +import {defineConfig} from 'vite' +import vue from '@vitejs/plugin-vue' +import vueDevTools from 'vite-plugin-vue-devtools' + +// https://vite.dev/config/ +export default defineConfig({ + server: { + proxy: { + '/api': { + target: 'http://127.0.0.1:5000', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api/, '') + } + } + }, + plugins: [ + vue(), + vueDevTools(), + ], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)) + }, + }, +}) diff --git a/compose.yaml b/compose.yaml index 89b45f3..5787968 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,20 +1,27 @@ services: - api-development: + api: build: context: ./api/ + # env-file: + # - api/.env environment: - API_MODE: "dev" + API_MODE: "prod" + DB: "relational" + SQLITE_DB_PATH: "/app/data/moh.sqlite" volumes: - ./api:/app/api + # - ./client:/app/client + - sqlite-db:/app/data/ ports: - - "8000:8000" - command: flask --app api.server:app run --debug --host=0.0.0.0 --port=8000 + - "8002:5000" + command: flask --app 'api.server:create_app()' run --debug --host=0.0.0.0 --port=5000 - api-production: - extends: - service: api-development - environment: - API_MODE: "prod" + client: + build: + context: ./client/ ports: - - "8000:8000" - command: gunicorn 'api.server:create_app()' + - "3000:3000" + command: npm run dev + +volumes: + sqlite-db: \ No newline at end of file diff --git a/hardware/cardSwipe.py b/hardware/cardSwipe.py deleted file mode 100644 index f2d50d3..0000000 --- a/hardware/cardSwipe.py +++ /dev/null @@ -1,12 +0,0 @@ -import os - - -def cardSwipe(): - while True: - line = input("Swipe card now!!! \n") - try: - name = line.split("/^")[1][14:22] - print(name) - except Exception: - pass -cardSwipe() \ No newline at end of file diff --git a/hardware/card_swipe.py b/hardware/card_swipe.py new file mode 100644 index 0000000..72ebe78 --- /dev/null +++ b/hardware/card_swipe.py @@ -0,0 +1,14 @@ +import os + + +def decode_pn(raw): + try: + return raw.split("/^")[1][14:22] + except Exception: + return "" + + +def cardSwipe(): + while True: + line = input("Swipe card now!!! \n") + print(decode_pn) diff --git a/tests/api/.env b/tests/api/.env new file mode 100644 index 0000000..7279bb1 --- /dev/null +++ b/tests/api/.env @@ -0,0 +1,2 @@ +API_MODE=testing +DB=testing \ No newline at end of file diff --git a/tests/api/test_queue_basics.py b/tests/api/test_queue_basics.py new file mode 100644 index 0000000..cf30723 --- /dev/null +++ b/tests/api/test_queue_basics.py @@ -0,0 +1,64 @@ +import os +import json + +import pytest +from dotenv import load_dotenv + +load_dotenv() + +from api.server import create_app +from api.database.db import db +from api.auth.controller import create_account + +all_account_data = [ + {"username": "jy123", "pn": "123456789"}, + {"username": "lucy5", "pn": "123456784"}, + {"username": "steve", "pn": "987654321"}, +] + + +@pytest.fixture +def accounts(): + accounts = {} # id : account + for account_data in all_account_data: + account_id = create_account(account_data["username"], account_data["pn"]) + account_data["id"] = account_id + accounts[account_id] = account_data + yield accounts + + +@pytest.fixture +def client(): + app = create_app() + app.testing = True + with app.test_client() as client: + yield client + + +def test_first_test(client): + response = client.get("/") + assert response.status_code == 200 + assert ( + response.get_data() + == b"Welcome to the homepage, you are currently in testing mode" + ) + + +def test_that_needs_db(client, accounts): + client.post("/enqueue-ta-override", json={"identifier": "lucy5"}) + client.post("/enqueue-ta-override", json={"identifier": "steve"}) + client.post("/enqueue-ta-override", json={"identifier": "jy123"}) + response = client.post("/help-a-student") + assert response.get_json()["username"] == "lucy5" + response = client.post("/help-a-student") + assert response.get_json()["username"] == "steve" + response = client.post("/help-a-student") + assert response.get_json()["username"] == "jy123" + + response = client.post( + "/enqueue-ta-override", json={"identifier": "fdgihudfhugdhfghdfghbdfbgfnfg"} + ) + assert response.get_json()["message"] == "No student matching provided identifier" + + response = client.post("/help-a-student") + assert response.get_json()["message"] == "The queue is empty" diff --git a/tests/auth/.env b/tests/auth/.env new file mode 100644 index 0000000..8cb26b5 --- /dev/null +++ b/tests/auth/.env @@ -0,0 +1,2 @@ +API_MODE=dev +DB=relational \ No newline at end of file diff --git a/tests/auth/test_permissions.py b/tests/auth/test_permissions.py new file mode 100644 index 0000000..370f6a1 --- /dev/null +++ b/tests/auth/test_permissions.py @@ -0,0 +1,148 @@ +import pytest +import os +from dotenv import load_dotenv + +load_dotenv() + +from api.database.db import db +from api.auth.controller import create_account +from api.roster.controller import min_level +from api.server import create_app + +app = create_app() + +all_account_data = [ + {"username": "jy123", "pn": "123456789"}, + {"username": "lucy5", "pn": "123456784"}, + {"username": "steve", "pn": "987654321"}, + {"username": "jimmy", "pn": "67676767"}, + {"username": "horse", "pn": "154345345"}, +] + + +@pytest.fixture +def test_db(): + db.filename = "testing.sqlite" + db.connect() + yield + db.connection.commit() + if os.path.exists("testing.sqlite"): + os.remove("testing.sqlite") + + +@pytest.fixture +def accounts(): + accounts = {} # id : account + for account_data in all_account_data: + account_id = create_account(account_data["username"], account_data["pn"]) + account_data["id"] = account_id + accounts[account_id] = account_data + + lucy5 = db.lookup_person_number("123456784") + db.add_to_roster(lucy5["user_id"], "ta") + + jimmy = db.lookup_person_number("67676767") + db.add_to_roster(jimmy["user_id"], "instructor") + + horse = db.lookup_person_number("154345345") + db.add_to_roster(horse["user_id"], "admin") + + yield accounts + + +@pytest.fixture +def client(): + app.testing = True + with app.test_client() as client: + yield client + + +@app.route("/test-ta") +@min_level("ta") +def ta_only(): + return "I'm at least TA!", 200 + + +@app.route("/test-instructor") +@min_level("instructor") +def ins_only(): + return "I'm at least an instructor!", 200 + + +@app.route("/test-admin") +@min_level("admin") +def admin_only(): + return "I'm at least an admin!", 200 + + +def test_ta_instructor_admin_permissions(test_db, client, accounts): + client.post("/signup", data={"ubit": "lucy5", "password": "jimmy"}) + + client.post("/login", data={"ubit": "lucy5", "password": "jimmy"}) + + assert client.get("/test-ta").status_code == 200 + assert client.get("/test-instructor").status_code == 403 + assert client.get("/test-admin").status_code == 403 + + client.post("/signup", data={"ubit": "jimmy", "password": "jimmy2"}) + + client.post("/login", data={"ubit": "jimmy", "password": "jimmy2"}) + + assert client.get("/test-ta").status_code == 200 + assert client.get("/test-instructor").status_code == 200 + assert client.get("/test-admin").status_code == 403 + + client.post("/signup", data={"ubit": "horse", "password": "jimmy3"}) + + client.post("/login", data={"ubit": "horse", "password": "jimmy3"}) + + assert client.get("/test-ta").status_code == 200 + assert client.get("/test-instructor").status_code == 200 + assert client.get("/test-admin").status_code == 200 + + +def test_ta_queue_permissions(test_db, client, accounts): + + response = client.post("/signup", data={"ubit": "lucy5", "password": "jimmy"}) + + assert ( + response.get_json().get("message") + != "You are not in the roster. If this is an error, please contact the course staff." + ) + + response = client.post("/login", data={"ubit": "lucy5", "password": "jimmy"}) + + assert response.get_json().get("message") == "Successfully logged in" + + response = client.post("/enqueue-ta-override", json={"identifier": "steve"}) + + assert response.status_code == 200 + + response = client.get("/get-queue") + assert response.status_code == 200 + assert len(response.get_json()) == 1 + + response = client.post("/help-a-student") + assert response.status_code == 200 + + auth_token = client.get_cookie("auth_token") + response = client.post("/signout") + assert response.status_code == 200 + assert client.get_cookie("auth_token") is None + + client.set_cookie("auth_token", auth_token.value, max_age=10000000) + response = client.post("/enqueue-ta-override", json={"identifier": "steve"}) + assert response.status_code == 403 + + assert db.get_authenticated_user(auth_token.value) is None + + response = client.post("/help-a-student") + assert response.status_code == 403 + + client.post("/signup", data={"ubit": "steve", "password": "jimmy2"}) + + response = client.post("/login", data={"ubit": "steve", "password": "jimmy2"}) + assert response.status_code == 200 + + response = client.post("/help-a-student") + assert response.status_code == 403 diff --git a/tests/database/.env b/tests/database/.env new file mode 100644 index 0000000..ef32e2d --- /dev/null +++ b/tests/database/.env @@ -0,0 +1,2 @@ +API_MODE=testing +DB=relational \ No newline at end of file diff --git a/tests/database/test_queue_relational.py b/tests/database/test_queue_relational.py new file mode 100644 index 0000000..c881374 --- /dev/null +++ b/tests/database/test_queue_relational.py @@ -0,0 +1,62 @@ +import os +import json + +import pytest +from dotenv import load_dotenv + +load_dotenv() + +from api.server import create_app +from api.auth.controller import create_account + + +all_account_data = [ + {"username": "jy123", "pn": "123456789"}, + {"username": "lucy5", "pn": "123456784"}, + {"username": "steve", "pn": "987654321"}, +] + + +@pytest.fixture +def accounts(): + accounts = {} # id : account + for account_data in all_account_data: + account_id = create_account(account_data["username"], account_data["pn"]) + account_data["id"] = account_id + accounts[account_id] = account_data + yield accounts + + +@pytest.fixture +def client(): + app = create_app() + app.testing = True + with app.test_client() as client: + yield client + + +def test_that_needs_db(client, accounts): + client.post("/enqueue-ta-override", json={"identifier": "lucy5"}) + client.post("/enqueue-ta-override", json={"identifier": "steve"}) + client.post("/enqueue-ta-override", json={"identifier": "jy123"}) + + response = client.post("/help-a-student") + assert response.get_json()["username"] == "lucy5" + response = client.post("/help-a-student") + assert response.get_json()["username"] == "steve" + + client.post("/enqueue-ta-override", json={"identifier": "lucy5"}) + + response = client.post("/help-a-student") + assert response.get_json()["username"] == "jy123" + + response = client.post("/help-a-student") + assert response.get_json()["username"] == "lucy5" + + response = client.post( + "/enqueue-ta-override", json={"identifier": "fdgihudfhugdhfghdfghbdfbgfnfg"} + ) + assert response.get_json()["message"] == "No student matching provided identifier" + + response = client.post("/help-a-student") + assert response.get_json()["message"] == "The queue is empty" diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 0000000..bcea013 --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1,3 @@ +Flask +pytest +python-dotenv