diff --git a/app/Home.py b/app/Home.py index c5866ad..8f1977b 100644 --- a/app/Home.py +++ b/app/Home.py @@ -20,7 +20,11 @@ """ ) -st.image("app/ballot_initiative_schematic.png", caption="Core process for validating signatures", use_container_width=True) +st.image( + "app/ballot_initiative_schematic.png", + caption="Core process for validating signatures", + use_container_width=True, +) st.markdown( """ @@ -47,6 +51,6 @@ "© 2025 Ballot Initiative Project | " # "Privacy Policy | " "Terms of Use" - "", - unsafe_allow_html=True + "", + unsafe_allow_html=True, ) diff --git a/app/api.py b/app/api.py index e10ff35..efa41f1 100644 --- a/app/api.py +++ b/app/api.py @@ -1,70 +1,38 @@ import os -from enum import Enum -from io import BytesIO -import pandas as pd -from fastapi import FastAPI, Response, UploadFile +from fastapi import FastAPI, Response +from fastapi.middleware.cors import CORSMiddleware from fuzzy_match_helper import create_ocr_matched_df, create_select_voter_records from ocr_helper import create_ocr_df +from routers import file from settings.settings_repo import config from utils import logger -app = FastAPI() +app = FastAPI(root_path="/api") app.state.voter_records_df = None -class UploadFileTypes(str, Enum): - voter_records = "voter_records" - petition_signatures = "petition_signatures" - -@app.post("/upload/{filetype}") -def upload_file(filetype: UploadFileTypes, file: UploadFile, response: Response): - """Uploads file to the server and saves it to a temporary directory. +origins = [ + "http://localhost", + "http://localhost:5173", +] - Args: - filetype (UploadFileTypes): can be voter_records or petition_signatures - """ - logger.info(f"Received file: {file.filename} of type: {filetype}") - - # Validate file type extension - match filetype: - case UploadFileTypes.petition_signatures: - if not file.filename.endswith(".pdf"): - response.status_code = 400 - return {"error": "Invalid file type. Only pdf files are allowed."} - with open(os.path.join('temp', 'ballot.pdf'), "wb") as buffer: - buffer.write(file.file.read()) - logger.info("File saved to temporary directory: temp/ballot.pdf") - case UploadFileTypes.voter_records: - if not file.filename.endswith(".csv"): - response.status_code = 400 - return {"error": "Invalid file type. Only .csv files are allowed."} - contents = file.file.read() - buffer = BytesIO(contents) - df = pd.read_csv(buffer, dtype=str) - - # Create necessary columns - df['Full Name'] = df["First_Name"] + ' ' + df['Last_Name'] - df['Full Address'] = df["Street_Number"] + " " + df["Street_Name"] + " " + \ - df["Street_Type"] + " " + df["Street_Dir_Suffix"] +app.add_middleware( + CORSMiddleware, + allow_origins=origins, + allow_credentials=True, + allow_methods=["*"], # Allows all HTTP methods + allow_headers=["*"], # Allows all headers +) - required_columns = ["First_Name", "Last_Name", "Street_Number", - "Street_Name", "Street_Type", "Street_Dir_Suffix"] - app.state.voter_records_df = df - - # Verify required columns - if not all(col in df.columns for col in required_columns): - response.status_code = 400 - return {"error": "Missing required columns in voter records file."} +app.include_router(file.router) - return {"filename": file.filename} - -@app.post("/ocr") +@app.post("/ocr", tags=["OCR"]) def ocr(response: Response): """ Triggers the OCR process on the uploaded petition signatures PDF file. """ - if not os.path.exists('temp/ballot.pdf'): + if not os.path.exists("temp/ballot.pdf"): logger.error("No PDF file found for petition signatures") response.status_code = 400 return {"error": "No PDF file found for petition signatures"} @@ -75,35 +43,17 @@ def ocr(response: Response): logger.info("Starting OCR processing...") # Process files if in processing state logger.info("Converting PDF to images...") - - ocr_df = create_ocr_df(filedir='temp', - filename='ballot.pdf') - + + ocr_df = create_ocr_df(filedir="temp", filename="ballot.pdf") + logger.info("Compiling Voter Record Data...") select_voter_records = create_select_voter_records(app.state.voter_records_df) - + logger.info("Matching petition signatures to voter records...") ocr_matched_df = create_ocr_matched_df( - ocr_df, - select_voter_records, - threshold=config['BASE_THRESHOLD'] + ocr_df, select_voter_records, threshold=config["BASE_THRESHOLD"] ) - response.headers['Content-Disposition'] = 'attachment; filename=ocr_matched.csv' - response.headers['Content-Type'] = 'text/csv' - return ocr_matched_df.to_csv() - -@app.delete("/clear") -def clear_all_files(): - """ - Delete all files - """ - app.state.voter_records_df = None - if os.path.exists('temp/ballot.pdf'): - os.remove('temp/ballot.pdf') - logger.info("Deleted all files") - else: - logger.warning("No files to delete") - return {"message": "All files deleted"} - \ No newline at end of file + response.headers["Content-Type"] = "application/json" + return {"data": ocr_matched_df.to_dict(orient="records"), "stats": {}} diff --git a/app/fuzzy_match_helper.py b/app/fuzzy_match_helper.py index 15f833b..04fb0df 100644 --- a/app/fuzzy_match_helper.py +++ b/app/fuzzy_match_helper.py @@ -13,12 +13,12 @@ from datetime import datetime # local environment storage -repo_name = 'Ballot-Initiative' +repo_name = "Ballot-Initiative" REPODIR = os.getcwd() -load_dotenv(os.path.join(REPODIR, '.env'), override=True) +load_dotenv(os.path.join(REPODIR, ".env"), override=True) # load config -with open('config.json', 'r') as f: +with open("config.json", "r") as f: config = json.load(f) # Set up logging after imports @@ -27,7 +27,7 @@ os.makedirs(log_directory) # Create a logger -logger = logging.getLogger('fuzzy_matching') +logger = logging.getLogger("fuzzy_matching") logger.setLevel(logging.INFO) # Create handlers @@ -36,7 +36,7 @@ console_handler = logging.StreamHandler() # Create formatters and add it to handlers -log_format = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') +log_format = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") file_handler.setFormatter(log_format) console_handler.setFormatter(log_format) @@ -48,35 +48,44 @@ ## MATCHING FUNCTIONS ### -def create_select_voter_records(voter_records : pd.DataFrame) -> pd.DataFrame: + +def create_select_voter_records(voter_records: pd.DataFrame) -> pd.DataFrame: """ Creates a simplified DataFrame with full names and addresses from voter records. - + Args: voter_records (pd.DataFrame): DataFrame containing voter information with columns for first name, last name, and address components. - + Returns: pd.DataFrame: DataFrame with 'Full Name' and 'Full Address' columns """ # Create full name by combining first and last names name_components = ["First_Name", "Last_Name"] - voter_records[name_components] = voter_records[name_components].fillna('') - voter_records["Full Name"] = voter_records[name_components].astype(str).agg(" ".join, axis=1) + voter_records[name_components] = voter_records[name_components].fillna("") + voter_records["Full Name"] = ( + voter_records[name_components].astype(str).agg(" ".join, axis=1) + ) # Create full address by combining address components - address_components = ["Street_Number", "Street_Name", "Street_Type", "Street_Dir_Suffix"] - voter_records[address_components] = voter_records[address_components].fillna('') - voter_records["Full Address"] = voter_records[address_components].astype(str).agg(" ".join, axis=1) + address_components = [ + "Street_Number", + "Street_Name", + "Street_Type", + "Street_Dir_Suffix", + ] + voter_records[address_components] = voter_records[address_components].fillna("") + voter_records["Full Address"] = ( + voter_records[address_components].astype(str).agg(" ".join, axis=1) + ) # Return only the columns we need return voter_records[["Full Name", "Full Address"]] -def score_fuzzy_match_slim(ocr_result : str, - comparison_list : List[str], - scorer_=fuzz.ratio, - limit_=10) -> List[Tuple[str, int, int]]: +def score_fuzzy_match_slim( + ocr_result: str, comparison_list: List[str], scorer_=fuzz.ratio, limit_=10 +) -> List[Tuple[str, int, int]]: """ Scores the fuzzy match between the OCR result and the comparison list. @@ -85,32 +94,33 @@ def score_fuzzy_match_slim(ocr_result : str, comparison_list (List[str]): The list of strings to compare against. scorer_ (function): The scorer function to use. limit_ (int): The number of top matches to return. - + Returns: List[Tuple[str, int, int]]: The list of top matches with their scores and indices. """ logger.debug(f"Starting fuzzy matching for: {ocr_result[:30]}...") - + # Convert to numpy array for faster operations comparison_array = np.array(comparison_list) - + # Vectorize the scorer function vectorized_scorer = np.vectorize(lambda x: scorer_(ocr_result, x)) - + # Calculate all scores at once scores = vectorized_scorer(comparison_array) - + # Get top N indices top_indices = np.argpartition(scores, -limit_)[-limit_:] top_indices = top_indices[np.argsort(scores[top_indices])[::-1]] - + results = [(comparison_array[i], scores[i], i) for i in top_indices] logger.debug(f"Top match score: {results[0][1]}, Match: {results[0][0][:30]}...") return results -def get_matched_name_address(ocr_name : str, - ocr_address : str, - select_voter_records : pd.DataFrame) -> List[Tuple[str, str, float, int]]: + +def get_matched_name_address( + ocr_name: str, ocr_address: str, select_voter_records: pd.DataFrame +) -> List[Tuple[str, str, float, int]]: """ Optimized name and address matching @@ -118,43 +128,50 @@ def get_matched_name_address(ocr_name : str, ocr_name (str): The OCR result for the name. ocr_address (str): The OCR result for the address. select_voter_records (pd.DataFrame): The DataFrame containing voter records. - + Returns: List[Tuple[str, str, float, int]]: The list of top matches with their scores and indices. """ logger.debug(f"Matching - Name: {ocr_name[:30]}... Address: {ocr_address[:30]}...") - + # Get name matches - name_matches = score_fuzzy_match_slim(ocr_name, select_voter_records["Full Name"].values) + name_matches = score_fuzzy_match_slim( + ocr_name, select_voter_records["Full Name"].values + ) logger.debug(f"Best name match score: {name_matches[0][1]}") - + # Get address matches matched_indices = [x[2] for x in name_matches] relevant_addresses = select_voter_records["Full Address"].values[matched_indices] address_matches = score_fuzzy_match_slim(ocr_address, relevant_addresses) logger.debug(f"Best address match score: {address_matches[0][1]}") - + # Calculate harmonic means name_scores = np.array([x[1] for x in name_matches]) addr_scores = np.array([x[1] for x in address_matches]) harmonic_means = 2 * name_scores * addr_scores / (name_scores + addr_scores) - + # Create and sort results - results = list(zip( - [x[0] for x in name_matches], - [x[0] for x in address_matches], - harmonic_means, - matched_indices - )) + results = list( + zip( + [x[0] for x in name_matches], + [x[0] for x in address_matches], + harmonic_means, + matched_indices, + ) + ) results = sorted(results, key=lambda x: x[2], reverse=True) - + logger.debug(f"Best combined match score: {results[0][2]}") return results -def create_ocr_matched_df(ocr_df : pd.DataFrame, - select_voter_records : pd.DataFrame, - threshold : float = config['BASE_THRESHOLD'], - st_bar = None) -> pd.DataFrame: + +def create_ocr_matched_df( + ocr_df: pd.DataFrame, + select_voter_records: pd.DataFrame, + threshold: float = config["BASE_THRESHOLD"], + st_bar=None, +) -> pd.DataFrame: """ Creates a DataFrame with matched name and address. @@ -163,59 +180,80 @@ def create_ocr_matched_df(ocr_df : pd.DataFrame, select_voter_records (pd.DataFrame): The DataFrame containing voter records. threshold (float): The threshold for matching. st_bar (st.progress): The progress bar to display. - + Returns: pd.DataFrame: The DataFrame with matched name and address. """ - logger.info(f"Starting matching process for {len(ocr_df)} records with threshold {threshold}") - + logger.info( + f"Starting matching process for {len(ocr_df)} records with threshold {threshold}" + ) + # Process in batches for better memory management batch_size = 1000 results = [] - + for batch_start in tqdm(range(0, len(ocr_df), batch_size)): - batch = ocr_df.iloc[batch_start:batch_start + batch_size] - logger.info(f"Processing batch {batch_start//batch_size + 1}, rows {batch_start} to {min(batch_start + batch_size, len(ocr_df))}") - + batch = ocr_df.iloc[batch_start : batch_start + batch_size] + logger.info( + f"Processing batch {batch_start // batch_size + 1}, rows {batch_start} to {min(batch_start + batch_size, len(ocr_df))}" + ) + # Process batch in parallel with ThreadPoolExecutor() as executor: - batch_results = list(executor.map( - lambda row: get_matched_name_address( - row["OCR Name"], - row["OCR Address"], - select_voter_records - ), - [row for _, row in batch.iterrows()] - )) - + batch_results = list( + executor.map( + lambda row: get_matched_name_address( + row["OCR Name"], row["OCR Address"], select_voter_records + ), + [row for _, row in batch.iterrows()], + ) + ) + # Extract best matches batch_matches = [(res[0][0], res[0][1], res[0][2]) for res in batch_results] results.extend(batch_matches) - + # Log batch statistics batch_scores = [match[2] for match in batch_matches] - logger.info(f"Batch statistics - Avg score: {np.mean(batch_scores):.2f}, " - f"Min score: {min(batch_scores):.2f}, " - f"Max score: {max(batch_scores):.2f}, " - f"Valid matches: {sum(score >= threshold for score in batch_scores)}") + logger.info( + f"Batch statistics - Avg score: {np.mean(batch_scores):.2f}, " + f"Min score: {min(batch_scores):.2f}, " + f"Max score: {max(batch_scores):.2f}, " + f"Valid matches: {sum(score >= threshold for score in batch_scores)}" + ) if st_bar: - st_bar.progress(batch_start / len(ocr_df), text=f"Processing batch {batch_start} out of {len(ocr_df)//batch_size+1} batches") - + st_bar.progress( + batch_start / len(ocr_df), + text=f"Processing batch {batch_start} out of {len(ocr_df) // batch_size + 1} batches", + ) + logger.info("Creating final DataFrame") - match_df = pd.DataFrame(results, columns=["Matched Name", "Matched Address", "Match Score"]) + match_df = pd.DataFrame( + results, columns=["Matched Name", "Matched Address", "Match Score"] + ) result_df = pd.concat([ocr_df, match_df], axis=1) result_df["Valid"] = result_df["Match Score"] >= threshold - + # Reorder columns column_order = [ - "OCR Name", "OCR Address", "Matched Name", "Matched Address", - "Date", "Match Score", "Valid", "Page Number", "Row Number", "Filename" + "OCR Name", + "OCR Address", + "Matched Name", + "Matched Address", + "Date", + "Match Score", + "Valid", + "Page Number", + "Row Number", + "Filename", ] - + # Log final statistics total_valid = result_df["Valid"].sum() - logger.info(f"Matching complete - Total records: {len(result_df)}, " - f"Valid matches: {total_valid} ({total_valid/len(result_df)*100:.1f}%)") - - return result_df[column_order] \ No newline at end of file + logger.info( + f"Matching complete - Total records: {len(result_df)}, " + f"Valid matches: {total_valid} ({total_valid / len(result_df) * 100:.1f}%)" + ) + + return result_df[column_order] diff --git a/app/ocr_helper.py b/app/ocr_helper.py index 4753992..5229fa7 100644 --- a/app/ocr_helper.py +++ b/app/ocr_helper.py @@ -187,7 +187,7 @@ def collect_ocr_data( for i in tqdm(range(0, total_pages, batch_size)): batch = encoded_images[i : i + batch_size] logger.info( - f"Processing batch {i//batch_size + 1} of {(total_pages + batch_size - 1)//batch_size}" + f"Processing batch {i // batch_size + 1} of {(total_pages + batch_size - 1) // batch_size}" ) if st_bar: @@ -208,7 +208,7 @@ def collect_ocr_data( full_data.extend(ocr_data) logger.info( - f"Batch {i//batch_size + 1} complete. Processed {len(batch_results)} pages" + f"Batch {i // batch_size + 1} complete. Processed {len(batch_results)} pages" ) logger.info(f"OCR collection complete. Total entries: {len(full_data)}") diff --git a/app/pages/1_Petition_Validation.py b/app/pages/1_Petition_Validation.py index a1f87f0..f5150ac 100644 --- a/app/pages/1_Petition_Validation.py +++ b/app/pages/1_Petition_Validation.py @@ -19,17 +19,17 @@ # logger.add("data/logs/benchmark_logs.log", rotation="10 MB", level="INFO") # loading environmental variables -load_dotenv('.env', override=True) +load_dotenv(".env", override=True) # name of uploaded pdf file UPLOADED_FILENAME = "ballot.pdf" # name of repo -repo_name = 'Ballot-Initiative' +repo_name = "Ballot-Initiative" REPODIR = os.getcwd().split(repo_name)[0] + repo_name # load config -with open('config.json', 'r') as f: +with open("config.json", "r") as f: config = json.load(f) @@ -37,33 +37,37 @@ # DELETE TEMPORARY FILES ## + def wipe_all_temp_files(): """Wipes all temporary files and resets session state""" try: # Clear temp directory - temp_files = [file.path for file in os.scandir('./temp') if file.name != '.gitkeep'] + temp_files = [ + file.path for file in os.scandir("./temp") if file.name != ".gitkeep" + ] for file in temp_files: os.remove(file) - - # Reset session state for data and files - if 'voter_records_df' in st.session_state: + + # Reset session state for data and files + if "voter_records_df" in st.session_state: del st.session_state.voter_records_df - if 'processed_results' in st.session_state: + if "processed_results" in st.session_state: del st.session_state.processed_results - if 'signature_file' in st.session_state: + if "signature_file" in st.session_state: del st.session_state.signature_file - if 'voter_records_file' in st.session_state: + if "voter_records_file" in st.session_state: del st.session_state.voter_records_file - + # Instead of directly modifying file uploader states, # we'll use a flag to trigger a rerun st.session_state.clear_files = True - + return True except Exception as e: st.error(f"Error clearing files: {str(e)}") return False + ## # STREAMLIT APPLICATION ## @@ -71,7 +75,8 @@ def wipe_all_temp_files(): st.set_page_config(page_title="Petition Validation", page_icon="🗳️") # Custom CSS for better styling -st.markdown(""" +st.markdown( + """ -""", unsafe_allow_html=True) +""", + unsafe_allow_html=True, +) # Application Header st.header("Petition Validation") st.caption("Automated signature verification for ballot initiatives") -st.markdown("
", unsafe_allow_html=True) +st.markdown( + "
", + unsafe_allow_html=True, +) start_time = None # Add these session state initializations near the top with other session state setup -if 'is_processing_complete' not in st.session_state: +if "is_processing_complete" not in st.session_state: st.session_state.is_processing_complete = False -if 'current_progress' not in st.session_state: +if "current_progress" not in st.session_state: st.session_state.current_progress = 0 -if 'progress_text' not in st.session_state: +if "progress_text" not in st.session_state: st.session_state.progress_text = "" + @st.cache_data def load_voter_records(voter_records_file): """Cache and process voter records file""" df = pd.read_csv(voter_records_file, dtype=str) - + # Create necessary columns - df['Full Name'] = df["First_Name"] + ' ' + df['Last_Name'] - df['Full Address'] = df["Street_Number"] + " " + df["Street_Name"] + " " + \ - df["Street_Type"] + " " + df["Street_Dir_Suffix"] + df["Full Name"] = df["First_Name"] + " " + df["Last_Name"] + df["Full Address"] = ( + df["Street_Number"] + + " " + + df["Street_Name"] + + " " + + df["Street_Type"] + + " " + + df["Street_Dir_Suffix"] + ) return df + @st.cache_data def load_signatures(signatures_file): """Cache and process signatures PDF file""" pdf_bytes = signatures_file.read() # Create temp directory if it doesn't exist - os.makedirs('temp', exist_ok=True) - + os.makedirs("temp", exist_ok=True) + # Save PDF to temp directory - pdf_path = os.path.join('temp', UPLOADED_FILENAME) - with open(pdf_path, 'wb') as f: + pdf_path = os.path.join("temp", UPLOADED_FILENAME) + with open(pdf_path, "wb") as f: f.write(pdf_bytes) - + # Convert first page for preview using PyMuPDF doc = fitz.open(stream=pdf_bytes, filetype="pdf") first_page = doc[0] @@ -143,13 +162,12 @@ def load_signatures(signatures_file): preview_image = Image.frombytes("RGB", [pix.width, pix.height], pix.samples) num_pages = len(doc) doc.close() - + return pdf_bytes, preview_image, num_pages # Sidebar with improved styling with st.sidebar: - st.markdown("### 📝 Instructions") with st.expander("1️⃣ Upload Voter Records", expanded=False): @@ -164,7 +182,7 @@ def load_signatures(signatures_file): - Street_Dir_Suffix - *Example: Download a sample of fake voter records [here](https://github.com/Civic-Tech-Ballot-Inititiave/Ballot-Initiative/blob/main/sample_data/fake_voter_records.csv).* """) - + with st.expander("2️⃣ Upload Signatures", expanded=False): st.markdown(""" - PDF format only @@ -172,7 +190,7 @@ def load_signatures(signatures_file): - One signature per line - *Example: Download a sample of fake signed petitions [here](https://github.com/Civic-Tech-Ballot-Inititiave/Ballot-Initiative/blob/main/sample_data/fake_signed_petitions_1-10.pdf).* """) - + with st.expander("3️⃣ Process & Review", expanded=False): st.markdown(""" - Click 'Process Files' @@ -184,17 +202,17 @@ def load_signatures(signatures_file): with st.expander("4️⃣ Clear Files", expanded=False): st.markdown(""" - Clear temporary files when done - """) + """) # Initialize session state for data storage -if 'voter_records_df' not in st.session_state: +if "voter_records_df" not in st.session_state: st.session_state.voter_records_df = None -if 'processed_results' not in st.session_state: +if "processed_results" not in st.session_state: st.session_state.processed_results = None # Add this near the top of your app, after session state initialization -if 'clear_files' in st.session_state and st.session_state.clear_files: +if "clear_files" in st.session_state and st.session_state.clear_files: st.session_state.clear_files = False st.rerun() @@ -203,7 +221,7 @@ def load_signatures(signatures_file): st.markdown("### Upload Files") col1, col2 = st.columns(2, gap="large") -if 'voter_records_file' not in st.session_state: +if "voter_records_file" not in st.session_state: st.session_state.voter_records_file = None with col1: @@ -213,44 +231,52 @@ def load_signatures(signatures_file): Required columns: `First_Name`, `Last_Name`, `Street_Number`, `Street_Name`, `Street_Type`, `Street_Dir_Suffix` """) - + voter_records = st.file_uploader( "Choose CSV file", - type=['csv'], + type=["csv"], key="voter_records", help="Upload a CSV file containing voter registration data", - on_change=lambda: setattr(st.session_state, 'voter_records_file', st.session_state.voter_records) + on_change=lambda: setattr( + st.session_state, "voter_records_file", st.session_state.voter_records + ), ) - + # Restore file from session state if available if voter_records is None and st.session_state.voter_records_file is not None: - voter_records = st.session_state.voter_records_file + voter_records = st.session_state.voter_records_file # Process voter records when uploaded if voter_records is not None: try: df = load_voter_records(voter_records) - required_columns = ["First_Name", "Last_Name", "Street_Number", - "Street_Name", "Street_Type", "Street_Dir_Suffix"] - + required_columns = [ + "First_Name", + "Last_Name", + "Street_Number", + "Street_Name", + "Street_Type", + "Street_Dir_Suffix", + ] + # Verify required columns if not all(col in df.columns for col in required_columns): st.error("Missing required columns in CSV file") else: st.session_state.voter_records_df = df st.success("✅ Voter records loaded successfully!") - + # Display preview with st.expander("Preview Voter Records"): st.dataframe(df.head(), use_container_width=True) st.caption(f"Total records: {len(df):,}") - + except Exception as e: st.error(f"Error loading voter records: {str(e)}") # Initialize session state for file uploads -if 'signature_file' not in st.session_state: +if "signature_file" not in st.session_state: st.session_state.signature_file = None with col2: @@ -259,15 +285,17 @@ def load_signatures(signatures_file): Upload your PDF file containing petition pages with signatures. Each file will be cropped to focus on the section where the signatures are located. Ensure these sections have the printed name and address of the voter. """) - + signatures = st.file_uploader( "Choose PDF file", - type=['pdf'], + type=["pdf"], key="signatures", help="Upload a PDF containing scanned signature pages", - on_change=lambda: setattr(st.session_state, 'signature_file', st.session_state.signatures) + on_change=lambda: setattr( + st.session_state, "signature_file", st.session_state.signatures + ), ) - + # Restore file from session state if available if signatures is None and st.session_state.signature_file is not None: signatures = st.session_state.signature_file @@ -277,13 +305,13 @@ def load_signatures(signatures_file): try: pdf_bytes, preview_image, num_pages = load_signatures(signatures) st.success("✅ Petition signatures loaded successfully!") - + # Display preview with st.expander("Preview Petition Signatures"): st.markdown("**Preview of First Page:**") st.image(preview_image, width=300) st.caption(f"Total pages: {num_pages}") - + except Exception as e: st.error(f"Error processing ballot signatures: {str(e)}") @@ -292,23 +320,30 @@ def load_signatures(signatures_file): # Process Files Button st.markdown("### Process Files") -col1, col2, col3 = st.columns([1,2,1]) +col1, col2, col3 = st.columns([1, 2, 1]) with col2: if st.session_state.voter_records_df is None or signatures is None: st.warning("⚠️ Please upload both files to proceed") else: # Initialize cancel state if not exists - if 'processing_cancelled' not in st.session_state: + if "processing_cancelled" not in st.session_state: st.session_state.processing_cancelled = False - + # Show either process or cancel button - if not st.session_state.get('is_processing', False): - process_button = st.button("🚀 Process Files", type="primary", use_container_width=True) + if not st.session_state.get("is_processing", False): + process_button = st.button( + "🚀 Process Files", type="primary", use_container_width=True + ) if process_button: st.session_state.is_processing = True st.rerun() else: - if st.button("⚠️ Cancel Processing", type="secondary", use_container_width=True, help="Note: Moving to the 'Home' page will restart processing."): + if st.button( + "⚠️ Cancel Processing", + type="secondary", + use_container_width=True, + help="Note: Moving to the 'Home' page will restart processing.", + ): st.caption("Processing cancelled by user") st.session_state.processing_cancelled = True st.session_state.is_processing = False @@ -317,15 +352,21 @@ def load_signatures(signatures_file): st.session_state.current_progress = 0 st.session_state.progress_text = "" st.rerun() - + # Process files if in processing state - if st.session_state.get('is_processing', False) and not st.session_state.is_processing_complete: + if ( + st.session_state.get("is_processing", False) + and not st.session_state.is_processing_complete + ): start_time = time.time() with st.spinner("Processing signatures for validation..."): try: - matching_bar = st.progress(st.session_state.current_progress, - text=st.session_state.progress_text or "Loading PDF of signed petitions...") - + matching_bar = st.progress( + st.session_state.current_progress, + text=st.session_state.progress_text + or "Loading PDF of signed petitions...", + ) + # Check for cancellation if st.session_state.processing_cancelled: st.warning("Processing cancelled by user") @@ -339,46 +380,65 @@ def load_signatures(signatures_file): # Update progress state as processing continues st.session_state.current_progress = 0.0 st.session_state.progress_text = "Converting PDF to images..." - matching_bar.progress(st.session_state.current_progress, text=st.session_state.progress_text) - - pdf_full_path = glob.glob(os.path.join('temp', UPLOADED_FILENAME))[0] + matching_bar.progress( + st.session_state.current_progress, + text=st.session_state.progress_text, + ) + + pdf_full_path = glob.glob(os.path.join("temp", UPLOADED_FILENAME))[ + 0 + ] if st.session_state.processing_cancelled: raise InterruptedError("Processing cancelled by user") st.session_state.current_progress = 0.3 - matching_bar.progress(st.session_state.current_progress, text=st.session_state.progress_text) - - ocr_df = create_ocr_df(filedir='temp', - filename=UPLOADED_FILENAME, - st_bar=matching_bar) - + matching_bar.progress( + st.session_state.current_progress, + text=st.session_state.progress_text, + ) + + ocr_df = create_ocr_df( + filedir="temp", filename=UPLOADED_FILENAME, st_bar=matching_bar + ) + if st.session_state.processing_cancelled: raise InterruptedError("Processing cancelled by user") st.session_state.current_progress = 0.9 st.session_state.progress_text = "Compiling Voter Record Data" - matching_bar.progress(st.session_state.current_progress, text=st.session_state.progress_text) + matching_bar.progress( + st.session_state.current_progress, + text=st.session_state.progress_text, + ) + + select_voter_records = create_select_voter_records( + st.session_state.voter_records_df + ) - select_voter_records = create_select_voter_records(st.session_state.voter_records_df) - if st.session_state.processing_cancelled: raise InterruptedError("Processing cancelled by user") st.session_state.current_progress = 0.95 - st.session_state.progress_text = "Matching petition signatures to voter records..." - matching_bar.progress(st.session_state.current_progress, text=st.session_state.progress_text) + st.session_state.progress_text = ( + "Matching petition signatures to voter records..." + ) + matching_bar.progress( + st.session_state.current_progress, + text=st.session_state.progress_text, + ) ocr_matched_df = create_ocr_matched_df( - ocr_df, - select_voter_records, - threshold=config['BASE_THRESHOLD'] + ocr_df, select_voter_records, threshold=config["BASE_THRESHOLD"] ) - + st.session_state.current_progress = 1.0 st.session_state.progress_text = "Complete!" - matching_bar.progress(st.session_state.current_progress, text=st.session_state.progress_text) - + matching_bar.progress( + st.session_state.current_progress, + text=st.session_state.progress_text, + ) + st.session_state.processed_results = ocr_matched_df matching_bar.empty() st.session_state.is_processing = False @@ -387,7 +447,7 @@ def load_signatures(signatures_file): st.session_state.current_progress = 0 st.session_state.progress_text = "" st.rerun() - + except InterruptedError as e: st.warning(str(e)) matching_bar.empty() @@ -402,28 +462,28 @@ def load_signatures(signatures_file): st.session_state.current_progress = 0 st.session_state.progress_text = "" + @st.cache_data def convert_df(df): # IMPORTANT: Cache the conversion to prevent computation on every rerun return df.to_csv().encode("utf-8") + # Display results if available -if st.session_state.get('processed_results') is not None: +if st.session_state.get("processed_results") is not None: st.markdown("### Results") - + # Update Valid column based on threshold results_df = st.session_state.processed_results.copy() - results_df["Valid"] = results_df["Match Score"] >= config['BASE_THRESHOLD'] - + results_df["Valid"] = results_df["Match Score"] >= config["BASE_THRESHOLD"] + tabs = st.tabs(["📊 Data Table", "📈 Statistics"]) if st.session_state.processing_time: st.caption(f"Processing time: {st.session_state.processing_time:.2f} seconds") with tabs[0]: edited_df = st.data_editor( - results_df, - use_container_width=True, - hide_index=True + results_df, use_container_width=True, hide_index=True ) # Update the download button to use the modified dataframe @@ -434,8 +494,8 @@ def convert_df(df): data=csv, file_name="validated_petition_signatures.csv", mime="text/csv", - ) - + ) + with tabs[1]: # results_df = st.session_state.processed_results col1, col2, col3 = st.columns(3) @@ -443,32 +503,32 @@ def convert_df(df): ui.metric_card( title="Total Records", content=len(results_df), - description="Total signatures processed" + description="Total signatures processed", ) with col2: ui.metric_card( title="Valid Matches", content=sum(results_df["Valid"]), - description="Signatures verified" + description="Signatures verified", ) with col3: ui.metric_card( title="Percentage Valid", - content=f"{(sum(results_df['Valid'])/len(results_df))*100:.1f}%", - description="Percentage of signatures verified" + content=f"{(sum(results_df['Valid']) / len(results_df)) * 100:.1f}%", + description="Percentage of signatures verified", ) # Add this near the bottom of your app, before the footer st.markdown("---") st.markdown("### Maintenance") -col1, col2, col3 = st.columns([1,2,1]) +col1, col2, col3 = st.columns([1, 2, 1]) with col2: if st.button("🗑️ Clear All Files", type="secondary", use_container_width=True): with st.spinner("Clearing temporary files..."): if wipe_all_temp_files(): st.session_state.clear_files = True st.success("✅ All temporary files cleared!") - st.info("Please refresh the page to start over.") + st.info("Please refresh the page to start over.") # Footer st.markdown("---") @@ -477,6 +537,6 @@ def convert_df(df): "© 2024 Ballot Initiative Project | " "Privacy Policy | " "Terms of Use" - "", - unsafe_allow_html=True + "", + unsafe_allow_html=True, ) diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routers/file.py b/app/routers/file.py new file mode 100644 index 0000000..7ca7c85 --- /dev/null +++ b/app/routers/file.py @@ -0,0 +1,108 @@ +import os +from enum import Enum +from io import BytesIO + +from fastapi.responses import FileResponse +import pandas as pd +from fastapi import APIRouter, Request, Response, UploadFile +from utils import logger + +router = APIRouter(tags=["File Upload"]) + + +class UploadFileTypes(str, Enum): + voter_records = "voter_records" + petition_signatures = "petition_signatures" + + +@router.delete("/clear") +def clear_all_files(request: Request): + """ + Delete all files + """ + request.state.voter_records_df = None + if os.path.exists("temp/ballot.pdf"): + os.remove("temp/ballot.pdf") + logger.info("Deleted all files") + else: + logger.warning("No files to delete") + return {"message": "All files deleted"} + + +@router.post("/upload/{filetype}") +def upload_file( + filetype: UploadFileTypes, file: UploadFile, response: Response, request: Request +): + """Uploads file to the server and saves it to a temporary directory. + + Args: + filetype (UploadFileTypes): can be voter_records or petition_signatures + """ + logger.info(f"Received file: {file.filename} of type: {filetype}") + + # Validate file type extension + match filetype: + case UploadFileTypes.petition_signatures: + if not file.filename.endswith(".pdf"): + response.status_code = 400 + return {"error": "Invalid file type. Only pdf files are allowed."} + with open(os.path.join("temp", "ballot.pdf"), "wb") as buffer: + buffer.write(file.file.read()) + logger.info("File saved to temporary directory: temp/ballot.pdf") + case UploadFileTypes.voter_records: + if not file.filename.endswith(".csv"): + response.status_code = 400 + return {"error": "Invalid file type. Only .csv files are allowed."} + contents = file.file.read() + buffer = BytesIO(contents) + df = pd.read_csv(buffer, dtype=str) + + # Create necessary columns + df["Full Name"] = df["First_Name"] + " " + df["Last_Name"] + df["Full Address"] = ( + df["Street_Number"] + + " " + + df["Street_Name"] + + " " + + df["Street_Type"] + + " " + + df["Street_Dir_Suffix"] + ) + + required_columns = [ + "First_Name", + "Last_Name", + "Street_Number", + "Street_Name", + "Street_Type", + "Street_Dir_Suffix", + ] + request.app.state.voter_records_df = df + + # Verify required columns + if not all(col in df.columns for col in required_columns): + response.status_code = 400 + return {"error": "Missing required columns in voter records file."} + + return {"filename": file.filename} + + +@router.get("/upload/{filetype}") +def get_uploaded_file(filetype: UploadFileTypes, request: Request): + """Returns the uploaded file. + + Args: + filetype (UploadFileTypes): can be voter_records or petition_signatures + """ + logger.info(f"Retrieving file of type: {filetype}") + + # Validate file type + match filetype: + case UploadFileTypes.petition_signatures: + if not os.path.exists("temp/ballot.pdf"): + return {"error": "No PDF file found for petition signatures"} + return FileResponse("temp/ballot.pdf") + case UploadFileTypes.voter_records: + if request.app.state.voter_records_df is None: + return {"error": "No voter records file found"} + return request.app.state.voter_records_df.to_csv(index=False) diff --git a/app/utils/app_logger.py b/app/utils/app_logger.py index 916c4e9..a052a74 100644 --- a/app/utils/app_logger.py +++ b/app/utils/app_logger.py @@ -2,18 +2,16 @@ import logging # Configure the default logging level -structlog.configure( - wrapper_class=structlog.make_filtering_bound_logger(logging.INFO) -) +structlog.configure(wrapper_class=structlog.make_filtering_bound_logger(logging.INFO)) logger = structlog.get_logger() + def enable_debug_logging(enable_debugging: bool): if enable_debugging: structlog.configure( - wrapper_class=structlog.make_filtering_bound_logger(logging.DEBUG) + wrapper_class=structlog.make_filtering_bound_logger(logging.DEBUG) ) - global logger - logger = structlog.get_logger() \ No newline at end of file + logger = structlog.get_logger() diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e61c4de..f4e1e81 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,23 +9,31 @@ "version": "0.0.0", "dependencies": { "@radix-ui/react-accordion": "^1.2.4", + "@radix-ui/react-checkbox": "^1.2.3", "@radix-ui/react-dialog": "^1.1.7", "@radix-ui/react-dropdown-menu": "^2.1.7", "@radix-ui/react-label": "^2.1.4", "@radix-ui/react-separator": "^1.1.3", "@radix-ui/react-slot": "^1.2.0", + "@radix-ui/react-tabs": "^1.1.9", "@radix-ui/react-tooltip": "^1.2.0", "@tailwindcss/vite": "^4.1.3", + "@tanstack/react-query": "^5.74.4", "@tanstack/react-router": "^1.116.0", "@tanstack/react-router-devtools": "^1.116.0", + "@tanstack/react-table": "^8.21.3", + "axios": "^1.9.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "filesize": "^10.1.6", "lucide-react": "^0.487.0", + "next-themes": "^0.4.6", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^10.1.0", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", + "sonner": "^2.0.3", "tailwind-merge": "^3.2.0", "tailwindcss": "^4.1.3", "tw-animate-css": "^1.2.5" @@ -1184,6 +1192,98 @@ } } }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.2.3.tgz", + "integrity": "sha512-pHVzDYsnaDmBlAuwim45y3soIN8H4R7KbkSVirGhXO+R/kO2OLCe0eucUEbddaTcdMHHdzcIGHtZSMSQlA+apw==", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.0", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-presence": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.4.tgz", + "integrity": "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-primitive": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.0.tgz", + "integrity": "sha512-/J/FhLdK0zVcILOwt5g+dH4KnkonCtkVJsa2G6JmvbbtZfBEI1gMsO3QMjseL4F/SwfAMt1Vc/0XKYKq+xJ1sw==", + "dependencies": { + "@radix-ui/react-slot": "1.2.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collapsible": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.4.tgz", @@ -1676,6 +1776,153 @@ } } }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.9.tgz", + "integrity": "sha512-KIjtwciYvquiW/wAFkELZCVnaNLBsYNhTNcvl+zfMAbMhRkcvNuCLXDDd22L0j7tagpzVh/QwbFpwAATg7ILPw==", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.0", + "@radix-ui/react-roving-focus": "1.1.7", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-collection": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.4.tgz", + "integrity": "sha512-cv4vSf7HttqXilDnAnvINd53OTl1/bjUYVZrkFnA7nwmY9Ob2POUy0WY0sfqBAe1s5FyKsyceQlqiEGPYNTadg==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.0", + "@radix-ui/react-slot": "1.2.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-presence": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.4.tgz", + "integrity": "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.0.tgz", + "integrity": "sha512-/J/FhLdK0zVcILOwt5g+dH4KnkonCtkVJsa2G6JmvbbtZfBEI1gMsO3QMjseL4F/SwfAMt1Vc/0XKYKq+xJ1sw==", + "dependencies": { + "@radix-ui/react-slot": "1.2.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.7.tgz", + "integrity": "sha512-C6oAg451/fQT3EGbWHbCQjYTtbyjNO1uzQgMzwyivcHT3GKNEmu1q3UuREhN+HzHAVtv3ivMVK08QlC+PkYw9Q==", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collection": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.0", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-tooltip": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.0.tgz", @@ -1740,6 +1987,23 @@ } } }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-escape-keydown": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", @@ -1771,6 +2035,20 @@ } } }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-rect": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", @@ -2328,6 +2606,30 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tanstack/query-core": { + "version": "5.74.4", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.74.4.tgz", + "integrity": "sha512-YuG0A0+3i9b2Gfo9fkmNnkUWh5+5cFhWBN0pJAHkHilTx6A0nv8kepkk4T4GRt4e5ahbtFj2eTtkiPcVU1xO4A==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.74.4", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.74.4.tgz", + "integrity": "sha512-mAbxw60d4ffQ4qmRYfkO1xzRBPUEf/72Dgo3qqea0J66nIKuDTLEqQt0ku++SDFlMGMnB6uKDnEG1xD/TDse4Q==", + "dependencies": { + "@tanstack/query-core": "5.74.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@tanstack/react-router": { "version": "1.116.0", "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.116.0.tgz", @@ -2390,6 +2692,25 @@ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@tanstack/react-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, "node_modules/@tanstack/router-core": { "version": "1.115.3", "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.115.3.tgz", @@ -2545,6 +2866,18 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/virtual-file-routes": { "version": "1.115.0", "resolved": "https://registry.npmjs.org/@tanstack/virtual-file-routes/-/virtual-file-routes-1.115.0.tgz", @@ -3027,6 +3360,21 @@ "node": ">=10" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/axios": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz", + "integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/babel-dead-code-elimination": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/babel-dead-code-elimination/-/babel-dead-code-elimination-1.0.10.tgz", @@ -3124,6 +3472,18 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -3293,6 +3653,17 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/comma-separated-tokens": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", @@ -3373,6 +3744,14 @@ "dev": true, "license": "MIT" }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -3416,6 +3795,19 @@ "node": ">=0.3.1" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.136", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.136.tgz", @@ -3447,6 +3839,47 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.25.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.2.tgz", @@ -3776,6 +4209,14 @@ "node": ">=16.0.0" } }, + "node_modules/filesize": { + "version": "10.1.6", + "resolved": "https://registry.npmjs.org/filesize/-/filesize-10.1.6.tgz", + "integrity": "sha512-sJslQKU2uM33qH5nqewAwVB2QgR6w1aMNsYUp3aN5rMRyXEwJGmZvaWzeJFNTOXWlHQyBFCWrdj3fV/fsTOX8w==", + "engines": { + "node": ">= 10.4.0" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -3827,6 +4268,39 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", + "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -3841,6 +4315,14 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -3851,6 +4333,29 @@ "node": ">=6.9.0" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", @@ -3859,6 +4364,18 @@ "node": ">=6" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-tsconfig": { "version": "4.10.0", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.0.tgz", @@ -3905,6 +4422,17 @@ "csstype": "^3.0.10" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -3928,6 +4456,42 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/hast-util-from-parse5": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", @@ -4624,6 +5188,14 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/mdast-util-find-and-replace": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", @@ -5449,6 +6021,25 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -5493,6 +6084,15 @@ "dev": true, "license": "MIT" }, + "node_modules/next-themes": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", + "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==", + "peerDependencies": { + "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/node-releases": { "version": "2.0.19", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", @@ -5707,6 +6307,11 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -6117,6 +6722,15 @@ "seroval-plugins": "^1.1.0" } }, + "node_modules/sonner": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.3.tgz", + "integrity": "sha512-njQ4Hht92m0sMqqHVDL32V2Oun9W1+PHO9NDv9FHfJjT3JT22IG4Jpo3FPQy+mouRKCXFWO+r67v6MrHX2zeIA==", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 548dd78..9ede5de 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,23 +11,31 @@ }, "dependencies": { "@radix-ui/react-accordion": "^1.2.4", + "@radix-ui/react-checkbox": "^1.2.3", "@radix-ui/react-dialog": "^1.1.7", "@radix-ui/react-dropdown-menu": "^2.1.7", "@radix-ui/react-label": "^2.1.4", "@radix-ui/react-separator": "^1.1.3", "@radix-ui/react-slot": "^1.2.0", + "@radix-ui/react-tabs": "^1.1.9", "@radix-ui/react-tooltip": "^1.2.0", "@tailwindcss/vite": "^4.1.3", + "@tanstack/react-query": "^5.74.4", "@tanstack/react-router": "^1.116.0", "@tanstack/react-router-devtools": "^1.116.0", + "@tanstack/react-table": "^8.21.3", + "axios": "^1.9.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "filesize": "^10.1.6", "lucide-react": "^0.487.0", + "next-themes": "^0.4.6", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^10.1.0", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", + "sonner": "^2.0.3", "tailwind-merge": "^3.2.0", "tailwindcss": "^4.1.3", "tw-animate-css": "^1.2.5" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1a6b661..27e4838 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,11 +1,11 @@ import "./App.css"; -import Layout from "@/Layout"; import { ThemeProvider } from "./components/theme-provider"; import { RouterProvider, createRouter } from "@tanstack/react-router"; // Import the generated route tree import { routeTree } from "./routeTree.gen"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; // Create a new router instance const router = createRouter({ routeTree, defaultPendingMinMs: 0 }); @@ -16,11 +16,16 @@ declare module "@tanstack/react-router" { router: typeof router; } } + +const queryClient = new QueryClient(); + function App() { return ( - - - + + + + + ); } diff --git a/frontend/src/Layout.tsx b/frontend/src/Layout.tsx index 603053d..6e47f5e 100644 --- a/frontend/src/Layout.tsx +++ b/frontend/src/Layout.tsx @@ -4,6 +4,7 @@ import { ModeToggle } from "./components/theme-provider/mode-toggle"; import Markdown from "react-markdown"; import rehypeRaw from "rehype-raw"; import { useLocation } from "@tanstack/react-router"; +import { Toaster } from "@/components/ui/sonner"; export default function Layout({ children }: { children?: React.ReactNode }) { const location = useLocation(); @@ -18,6 +19,7 @@ export default function Layout({ children }: { children?: React.ReactNode }) { {children} +