refactor: Rename TrueCV to RealCV throughout codebase
- Renamed all directories (TrueCV.* -> RealCV.*) - Renamed all project files (.csproj) - Renamed solution file (TrueCV.sln -> RealCV.sln) - Updated all namespaces in C# and Razor files - Updated project references - Updated CSS variable names 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
21
src/RealCV.Web/Components/App.razor
Normal file
21
src/RealCV.Web/Components/App.razor
Normal file
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<base href="/" />
|
||||
<link rel="stylesheet" href="bootstrap/bootstrap.min.css" />
|
||||
<link rel="stylesheet" href="app.css" />
|
||||
<link rel="stylesheet" href="RealCV.Web.styles.css" />
|
||||
<link rel="icon" type="image/png" href="favicon.png" />
|
||||
<HeadOutlet @rendermode="InteractiveServer" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<Routes @rendermode="InteractiveServer" />
|
||||
<script src="_framework/blazor.web.js"></script>
|
||||
<script src="js/app.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
91
src/RealCV.Web/Components/Layout/MainLayout.razor
Normal file
91
src/RealCV.Web/Components/Layout/MainLayout.razor
Normal file
@@ -0,0 +1,91 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<div class="d-flex flex-column min-vh-100">
|
||||
<nav class="navbar navbar-expand-lg navbar-light shadow-sm" style="background-color: var(--truecv-bg-surface);">
|
||||
<div class="container">
|
||||
<a class="navbar-brand fw-bold" href="/">
|
||||
<img src="images/RealCV_Logo.png" alt="RealCV" style="height: 95px;" />
|
||||
</a>
|
||||
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav me-auto">
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/" Match="NavLinkMatch.All">
|
||||
Home
|
||||
</NavLink>
|
||||
</li>
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/dashboard">
|
||||
Dashboard
|
||||
</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/check">
|
||||
New Check
|
||||
</NavLink>
|
||||
</li>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
</ul>
|
||||
|
||||
<ul class="navbar-nav">
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-person-circle me-1" viewBox="0 0 16 16">
|
||||
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
|
||||
<path fill-rule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1z"/>
|
||||
</svg>
|
||||
@context.User.Identity?.Name
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li>
|
||||
<form action="/account/logout" method="post">
|
||||
<AntiforgeryToken />
|
||||
<button type="submit" class="dropdown-item">Logout</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/account/login">
|
||||
Login
|
||||
</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link btn btn-outline-primary ms-2 px-3" href="/account/register">
|
||||
Register
|
||||
</NavLink>
|
||||
</li>
|
||||
</NotAuthorized>
|
||||
</AuthorizeView>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="flex-grow-1">
|
||||
@Body
|
||||
</main>
|
||||
|
||||
<footer class="text-light py-4 mt-auto" style="background-color: var(--truecv-footer-bg);">
|
||||
<div class="container text-center">
|
||||
<p class="mb-0">© @DateTime.Now.Year RealCV. All rights reserved.</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<div id="blazor-error-ui" class="alert alert-danger fixed-bottom m-3" style="display: none;">
|
||||
An unhandled error has occurred.
|
||||
<a href="" class="alert-link reload">Reload</a>
|
||||
<button type="button" class="btn-close float-end dismiss" aria-label="Close"></button>
|
||||
</div>
|
||||
18
src/RealCV.Web/Components/Layout/MainLayout.razor.css
Normal file
18
src/RealCV.Web/Components/Layout/MainLayout.razor.css
Normal file
@@ -0,0 +1,18 @@
|
||||
#blazor-error-ui {
|
||||
background: lightyellow;
|
||||
bottom: 0;
|
||||
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
|
||||
display: none;
|
||||
left: 0;
|
||||
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
#blazor-error-ui .dismiss {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 0.5rem;
|
||||
}
|
||||
147
src/RealCV.Web/Components/Pages/Account/Login.razor
Normal file
147
src/RealCV.Web/Components/Pages/Account/Login.razor
Normal file
@@ -0,0 +1,147 @@
|
||||
@page "/account/login"
|
||||
@using RealCV.Web.Components.Layout
|
||||
@layout MainLayout
|
||||
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using RealCV.Infrastructure.Identity
|
||||
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<PageTitle>Login - RealCV</PageTitle>
|
||||
|
||||
<div class="auth-container">
|
||||
<!-- Left side - Form -->
|
||||
<div class="auth-form-side">
|
||||
<div class="auth-form-wrapper">
|
||||
<div class="text-center mb-4">
|
||||
<a href="/">
|
||||
<img src="images/RealCV_Logo.png" alt="RealCV" class="auth-logo" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<h1 class="auth-title">Welcome back</h1>
|
||||
<p class="auth-subtitle">Sign in to continue verifying CVs</p>
|
||||
|
||||
@if (!string.IsNullOrEmpty(_errorMessage))
|
||||
{
|
||||
<div class="alert alert-danger d-flex align-items-center" role="alert">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="me-2 flex-shrink-0" viewBox="0 0 16 16">
|
||||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
|
||||
<path d="M7.002 11a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 4.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995z"/>
|
||||
</svg>
|
||||
<span>@_errorMessage</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<form method="post" action="/account/perform-login">
|
||||
<AntiforgeryToken />
|
||||
<input type="hidden" name="returnUrl" value="@ReturnUrl" />
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">Email address</label>
|
||||
<div class="input-group-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M0 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V4Zm2-1a1 1 0 0 0-1 1v.217l7 4.2 7-4.2V4a1 1 0 0 0-1-1H2Zm13 2.383-4.708 2.825L15 11.105V5.383Zm-.034 6.876-5.64-3.471L8 9.583l-1.326-.795-5.64 3.47A1 1 0 0 0 2 13h12a1 1 0 0 0 .966-.741ZM1 11.105l4.708-2.897L1 5.383v5.722Z"/>
|
||||
</svg>
|
||||
<input id="email" name="email" type="email" class="form-control form-control-lg"
|
||||
placeholder="name@example.com" autocomplete="email" required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<div class="input-group-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z"/>
|
||||
</svg>
|
||||
<input id="password" name="password" type="password" class="form-control form-control-lg"
|
||||
placeholder="Enter your password" autocomplete="current-password" required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 d-flex justify-content-between align-items-center">
|
||||
<div class="form-check">
|
||||
<input id="rememberMe" name="rememberMe" type="checkbox" class="form-check-input" value="true" />
|
||||
<label class="form-check-label" for="rememberMe">
|
||||
Remember me
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid mb-4">
|
||||
<button type="submit" class="btn btn-primary btn-lg">
|
||||
<span>Sign In</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="ms-2" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M1 8a.5.5 0 0 1 .5-.5h11.793l-3.147-3.146a.5.5 0 0 1 .708-.708l4 4a.5.5 0 0 1 0 .708l-4 4a.5.5 0 0 1-.708-.708L13.293 8.5H1.5A.5.5 0 0 1 1 8z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="auth-divider">
|
||||
<span>New to RealCV?</span>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<a href="/account/register" class="btn btn-outline-secondary btn-lg w-100">
|
||||
Create an account
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right side - Branding -->
|
||||
<div class="auth-brand-side">
|
||||
<div class="auth-brand-content">
|
||||
<div class="auth-brand-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M10.854 7.146a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708 0l-1.5-1.5a.5.5 0 1 1 .708-.708L7.5 9.793l2.646-2.647a.5.5 0 0 1 .708 0z"/>
|
||||
<path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/>
|
||||
<path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="auth-brand-title">CV Verification Made Simple</h2>
|
||||
<p class="auth-brand-text">
|
||||
Upload any CV and get instant AI-powered verification with detailed analysis of qualifications, experience, and company legitimacy.
|
||||
</p>
|
||||
|
||||
<div class="auth-stats">
|
||||
<div class="auth-stat">
|
||||
<div class="auth-stat-value">10K+</div>
|
||||
<div class="auth-stat-label">CVs Verified</div>
|
||||
</div>
|
||||
<div class="auth-stat">
|
||||
<div class="auth-stat-value">98%</div>
|
||||
<div class="auth-stat-label">Accuracy Rate</div>
|
||||
</div>
|
||||
<div class="auth-stat">
|
||||
<div class="auth-stat-value"><30s</div>
|
||||
<div class="auth-stat-label">Average Time</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="auth-testimonial">
|
||||
<blockquote>
|
||||
"RealCV has transformed our hiring process. We catch discrepancies we would have missed before."
|
||||
</blockquote>
|
||||
<cite>- HR Director, Tech Company</cite>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private string? _errorMessage;
|
||||
|
||||
[SupplyParameterFromQuery]
|
||||
public string? ReturnUrl { get; set; }
|
||||
|
||||
[SupplyParameterFromQuery(Name = "error")]
|
||||
public string? Error { get; set; }
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_errorMessage = Error;
|
||||
}
|
||||
}
|
||||
232
src/RealCV.Web/Components/Pages/Account/Register.razor
Normal file
232
src/RealCV.Web/Components/Pages/Account/Register.razor
Normal file
@@ -0,0 +1,232 @@
|
||||
@page "/account/register"
|
||||
@using RealCV.Web.Components.Layout
|
||||
@layout MainLayout
|
||||
@rendermode InteractiveServer
|
||||
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using RealCV.Infrastructure.Identity
|
||||
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<PageTitle>Register - RealCV</PageTitle>
|
||||
|
||||
<div class="auth-container">
|
||||
<!-- Left side - Form -->
|
||||
<div class="auth-form-side">
|
||||
<div class="auth-form-wrapper">
|
||||
<div class="text-center mb-4">
|
||||
<a href="/">
|
||||
<img src="images/RealCV_Logo.png" alt="RealCV" class="auth-logo" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<h1 class="auth-title">Create account</h1>
|
||||
<p class="auth-subtitle">Start verifying UK-based CVs in minutes</p>
|
||||
|
||||
@if (!string.IsNullOrEmpty(_errorMessage))
|
||||
{
|
||||
<div class="alert alert-danger d-flex align-items-center" role="alert">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="me-2 flex-shrink-0" viewBox="0 0 16 16">
|
||||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
|
||||
<path d="M7.002 11a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 4.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995z"/>
|
||||
</svg>
|
||||
<span>@_errorMessage</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<EditForm Model="_model" OnValidSubmit="HandleRegister" FormName="register">
|
||||
<DataAnnotationsValidator />
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">Email address</label>
|
||||
<div class="input-group-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M0 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V4Zm2-1a1 1 0 0 0-1 1v.217l7 4.2 7-4.2V4a1 1 0 0 0-1-1H2Zm13 2.383-4.708 2.825L15 11.105V5.383Zm-.034 6.876-5.64-3.471L8 9.583l-1.326-.795-5.64 3.47A1 1 0 0 0 2 13h12a1 1 0 0 0 .966-.741ZM1 11.105l4.708-2.897L1 5.383v5.722Z"/>
|
||||
</svg>
|
||||
<InputText id="email" class="form-control form-control-lg" @bind-Value="_model.Email"
|
||||
placeholder="name@example.com" autocomplete="email" />
|
||||
</div>
|
||||
<ValidationMessage For="() => _model.Email" class="text-danger small mt-1" />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<div class="input-group-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z"/>
|
||||
</svg>
|
||||
<InputText id="password" type="password" class="form-control form-control-lg"
|
||||
@bind-Value="_model.Password" placeholder="Create a password" autocomplete="new-password" />
|
||||
</div>
|
||||
<ValidationMessage For="() => _model.Password" class="text-danger small mt-1" />
|
||||
<div class="form-text">At least 12 characters with uppercase, lowercase, number, and symbol.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="confirmPassword" class="form-label">Confirm password</label>
|
||||
<div class="input-group-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M10.854 7.146a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708 0l-1.5-1.5a.5.5 0 1 1 .708-.708L7.5 9.793l2.646-2.647a.5.5 0 0 1 .708 0z"/>
|
||||
<path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z"/>
|
||||
</svg>
|
||||
<InputText id="confirmPassword" type="password" class="form-control form-control-lg"
|
||||
@bind-Value="_model.ConfirmPassword" placeholder="Confirm your password" autocomplete="new-password" />
|
||||
</div>
|
||||
<ValidationMessage For="() => _model.ConfirmPassword" class="text-danger small mt-1" />
|
||||
</div>
|
||||
|
||||
<div class="d-grid mb-4">
|
||||
<button type="submit" class="btn btn-primary btn-lg" disabled="@_isLoading">
|
||||
@if (_isLoading)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
|
||||
<span>Creating account...</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Create Account</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="ms-2" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M1 8a.5.5 0 0 1 .5-.5h11.793l-3.147-3.146a.5.5 0 0 1 .708-.708l4 4a.5.5 0 0 1 0 .708l-4 4a.5.5 0 0 1-.708-.708L13.293 8.5H1.5A.5.5 0 0 1 1 8z"/>
|
||||
</svg>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</EditForm>
|
||||
|
||||
<p class="text-center text-muted small mb-4">
|
||||
By creating an account, you agree to our
|
||||
<a href="#" class="text-decoration-none">Terms of Service</a>
|
||||
and
|
||||
<a href="#" class="text-decoration-none">Privacy Policy</a>
|
||||
</p>
|
||||
|
||||
<div class="auth-divider">
|
||||
<span>Already have an account?</span>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<a href="/account/login" class="btn btn-outline-secondary btn-lg w-100">
|
||||
Sign in
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right side - Branding -->
|
||||
<div class="auth-brand-side">
|
||||
<div class="auth-brand-content">
|
||||
<div class="auth-brand-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M8 4.754a3.246 3.246 0 1 0 0 6.492 3.246 3.246 0 0 0 0-6.492zM5.754 8a2.246 2.246 0 1 1 4.492 0 2.246 2.246 0 0 1-4.492 0z"/>
|
||||
<path d="M9.796 1.343c-.527-1.79-3.065-1.79-3.592 0l-.094.319a.873.873 0 0 1-1.255.52l-.292-.16c-1.64-.892-3.433.902-2.54 2.541l.159.292a.873.873 0 0 1-.52 1.255l-.319.094c-1.79.527-1.79 3.065 0 3.592l.319.094a.873.873 0 0 1 .52 1.255l-.16.292c-.892 1.64.901 3.434 2.541 2.54l.292-.159a.873.873 0 0 1 1.255.52l.094.319c.527 1.79 3.065 1.79 3.592 0l.094-.319a.873.873 0 0 1 1.255-.52l.292.16c1.64.893 3.434-.902 2.54-2.541l-.159-.292a.873.873 0 0 1 .52-1.255l.319-.094c1.79-.527 1.79-3.065 0-3.592l-.319-.094a.873.873 0 0 1-.52-1.255l.16-.292c.893-1.64-.902-3.433-2.541-2.54l-.292.159a.873.873 0 0 1-1.255-.52l-.094-.319zm-2.633.283c.246-.835 1.428-.835 1.674 0l.094.319a1.873 1.873 0 0 0 2.693 1.115l.291-.16c.764-.415 1.6.42 1.184 1.185l-.159.292a1.873 1.873 0 0 0 1.116 2.692l.318.094c.835.246.835 1.428 0 1.674l-.319.094a1.873 1.873 0 0 0-1.115 2.693l.16.291c.415.764-.42 1.6-1.185 1.184l-.291-.159a1.873 1.873 0 0 0-2.693 1.116l-.094.318c-.246.835-1.428.835-1.674 0l-.094-.319a1.873 1.873 0 0 0-2.692-1.115l-.292.16c-.764.415-1.6-.42-1.184-1.185l.159-.291A1.873 1.873 0 0 0 1.945 8.93l-.319-.094c-.835-.246-.835-1.428 0-1.674l.319-.094A1.873 1.873 0 0 0 3.06 4.377l-.16-.292c-.415-.764.42-1.6 1.185-1.184l.292.159a1.873 1.873 0 0 0 2.692-1.115l.094-.319z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="auth-brand-title">Start Your Free Trial</h2>
|
||||
<p class="auth-brand-text">
|
||||
Get 3 free CV verifications to experience the power of AI-driven credential analysis.
|
||||
</p>
|
||||
|
||||
<div class="auth-features">
|
||||
<div class="auth-feature">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M12.354 4.354a.5.5 0 0 0-.708-.708L5 10.293 1.854 7.146a.5.5 0 1 0-.708.708l3.5 3.5a.5.5 0 0 0 .708 0l7-7z"/>
|
||||
</svg>
|
||||
<span>AI-powered verification in seconds</span>
|
||||
</div>
|
||||
<div class="auth-feature">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M12.354 4.354a.5.5 0 0 0-.708-.708L5 10.293 1.854 7.146a.5.5 0 1 0-.708.708l3.5 3.5a.5.5 0 0 0 .708 0l7-7z"/>
|
||||
</svg>
|
||||
<span>Company legitimacy checks</span>
|
||||
</div>
|
||||
<div class="auth-feature">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M12.354 4.354a.5.5 0 0 0-.708-.708L5 10.293 1.854 7.146a.5.5 0 1 0-.708.708l3.5 3.5a.5.5 0 0 0 .708 0l7-7z"/>
|
||||
</svg>
|
||||
<span>Qualification & timeline analysis</span>
|
||||
</div>
|
||||
<div class="auth-feature">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M12.354 4.354a.5.5 0 0 0-.708-.708L5 10.293 1.854 7.146a.5.5 0 1 0-.708.708l3.5 3.5a.5.5 0 0 0 .708 0l7-7z"/>
|
||||
</svg>
|
||||
<span>Detailed PDF reports</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="auth-testimonial">
|
||||
<blockquote>
|
||||
"We reduced bad hires by 40% in the first quarter using RealCV."
|
||||
</blockquote>
|
||||
<cite>- Recruitment Manager, Financial Services</cite>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private RegisterModel _model = new();
|
||||
private bool _isLoading;
|
||||
private string? _errorMessage;
|
||||
|
||||
private async Task HandleRegister()
|
||||
{
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
|
||||
try
|
||||
{
|
||||
if (_model.Password != _model.ConfirmPassword)
|
||||
{
|
||||
_errorMessage = "Passwords do not match.";
|
||||
_isLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var user = new ApplicationUser
|
||||
{
|
||||
UserName = _model.Email,
|
||||
Email = _model.Email,
|
||||
Plan = Domain.Enums.UserPlan.Free,
|
||||
ChecksUsedThisMonth = 0
|
||||
};
|
||||
|
||||
var result = await UserManager.CreateAsync(user, _model.Password);
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
await SignInManager.SignInAsync(user, isPersistent: false);
|
||||
NavigationManager.NavigateTo("/dashboard", forceLoad: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
var errors = result.Errors.Select(e => e.Description);
|
||||
_errorMessage = string.Join(" ", errors);
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
_errorMessage = "An unexpected error occurred. Please try again.";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RegisterModel
|
||||
{
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Email is required")]
|
||||
[System.ComponentModel.DataAnnotations.EmailAddress(ErrorMessage = "Invalid email format")]
|
||||
public string Email { get; set; } = string.Empty;
|
||||
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Password is required")]
|
||||
[System.ComponentModel.DataAnnotations.MinLength(12, ErrorMessage = "Password must be at least 12 characters")]
|
||||
public string Password { get; set; } = string.Empty;
|
||||
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Please confirm your password")]
|
||||
[System.ComponentModel.DataAnnotations.Compare(nameof(Password), ErrorMessage = "Passwords do not match")]
|
||||
public string ConfirmPassword { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
551
src/RealCV.Web/Components/Pages/Check.razor
Normal file
551
src/RealCV.Web/Components/Pages/Check.razor
Normal file
@@ -0,0 +1,551 @@
|
||||
@page "/check"
|
||||
@attribute [Authorize]
|
||||
@rendermode InteractiveServer
|
||||
|
||||
@inject ICVCheckService CVCheckService
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
||||
@inject ILogger<Check> Logger
|
||||
|
||||
<PageTitle>Upload CVs - RealCV</PageTitle>
|
||||
|
||||
<div class="container py-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<div class="text-center mb-4">
|
||||
<h1 class="fw-bold">Upload CVs for Verification</h1>
|
||||
<p class="text-muted lead">Upload one or more CVs in PDF or DOCX format to begin the verification process</p>
|
||||
<div class="alert alert-info d-inline-flex align-items-center py-2 px-3 mb-0" style="font-size: 0.875rem;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="me-2 flex-shrink-0" viewBox="0 0 16 16">
|
||||
<path d="M8 16s6-5.686 6-10A6 6 0 0 0 2 6c0 4.314 6 10 6 10zm0-7a3 3 0 1 1 0-6 3 3 0 0 1 0 6z"/>
|
||||
</svg>
|
||||
<span>For UK employment history</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(_errorMessage))
|
||||
{
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
@_errorMessage
|
||||
<button type="button" class="btn-close" @onclick="() => _errorMessage = null" aria-label="Close"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="card border-0 shadow">
|
||||
<div class="card-body p-4">
|
||||
@if (_isUploading)
|
||||
{
|
||||
<div class="text-center py-5">
|
||||
<div class="spinner-border text-primary mb-3" role="status" style="width: 3rem; height: 3rem;">
|
||||
<span class="visually-hidden">Uploading...</span>
|
||||
</div>
|
||||
<h5 class="mb-2">Uploading CVs...</h5>
|
||||
<p class="text-muted">Processing @_currentFileIndex of @_totalFiles files</p>
|
||||
<div class="progress" style="height: 8px;">
|
||||
<div class="progress-bar progress-bar-striped progress-bar-animated"
|
||||
role="progressbar"
|
||||
style="width: @(_uploadProgress)%"
|
||||
aria-valuenow="@_uploadProgress"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100">
|
||||
</div>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(_currentFileName))
|
||||
{
|
||||
<small class="text-muted mt-2 d-block">@_currentFileName</small>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="upload-area @(_isDragging ? "dragging" : "")"
|
||||
@ondragenter="HandleDragEnter"
|
||||
@ondragleave="HandleDragLeave"
|
||||
@ondragover:preventDefault
|
||||
@ondrop="HandleDrop"
|
||||
@ondrop:preventDefault>
|
||||
|
||||
<InputFile OnChange="HandleFileSelected"
|
||||
accept=".pdf,.docx,.json"
|
||||
multiple
|
||||
class="d-none"
|
||||
id="fileInput" />
|
||||
|
||||
<label for="fileInput" class="d-block text-center py-4 cursor-pointer">
|
||||
<div class="upload-icon mb-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" fill="currentColor" class="bi bi-cloud-arrow-up" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M7.646 5.146a.5.5 0 0 1 .708 0l2 2a.5.5 0 0 1-.708.708L8.5 6.707V10.5a.5.5 0 0 1-1 0V6.707L6.354 7.854a.5.5 0 1 1-.708-.708l2-2z"/>
|
||||
<path d="M4.406 3.342A5.53 5.53 0 0 1 8 2c2.69 0 4.923 2 5.166 4.579C14.758 6.804 16 8.137 16 9.773 16 11.569 14.502 13 12.687 13H3.781C1.708 13 0 11.366 0 9.318c0-1.763 1.266-3.223 2.942-3.593.143-.863.698-1.723 1.464-2.383zm.653.757c-.757.653-1.153 1.44-1.153 2.056v.448l-.445.049C2.064 6.805 1 7.952 1 9.318 1 10.785 2.23 12 3.781 12h8.906C13.98 12 15 10.988 15 9.773c0-1.216-1.02-2.228-2.313-2.228h-.5v-.5C12.188 4.825 10.328 3 8 3a4.53 4.53 0 0 0-2.941 1.1z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h4 class="fw-bold mb-1">Drop your CVs here</h4>
|
||||
<p class="text-muted mb-3 small">Drag and drop files, or click to browse</p>
|
||||
<div class="d-flex justify-content-center gap-2 flex-wrap">
|
||||
<span class="badge bg-light text-dark border px-2 py-1">PDF</span>
|
||||
<span class="badge bg-light text-dark border px-2 py-1">DOCX</span>
|
||||
<span class="badge bg-light text-dark border px-2 py-1">Max 10MB</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@if (_isBuffering)
|
||||
{
|
||||
<div class="mt-4 text-center">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Loading files...</span>
|
||||
</div>
|
||||
<p class="mt-2 text-muted">Reading files...</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (_selectedFiles.Count > 0 && !_isBuffering)
|
||||
{
|
||||
<div class="mt-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h6 class="mb-0">Selected Files (@_selectedFiles.Count)</h6>
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="ClearAllFiles">
|
||||
Clear All
|
||||
</button>
|
||||
</div>
|
||||
<div class="file-list">
|
||||
@foreach (var file in _selectedFiles)
|
||||
{
|
||||
<div class="file-list-item">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="file-type-icon me-3 @GetFileTypeClass(file.Name)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2zM9.5 3A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5v2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<p class="mb-0 fw-medium">@file.Name</p>
|
||||
<small class="text-muted">@FormatFileSize(file.Size)</small>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => RemoveFile(file)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M2.146 2.854a.5.5 0 1 1 .708-.708L8 7.293l5.146-5.147a.5.5 0 0 1 .708.708L8.707 8l5.147 5.146a.5.5 0 0 1-.708.708L8 8.707l-5.146 5.147a.5.5 0 0 1-.708-.708L7.293 8 2.146 2.854Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-center">
|
||||
<button class="btn btn-primary btn-lg px-5" @onclick="UploadFiles">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-upload me-2" viewBox="0 0 16 16">
|
||||
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/>
|
||||
<path d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708l3-3z"/>
|
||||
</svg>
|
||||
Start Verification (@_selectedFiles.Count @(_selectedFiles.Count == 1 ? "CV" : "CVs"))
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Security & Process Info -->
|
||||
<div class="security-info mt-4">
|
||||
<div class="d-flex justify-content-center flex-wrap gap-4">
|
||||
<div class="security-badge">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M5.338 1.59a61.44 61.44 0 0 0-2.837.856.481.481 0 0 0-.328.39c-.554 4.157.726 7.19 2.253 9.188a10.725 10.725 0 0 0 2.287 2.233c.346.244.652.42.893.533.12.057.218.095.293.118a.55.55 0 0 0 .101.025.615.615 0 0 0 .1-.025c.076-.023.174-.061.294-.118.24-.113.547-.29.893-.533a10.726 10.726 0 0 0 2.287-2.233c1.527-1.997 2.807-5.031 2.253-9.188a.48.48 0 0 0-.328-.39c-.651-.213-1.75-.56-2.837-.855C9.552 1.29 8.531 1.067 8 1.067c-.53 0-1.552.223-2.662.524zM5.072.56C6.157.265 7.31 0 8 0s1.843.265 2.928.56c1.11.3 2.229.655 2.887.87a1.54 1.54 0 0 1 1.044 1.262c.596 4.477-.787 7.795-2.465 9.99a11.775 11.775 0 0 1-2.517 2.453 7.159 7.159 0 0 1-1.048.625c-.28.132-.581.24-.829.24s-.548-.108-.829-.24a7.158 7.158 0 0 1-1.048-.625 11.777 11.777 0 0 1-2.517-2.453C1.928 10.487.545 7.169 1.141 2.692A1.54 1.54 0 0 1 2.185 1.43 62.456 62.456 0 0 1 5.072.56z"/>
|
||||
<path d="M10.854 5.146a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708 0l-1.5-1.5a.5.5 0 1 1 .708-.708L7.5 7.793l2.646-2.647a.5.5 0 0 1 .708 0z"/>
|
||||
</svg>
|
||||
<span>256-bit Encryption</span>
|
||||
</div>
|
||||
<div class="security-badge">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z"/>
|
||||
</svg>
|
||||
<span>Secure Storage</span>
|
||||
</div>
|
||||
<div class="security-badge">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M8 4.754a3.246 3.246 0 1 0 0 6.492 3.246 3.246 0 0 0 0-6.492zM5.754 8a2.246 2.246 0 1 1 4.492 0 2.246 2.246 0 0 1-4.492 0z"/>
|
||||
<path d="M9.796 1.343c-.527-1.79-3.065-1.79-3.592 0l-.094.319a.873.873 0 0 1-1.255.52l-.292-.16c-1.64-.892-3.433.902-2.54 2.541l.159.292a.873.873 0 0 1-.52 1.255l-.319.094c-1.79.527-1.79 3.065 0 3.592l.319.094a.873.873 0 0 1 .52 1.255l-.16.292c-.892 1.64.901 3.434 2.541 2.54l.292-.159a.873.873 0 0 1 1.255.52l.094.319c.527 1.79 3.065 1.79 3.592 0l.094-.319a.873.873 0 0 1 1.255-.52l.292.16c1.64.893 3.434-.902 2.54-2.541l-.159-.292a.873.873 0 0 1 .52-1.255l.319-.094c1.79-.527 1.79-3.065 0-3.592l-.319-.094a.873.873 0 0 1-.52-1.255l.16-.292c.893-1.64-.902-3.433-2.541-2.54l-.292.159a.873.873 0 0 1-1.255-.52l-.094-.319z"/>
|
||||
</svg>
|
||||
<span>AI-Powered Analysis</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.upload-area {
|
||||
border: 2px dashed var(--truecv-gray-300);
|
||||
border-radius: 16px;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
background: linear-gradient(180deg, var(--truecv-bg-surface) 0%, var(--truecv-bg-muted) 100%);
|
||||
}
|
||||
|
||||
.upload-area:hover {
|
||||
border-color: var(--truecv-primary);
|
||||
background: linear-gradient(180deg, #e8f1fa 0%, #d4e4f4 100%);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 25px -5px rgba(59, 111, 212, 0.1);
|
||||
}
|
||||
|
||||
.upload-area.dragging {
|
||||
border-color: var(--truecv-primary);
|
||||
background: linear-gradient(180deg, #d4e4f4 0%, #c5d9ef 100%);
|
||||
border-style: solid;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: linear-gradient(135deg, var(--truecv-primary) 0%, var(--truecv-primary-dark) 100%);
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 1.5rem;
|
||||
color: white;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.upload-area:hover .upload-icon {
|
||||
transform: scale(1.1) rotate(-5deg);
|
||||
}
|
||||
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.file-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border: 1px solid var(--truecv-gray-200);
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
background: var(--truecv-bg-surface);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.file-list-item:hover {
|
||||
border-color: var(--truecv-primary);
|
||||
box-shadow: 0 4px 12px rgba(59, 111, 212, 0.08);
|
||||
}
|
||||
|
||||
.file-type-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.file-type-icon.pdf {
|
||||
background: linear-gradient(135deg, #fde8e8 0%, #fcd9d9 100%);
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.file-type-icon.docx {
|
||||
background: linear-gradient(135deg, #e3ecf7 0%, #d4e4f4 100%);
|
||||
color: var(--truecv-primary);
|
||||
}
|
||||
|
||||
.file-type-icon.json {
|
||||
background: linear-gradient(135deg, #fef9c3 0%, #fef08a 100%);
|
||||
color: #ca8a04;
|
||||
}
|
||||
|
||||
.security-info {
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.security-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 1rem;
|
||||
background: var(--truecv-bg-muted);
|
||||
border-radius: var(--truecv-radius);
|
||||
font-size: 0.875rem;
|
||||
color: var(--truecv-gray-600);
|
||||
}
|
||||
|
||||
.security-badge svg {
|
||||
color: var(--truecv-verified);
|
||||
}
|
||||
|
||||
@@media (max-width: 576px) {
|
||||
.upload-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.upload-icon svg {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.card-body.p-5 {
|
||||
padding: 1.5rem !important;
|
||||
}
|
||||
|
||||
.d-flex.justify-content-center.gap-3 {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem !important;
|
||||
}
|
||||
|
||||
.security-info .d-flex {
|
||||
flex-direction: column;
|
||||
gap: 0.75rem !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@code {
|
||||
private List<BufferedFile> _selectedFiles = new();
|
||||
private bool _isUploading;
|
||||
private bool _isBuffering;
|
||||
private bool _isDragging;
|
||||
private int _uploadProgress;
|
||||
private string? _errorMessage;
|
||||
private int _currentFileIndex;
|
||||
private int _totalFiles;
|
||||
private string? _currentFileName;
|
||||
|
||||
private const long MaxFileSize = 10 * 1024 * 1024; // 10MB
|
||||
private const int MaxFileCount = 50; // Maximum files per batch
|
||||
|
||||
// Magic bytes for file type validation
|
||||
private static readonly byte[] PdfMagicBytes = [0x25, 0x50, 0x44, 0x46]; // %PDF
|
||||
private static readonly byte[] DocxMagicBytes = [0x50, 0x4B, 0x03, 0x04]; // PK.. (ZIP signature)
|
||||
|
||||
// Buffered file to prevent stale IBrowserFile references
|
||||
private sealed record BufferedFile(string Name, long Size, byte[] Data);
|
||||
|
||||
private void HandleDragEnter()
|
||||
{
|
||||
_isDragging = true;
|
||||
}
|
||||
|
||||
private void HandleDragLeave()
|
||||
{
|
||||
_isDragging = false;
|
||||
}
|
||||
|
||||
private void HandleDrop()
|
||||
{
|
||||
_isDragging = false;
|
||||
}
|
||||
|
||||
private async Task HandleFileSelected(InputFileChangeEventArgs e)
|
||||
{
|
||||
_errorMessage = null;
|
||||
_isBuffering = true;
|
||||
StateHasChanged();
|
||||
|
||||
var invalidFiles = new List<string>();
|
||||
var oversizedFiles = new List<string>();
|
||||
var failedFiles = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var file in e.GetMultipleFiles(MaxFileCount))
|
||||
{
|
||||
if (!IsValidFileType(file.Name))
|
||||
{
|
||||
invalidFiles.Add(file.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (file.Size > MaxFileSize)
|
||||
{
|
||||
oversizedFiles.Add(file.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Avoid duplicates
|
||||
if (_selectedFiles.Any(f => f.Name == file.Name && f.Size == file.Size))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Read file data immediately to prevent stale reference issues
|
||||
try
|
||||
{
|
||||
await using var stream = file.OpenReadStream(MaxFileSize);
|
||||
using var memoryStream = new MemoryStream();
|
||||
await stream.CopyToAsync(memoryStream);
|
||||
_selectedFiles.Add(new BufferedFile(file.Name, file.Size, memoryStream.ToArray()));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Failed to buffer file: {FileName}", file.Name);
|
||||
failedFiles.Add(file.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isBuffering = false;
|
||||
}
|
||||
|
||||
// Build error message if any files were rejected
|
||||
var errors = new List<string>();
|
||||
if (invalidFiles.Count > 0)
|
||||
errors.Add($"Invalid file type(s): {string.Join(", ", invalidFiles.Take(3))}{(invalidFiles.Count > 3 ? $" and {invalidFiles.Count - 3} more" : "")}");
|
||||
if (oversizedFiles.Count > 0)
|
||||
errors.Add($"File(s) exceed 10MB limit: {string.Join(", ", oversizedFiles.Take(3))}{(oversizedFiles.Count > 3 ? $" and {oversizedFiles.Count - 3} more" : "")}");
|
||||
if (failedFiles.Count > 0)
|
||||
errors.Add($"Failed to read file(s): {string.Join(", ", failedFiles.Take(3))}{(failedFiles.Count > 3 ? $" and {failedFiles.Count - 3} more" : "")}");
|
||||
|
||||
if (errors.Count > 0)
|
||||
_errorMessage = string.Join(". ", errors);
|
||||
}
|
||||
|
||||
private void RemoveFile(BufferedFile file)
|
||||
{
|
||||
_selectedFiles.Remove(file);
|
||||
}
|
||||
|
||||
private void ClearAllFiles()
|
||||
{
|
||||
_selectedFiles.Clear();
|
||||
_errorMessage = null;
|
||||
}
|
||||
|
||||
private async Task UploadFiles()
|
||||
{
|
||||
if (_selectedFiles.Count == 0) return;
|
||||
|
||||
try
|
||||
{
|
||||
_isUploading = true;
|
||||
_uploadProgress = 0;
|
||||
_errorMessage = null;
|
||||
_totalFiles = _selectedFiles.Count;
|
||||
_currentFileIndex = 0;
|
||||
|
||||
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
|
||||
var userIdClaim = authState.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
|
||||
if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId))
|
||||
{
|
||||
_errorMessage = "Unable to identify user. Please log in again.";
|
||||
_isUploading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var failedFiles = new List<string>();
|
||||
|
||||
foreach (var file in _selectedFiles.ToList())
|
||||
{
|
||||
_currentFileIndex++;
|
||||
_currentFileName = file.Name;
|
||||
_uploadProgress = (int)((_currentFileIndex - 1) / (double)_totalFiles * 100);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
|
||||
try
|
||||
{
|
||||
using var memoryStream = new MemoryStream(file.Data);
|
||||
|
||||
// Validate file content (magic bytes)
|
||||
if (!await ValidateFileContentAsync(memoryStream, file.Name))
|
||||
{
|
||||
failedFiles.Add($"{file.Name} (invalid content)");
|
||||
continue;
|
||||
}
|
||||
|
||||
await CVCheckService.CreateCheckAsync(userId, memoryStream, file.Name);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Error uploading CV: {FileName}", file.Name);
|
||||
failedFiles.Add($"{file.Name} (upload error)");
|
||||
}
|
||||
}
|
||||
|
||||
_uploadProgress = 100;
|
||||
_currentFileName = null;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
await Task.Delay(500); // Brief pause to show completion
|
||||
|
||||
if (failedFiles.Count > 0 && failedFiles.Count < _totalFiles)
|
||||
{
|
||||
// Partial success - some files failed
|
||||
Logger.LogWarning("Some files failed to upload: {FailedFiles}", string.Join(", ", failedFiles));
|
||||
}
|
||||
|
||||
// Navigate to dashboard to see all uploaded CVs
|
||||
NavigationManager.NavigateTo("/dashboard");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Error uploading CVs");
|
||||
_errorMessage = "An error occurred while uploading. Please try again.";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isUploading = false;
|
||||
_currentFileName = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> ValidateFileContentAsync(MemoryStream stream, string fileName)
|
||||
{
|
||||
var extension = Path.GetExtension(fileName).ToLowerInvariant();
|
||||
var header = new byte[4];
|
||||
|
||||
stream.Position = 0;
|
||||
var bytesRead = await stream.ReadAsync(header.AsMemory(0, 4));
|
||||
stream.Position = 0;
|
||||
|
||||
if (bytesRead < 4)
|
||||
return false;
|
||||
|
||||
return extension switch
|
||||
{
|
||||
".pdf" => header.AsSpan().StartsWith(PdfMagicBytes),
|
||||
".docx" => header.AsSpan().StartsWith(DocxMagicBytes),
|
||||
".json" => header[0] == '{' || header[0] == '[',
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private bool IsValidFileType(string fileName)
|
||||
{
|
||||
var extension = Path.GetExtension(fileName).ToLowerInvariant();
|
||||
return extension is ".pdf" or ".docx" or ".json";
|
||||
}
|
||||
|
||||
private static string FormatFileSize(long bytes)
|
||||
{
|
||||
string[] sizes = ["B", "KB", "MB", "GB"];
|
||||
int order = 0;
|
||||
double size = bytes;
|
||||
|
||||
while (size >= 1024 && order < sizes.Length - 1)
|
||||
{
|
||||
order++;
|
||||
size /= 1024;
|
||||
}
|
||||
|
||||
return $"{size:0.##} {sizes[order]}";
|
||||
}
|
||||
|
||||
private static string GetFileTypeClass(string fileName)
|
||||
{
|
||||
var ext = Path.GetExtension(fileName).ToLowerInvariant();
|
||||
return ext switch
|
||||
{
|
||||
".pdf" => "pdf",
|
||||
".docx" => "docx",
|
||||
".json" => "json",
|
||||
_ => "docx"
|
||||
};
|
||||
}
|
||||
}
|
||||
804
src/RealCV.Web/Components/Pages/Dashboard.razor
Normal file
804
src/RealCV.Web/Components/Pages/Dashboard.razor
Normal file
@@ -0,0 +1,804 @@
|
||||
@page "/dashboard"
|
||||
@attribute [Authorize]
|
||||
@rendermode InteractiveServer
|
||||
@implements IDisposable
|
||||
|
||||
@inject ICVCheckService CVCheckService
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
||||
@inject ILogger<Dashboard> Logger
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject RealCV.Web.Services.IPdfReportService PdfReportService
|
||||
@inject IAuditService AuditService
|
||||
|
||||
<PageTitle>Dashboard - RealCV</PageTitle>
|
||||
|
||||
<div class="container py-5">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1 class="fw-bold mb-1">Dashboard</h1>
|
||||
<p class="text-muted mb-0">View and manage your CV verification checks</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-outline-secondary" @onclick="ExportToPdf" disabled="@(_isExporting || !HasCompletedChecks())">
|
||||
@if (_isExporting)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-file-pdf me-1" viewBox="0 0 16 16">
|
||||
<path d="M4 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H4zm0 1h8a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1z"/>
|
||||
<path d="M4.603 12.087a.81.81 0 0 1-.438-.42c-.195-.388-.13-.776.08-1.102.198-.307.526-.568.897-.787a7.68 7.68 0 0 1 1.482-.645 19.701 19.701 0 0 0 1.062-2.227 7.269 7.269 0 0 1-.43-1.295c-.086-.4-.119-.796-.046-1.136.075-.354.274-.672.65-.823.192-.077.4-.12.602-.077a.7.7 0 0 1 .477.365c.088.164.12.356.127.538.007.187-.012.395-.047.614-.084.51-.27 1.134-.52 1.794a10.954 10.954 0 0 0 .98 1.686 5.753 5.753 0 0 1 1.334.05c.364.065.734.195.96.465.12.144.193.32.2.518.007.192-.047.382-.138.563a1.04 1.04 0 0 1-.354.416.856.856 0 0 1-.51.138c-.331-.014-.654-.196-.933-.417a5.716 5.716 0 0 1-.911-.95 11.642 11.642 0 0 0-1.997.406 11.311 11.311 0 0 1-1.021 1.51c-.29.35-.608.655-.926.787a.793.793 0 0 1-.58.029zm1.379-1.901c-.166.076-.32.156-.459.238-.328.194-.541.383-.647.547-.094.145-.096.25-.04.361.01.022.02.036.026.044a.27.27 0 0 0 .035-.012c.137-.056.355-.235.635-.572a8.18 8.18 0 0 0 .45-.606zm1.64-1.33a12.647 12.647 0 0 1 1.01-.193 11.666 11.666 0 0 1-.51-.858 20.741 20.741 0 0 1-.5 1.05zm2.446.45c.15.162.296.3.435.41.24.19.407.253.498.256a.107.107 0 0 0 .07-.015.307.307 0 0 0 .094-.125.436.436 0 0 0 .059-.2.095.095 0 0 0-.026-.063c-.052-.062-.2-.152-.518-.209a3.881 3.881 0 0 0-.612-.053zM8.078 5.8a6.7 6.7 0 0 0 .2-.828c.031-.188.043-.343.038-.465a.613.613 0 0 0-.032-.198.517.517 0 0 0-.145.04c-.087.035-.158.106-.196.283-.04.192-.03.469.046.822.024.111.054.227.09.346z"/>
|
||||
</svg>
|
||||
}
|
||||
Export PDF
|
||||
</button>
|
||||
<a href="/check" class="btn btn-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-plus-lg me-1" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2Z"/>
|
||||
</svg>
|
||||
New Check
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_isLoading)
|
||||
{
|
||||
<div class="text-center py-5">
|
||||
<div class="placeholder-glow mb-4">
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-md-4"><div class="placeholder col-12 rounded-4" style="height: 100px;"></div></div>
|
||||
<div class="col-md-4"><div class="placeholder col-12 rounded-4" style="height: 100px;"></div></div>
|
||||
<div class="col-md-4"><div class="placeholder col-12 rounded-4" style="height: 100px;"></div></div>
|
||||
</div>
|
||||
<div class="placeholder col-12 rounded-4" style="height: 300px;"></div>
|
||||
</div>
|
||||
<div class="spinner-border text-primary" role="status" style="width: 2.5rem; height: 2.5rem;">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<p class="mt-3 text-muted fw-medium">Loading your checks...</p>
|
||||
</div>
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(_errorMessage))
|
||||
{
|
||||
<div class="alert alert-danger" role="alert">
|
||||
@_errorMessage
|
||||
</div>
|
||||
}
|
||||
else if (_checks.Count == 0)
|
||||
{
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body text-center py-5 px-4">
|
||||
<div class="empty-state-icon mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" class="bi bi-file-earmark-plus" viewBox="0 0 16 16">
|
||||
<path d="M8 6.5a.5.5 0 0 1 .5.5v1.5H10a.5.5 0 0 1 0 1H8.5V11a.5.5 0 0 1-1 0V9.5H6a.5.5 0 0 1 0-1h1.5V7a.5.5 0 0 1 .5-.5z"/>
|
||||
<path d="M14 4.5V14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h5.5L14 4.5zm-3 0A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4.5h-2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h4 class="fw-bold mb-2">No CV Checks Yet</h4>
|
||||
<p class="text-muted mb-4 mx-auto" style="max-width: 400px;">
|
||||
Upload your first CV to begin verifying employment history against official company records.
|
||||
</p>
|
||||
<a href="/check" class="btn btn-primary btn-lg">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="bi bi-upload me-2" viewBox="0 0 16 16">
|
||||
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/>
|
||||
<path d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708l3-3z"/>
|
||||
</svg>
|
||||
Upload Your First CV
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<!-- Stats Cards -->
|
||||
<div class="row mb-4 g-4">
|
||||
<div class="col-md-4">
|
||||
<div class="card border-0 shadow-sm stat-card h-100">
|
||||
<div class="card-body p-4">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="stat-icon stat-icon-primary me-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M10.854 7.854a.5.5 0 0 0-.708-.708L7.5 9.793 6.354 8.646a.5.5 0 1 0-.708.708l1.5 1.5a.5.5 0 0 0 .708 0l3-3z"/>
|
||||
<path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2zM9.5 3A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5v2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="stat-value">@_checks.Count</div>
|
||||
<div class="stat-label">Total Checks</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card border-0 shadow-sm stat-card h-100">
|
||||
<div class="card-body p-4">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="stat-icon stat-icon-success me-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
|
||||
<path d="M10.97 4.97a.235.235 0 0 0-.02.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-1.071-1.05z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="stat-value">@_checks.Count(c => c.Status == "Completed")</div>
|
||||
<div class="stat-label">Completed</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card border-0 shadow-sm stat-card h-100">
|
||||
<div class="card-body p-4">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="stat-icon stat-icon-warning me-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M2.5 15a.5.5 0 1 1 0-1h1v-1a4.5 4.5 0 0 1 2.557-4.06c.29-.139.443-.377.443-.59v-.7c0-.213-.154-.451-.443-.59A4.5 4.5 0 0 1 3.5 3V2h-1a.5.5 0 0 1 0-1h11a.5.5 0 0 1 0 1h-1v1a4.5 4.5 0 0 1-2.557 4.06c-.29.139-.443.377-.443.59v.7c0 .213.154.451.443.59A4.5 4.5 0 0 1 12.5 13v1h1a.5.5 0 0 1 0 1h-11zm2-13v1c0 .537.12 1.045.337 1.5h6.326c.216-.455.337-.963.337-1.5V2h-7zm3 6.35c0 .701-.478 1.236-1.011 1.492A3.5 3.5 0 0 0 4.5 13s.866-1.299 3-1.48V8.35zm1 0v3.17c2.134.181 3 1.48 3 1.48a3.5 3.5 0 0 0-1.989-3.158C8.978 9.586 8.5 9.052 8.5 8.351z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="stat-value">@_checks.Count(c => c.Status is "Pending" or "Processing")</div>
|
||||
<div class="stat-label">In Progress</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Checks List -->
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header py-3 border-bottom" style="background-color: var(--truecv-bg-surface);">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<h5 class="mb-0 fw-bold">Recent CV Checks</h5>
|
||||
@if (_selectedIds.Count > 0)
|
||||
{
|
||||
<span class="badge bg-primary">@_selectedIds.Count selected</span>
|
||||
}
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
@if (_selectedIds.Count > 0)
|
||||
{
|
||||
<button class="btn btn-sm btn-outline-danger" @onclick="ConfirmDeleteSelected">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-trash3 me-1" viewBox="0 0 16 16">
|
||||
<path d="M6.5 1h3a.5.5 0 0 1 .5.5v1H6v-1a.5.5 0 0 1 .5-.5ZM11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3A1.5 1.5 0 0 0 5 1.5v1H2.506a.58.58 0 0 0-.01 0H1.5a.5.5 0 0 0 0 1h.538l.853 10.66A2 2 0 0 0 4.885 16h6.23a2 2 0 0 0 1.994-1.84l.853-10.66h.538a.5.5 0 0 0 0-1h-.995a.59.59 0 0 0-.01 0H11Zm1.958 1-.846 10.58a1 1 0 0 1-.997.92h-6.23a1 1 0 0 1-.997-.92L3.042 3.5h9.916Zm-7.487 1a.5.5 0 0 1 .528.47l.5 8.5a.5.5 0 0 1-.998.06L5 5.03a.5.5 0 0 1 .47-.53Zm5.058 0a.5.5 0 0 1 .47.53l-.5 8.5a.5.5 0 1 1-.998-.06l.5-8.5a.5.5 0 0 1 .528-.47ZM8 4.5a.5.5 0 0 1 .5.5v8.5a.5.5 0 0 1-1 0V5a.5.5 0 0 1 .5-.5Z"/>
|
||||
</svg>
|
||||
Delete Selected
|
||||
</button>
|
||||
}
|
||||
<span class="badge bg-light text-muted">@_checks.Count total</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead>
|
||||
<tr style="background-color: var(--truecv-bg-muted);">
|
||||
<th class="border-0 ps-3 py-3" style="width: 40px;">
|
||||
<input type="checkbox" class="form-check-input"
|
||||
checked="@IsAllSelected()"
|
||||
@onchange="ToggleSelectAll"
|
||||
title="Select all" />
|
||||
</th>
|
||||
<th class="border-0 py-3 text-uppercase small fw-semibold text-muted" style="letter-spacing: 0.05em;">Candidate</th>
|
||||
<th class="border-0 py-3 text-uppercase small fw-semibold text-muted" style="letter-spacing: 0.05em;">Uploaded</th>
|
||||
<th class="border-0 py-3 text-uppercase small fw-semibold text-muted text-center" style="letter-spacing: 0.05em;">Status</th>
|
||||
<th class="border-0 py-3 text-uppercase small fw-semibold text-muted text-center" style="letter-spacing: 0.05em;">Score</th>
|
||||
<th class="border-0 py-3 pe-4 text-uppercase small fw-semibold text-muted text-end" style="letter-spacing: 0.05em;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var check in _checks)
|
||||
{
|
||||
<tr class="@(check.Status == "Completed" ? "cursor-pointer" : "") @(_selectedIds.Contains(check.Id) ? "table-active" : "")"
|
||||
@onclick="() => ViewReport(check)">
|
||||
<td class="ps-3 py-3" @onclick:stopPropagation="true">
|
||||
<input type="checkbox" class="form-check-input"
|
||||
checked="@_selectedIds.Contains(check.Id)"
|
||||
@onchange="() => ToggleSelection(check.Id)" />
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="file-icon-wrapper me-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-file-earmark-person text-primary" viewBox="0 0 16 16">
|
||||
<path d="M11 8a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
|
||||
<path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2zM9.5 3A1.5 1.5 0 0 0 11 4.5h2v9.255S12 12 8 12s-5 1.755-5 1.755V2a1 1 0 0 1 1-1h5.5v2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-0 fw-semibold text-dark">@Path.GetFileNameWithoutExtension(check.OriginalFileName)</p>
|
||||
<small class="text-muted">@Path.GetExtension(check.OriginalFileName).ToUpperInvariant()</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<div>
|
||||
<p class="mb-0 small">@check.CreatedAt.ToString("dd MMM yyyy")</p>
|
||||
<small class="text-muted">@check.CreatedAt.ToString("HH:mm")</small>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-3 text-center">
|
||||
@switch (check.Status)
|
||||
{
|
||||
case "Completed":
|
||||
<span class="badge rounded-pill bg-success-subtle text-success px-3 py-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" class="bi bi-check-circle-fill me-1" viewBox="0 0 16 16">
|
||||
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>
|
||||
</svg>
|
||||
Completed
|
||||
</span>
|
||||
break;
|
||||
case "Processing":
|
||||
<span class="badge rounded-pill bg-primary-subtle text-primary px-3 py-2">
|
||||
<span class="spinner-border spinner-border-sm me-1" role="status" style="width: 0.75rem; height: 0.75rem;"></span>
|
||||
@(check.ProcessingStage ?? "Processing")
|
||||
</span>
|
||||
break;
|
||||
case "Pending":
|
||||
<span class="badge rounded-pill bg-secondary-subtle text-secondary px-3 py-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" class="bi bi-clock me-1" viewBox="0 0 16 16">
|
||||
<path d="M8 3.5a.5.5 0 0 0-1 0V9a.5.5 0 0 0 .252.434l3.5 2a.5.5 0 0 0 .496-.868L8 8.71V3.5z"/>
|
||||
<path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm7-8A7 7 0 1 1 1 8a7 7 0 0 1 14 0z"/>
|
||||
</svg>
|
||||
Queued
|
||||
</span>
|
||||
break;
|
||||
case "Failed":
|
||||
<span class="badge rounded-pill bg-danger-subtle text-danger px-3 py-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" class="bi bi-x-circle-fill me-1" viewBox="0 0 16 16">
|
||||
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z"/>
|
||||
</svg>
|
||||
Failed
|
||||
</span>
|
||||
break;
|
||||
default:
|
||||
<span class="badge rounded-pill bg-secondary-subtle text-secondary px-3 py-2">@check.Status</span>
|
||||
break;
|
||||
}
|
||||
</td>
|
||||
<td class="py-3 text-center">
|
||||
@if (check.VeracityScore.HasValue)
|
||||
{
|
||||
<div class="score-ring-container" title="Veracity Score: @check.VeracityScore%">
|
||||
<svg class="score-ring" viewBox="0 0 40 40">
|
||||
<circle class="score-ring-bg" cx="20" cy="20" r="15.9"/>
|
||||
<circle class="score-ring-progress @GetScoreRingClass(check.VeracityScore.Value)"
|
||||
cx="20" cy="20" r="15.9"
|
||||
stroke-dasharray="@GetScoreDashArray(check.VeracityScore.Value), 100"/>
|
||||
</svg>
|
||||
<span class="score-ring-value @GetScoreTextClass(check.VeracityScore.Value)">@check.VeracityScore</span>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">--</span>
|
||||
}
|
||||
</td>
|
||||
<td class="py-3 pe-4 text-end">
|
||||
<div class="d-flex justify-content-end align-items-center gap-2">
|
||||
@if (check.Status == "Completed")
|
||||
{
|
||||
<a href="/report/@check.Id" class="btn btn-sm btn-primary" @onclick:stopPropagation="true">
|
||||
View Report
|
||||
</a>
|
||||
}
|
||||
else if (check.Status is "Pending" or "Processing")
|
||||
{
|
||||
<button class="btn btn-sm btn-outline-secondary" disabled>
|
||||
Processing...
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a href="/check" class="btn btn-sm btn-outline-warning" @onclick:stopPropagation="true">
|
||||
Retry
|
||||
</a>
|
||||
}
|
||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(check)" @onclick:stopPropagation="true" title="Delete">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-trash3" viewBox="0 0 16 16">
|
||||
<path d="M6.5 1h3a.5.5 0 0 1 .5.5v1H6v-1a.5.5 0 0 1 .5-.5ZM11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3A1.5 1.5 0 0 0 5 1.5v1H2.506a.58.58 0 0 0-.01 0H1.5a.5.5 0 0 0 0 1h.538l.853 10.66A2 2 0 0 0 4.885 16h6.23a2 2 0 0 0 1.994-1.84l.853-10.66h.538a.5.5 0 0 0 0-1h-.995a.59.59 0 0 0-.01 0H11Zm1.958 1-.846 10.58a1 1 0 0 1-.997.92h-6.23a1 1 0 0 1-.997-.92L3.042 3.5h9.916Zm-7.487 1a.5.5 0 0 1 .528.47l.5 8.5a.5.5 0 0 1-.998.06L5 5.03a.5.5 0 0 1 .47-.53Zm5.058 0a.5.5 0 0 1 .47.53l-.5 8.5a.5.5 0 1 1-.998-.06l.5-8.5a.5.5 0 0 1 .528-.47ZM8 4.5a.5.5 0 0 1 .5.5v8.5a.5.5 0 0 1-1 0V5a.5.5 0 0 1 .5-.5Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
@if (_showDeleteModal)
|
||||
{
|
||||
<div class="modal fade show d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header border-0">
|
||||
<h5 class="modal-title fw-bold">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-exclamation-triangle text-danger me-2" viewBox="0 0 16 16">
|
||||
<path d="M7.938 2.016A.13.13 0 0 1 8.002 2a.13.13 0 0 1 .063.016.146.146 0 0 1 .054.057l6.857 11.667c.036.06.035.124.002.183a.163.163 0 0 1-.054.06.116.116 0 0 1-.066.017H1.146a.115.115 0 0 1-.066-.017.163.163 0 0 1-.054-.06.176.176 0 0 1 .002-.183L7.884 2.073a.147.147 0 0 1 .054-.057zm1.044-.45a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566z"/>
|
||||
<path d="M7.002 12a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 5.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995z"/>
|
||||
</svg>
|
||||
Delete @(_isBulkDelete ? $"{_checksToDelete.Count} CV Checks" : "CV Check")
|
||||
</h5>
|
||||
<button type="button" class="btn-close" @onclick="CancelDelete"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@if (_isBulkDelete)
|
||||
{
|
||||
<p class="mb-2">Are you sure you want to delete <strong>@_checksToDelete.Count</strong> CV checks?</p>
|
||||
<div class="bg-light rounded p-3" style="max-height: 200px; overflow-y: auto;">
|
||||
@foreach (var check in _checksToDelete)
|
||||
{
|
||||
<div class="d-flex justify-content-between align-items-center py-1 @(check != _checksToDelete.Last() ? "border-bottom" : "")">
|
||||
<span>@Path.GetFileNameWithoutExtension(check.OriginalFileName)</span>
|
||||
<small class="text-muted">@check.CreatedAt.ToString("dd MMM yyyy")</small>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else if (_checkToDelete != null)
|
||||
{
|
||||
<p class="mb-2">Are you sure you want to delete this CV check?</p>
|
||||
<div class="bg-light rounded p-3">
|
||||
<strong>@Path.GetFileNameWithoutExtension(_checkToDelete.OriginalFileName)</strong>
|
||||
<br />
|
||||
<small class="text-muted">Uploaded @_checkToDelete.CreatedAt.ToString("dd MMM yyyy 'at' HH:mm")</small>
|
||||
</div>
|
||||
}
|
||||
<p class="text-danger small mt-3 mb-0">
|
||||
<strong>This action cannot be undone.</strong> @(_isBulkDelete ? "All selected CVs and their" : "The CV and all") verification data will be permanently removed.
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer border-0">
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="CancelDelete">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" @onclick="ExecuteDelete" disabled="@_isDeleting">
|
||||
@if (_isDeleting)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
|
||||
<span>Deleting...</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Delete @(_isBulkDelete ? $"{_checksToDelete.Count} Checks" : "")</span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<style>
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.cursor-pointer:hover {
|
||||
background-color: rgba(59, 111, 212, 0.04);
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
background: linear-gradient(135deg, #e8f1fa 0%, #d4e4f4 100%);
|
||||
border-radius: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto;
|
||||
color: var(--truecv-primary);
|
||||
}
|
||||
|
||||
.file-icon-wrapper {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background: linear-gradient(135deg, #e8f1fa 0%, #d4e4f4 100%);
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.score-ring-container {
|
||||
position: relative;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.score-ring {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.score-ring-bg {
|
||||
fill: none;
|
||||
stroke: var(--truecv-gray-200);
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.score-ring-progress {
|
||||
fill: none;
|
||||
stroke-width: 3;
|
||||
stroke-linecap: round;
|
||||
transform-origin: center;
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.score-ring-progress.high { stroke: var(--truecv-verified); }
|
||||
.score-ring-progress.medium { stroke: var(--truecv-warning); }
|
||||
.score-ring-progress.low { stroke: var(--truecv-danger); }
|
||||
|
||||
.score-ring-value {
|
||||
position: absolute;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.text-verified { color: var(--truecv-verified); }
|
||||
.text-warning-dark { color: var(--truecv-warning-dark); }
|
||||
.text-danger { color: var(--truecv-danger); }
|
||||
|
||||
@@media (max-width: 768px) {
|
||||
.d-flex.justify-content-between.align-items-center.mb-4 {
|
||||
flex-direction: column;
|
||||
align-items: stretch !important;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.d-flex.gap-2 {
|
||||
width: 100%;
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.d-flex.gap-2 .btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.row.mb-4 .col-md-4 {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.score-badge {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@code {
|
||||
private List<CVCheckDto> _checks = [];
|
||||
private bool _isLoading = true;
|
||||
private bool _isExporting;
|
||||
private bool _isDeleting;
|
||||
private string? _errorMessage;
|
||||
private Guid _userId;
|
||||
private System.Threading.Timer? _pollingTimer;
|
||||
private volatile bool _isPolling;
|
||||
private volatile bool _disposed;
|
||||
private volatile bool _isOperationInProgress;
|
||||
|
||||
// Delete confirmation modal state
|
||||
private bool _showDeleteModal;
|
||||
private CVCheckDto? _checkToDelete;
|
||||
private List<CVCheckDto> _checksToDelete = [];
|
||||
private bool _isBulkDelete;
|
||||
|
||||
// Selection state
|
||||
private HashSet<Guid> _selectedIds = [];
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadChecks();
|
||||
StartPollingIfNeeded();
|
||||
}
|
||||
|
||||
private void StartPollingIfNeeded()
|
||||
{
|
||||
if (HasProcessingChecks() && !_isPolling && !_disposed)
|
||||
{
|
||||
_isPolling = true;
|
||||
_pollingTimer = new System.Threading.Timer(async _ =>
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
||||
try
|
||||
{
|
||||
await InvokeAsync(async () =>
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
||||
await LoadChecks();
|
||||
if (!HasProcessingChecks())
|
||||
{
|
||||
StopPolling();
|
||||
}
|
||||
StateHasChanged();
|
||||
});
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Component was disposed, ignore
|
||||
}
|
||||
}, null, TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(3));
|
||||
}
|
||||
}
|
||||
|
||||
private bool HasProcessingChecks()
|
||||
{
|
||||
foreach (var c in _checks)
|
||||
{
|
||||
if (c.Status == "Processing" || c.Status == "Pending") return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void StopPolling()
|
||||
{
|
||||
_isPolling = false;
|
||||
_pollingTimer?.Dispose();
|
||||
_pollingTimer = null;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_disposed = true;
|
||||
StopPolling();
|
||||
}
|
||||
|
||||
private async Task LoadChecks()
|
||||
{
|
||||
if (_isOperationInProgress) return;
|
||||
|
||||
_isOperationInProgress = true;
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
|
||||
try
|
||||
{
|
||||
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
|
||||
var userIdClaim = authState.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
|
||||
if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out _userId))
|
||||
{
|
||||
_errorMessage = "Unable to identify user. Please log in again.";
|
||||
return;
|
||||
}
|
||||
|
||||
_checks = await CVCheckService.GetUserChecksAsync(_userId) ?? [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Error loading CV checks");
|
||||
_errorMessage = "An error occurred while loading checks. Please try again.";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isLoading = false;
|
||||
_isOperationInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void ViewReport(CVCheckDto check)
|
||||
{
|
||||
if (check.Status == "Completed")
|
||||
{
|
||||
NavigationManager.NavigateTo($"/report/{check.Id}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetScoreBadgeClass(int score)
|
||||
{
|
||||
return score switch
|
||||
{
|
||||
> 70 => "bg-success",
|
||||
>= 50 => "bg-warning text-dark",
|
||||
_ => "bg-danger"
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetScoreBadgeColorClass(int score)
|
||||
{
|
||||
return score switch
|
||||
{
|
||||
> 70 => "score-high",
|
||||
>= 50 => "score-medium",
|
||||
_ => "score-low"
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetScoreRingClass(int score) => score > 70 ? "high" : score >= 50 ? "medium" : "low";
|
||||
private static string GetScoreTextClass(int score) => score > 70 ? "text-verified" : score >= 50 ? "text-warning-dark" : "text-danger";
|
||||
private static string GetScoreDashArray(int score) => score.ToString();
|
||||
|
||||
private async Task ExportToPdf()
|
||||
{
|
||||
if (_isExporting) return;
|
||||
|
||||
_isExporting = true;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
var reportDataList = new List<RealCV.Web.Services.PdfReportData>();
|
||||
foreach (var check in _checks)
|
||||
{
|
||||
if (check.Status != "Completed") continue;
|
||||
|
||||
var report = await CVCheckService.GetReportAsync(check.Id, _userId);
|
||||
if (report is null) continue;
|
||||
|
||||
int verifiedCount = 0;
|
||||
int unverifiedCount = 0;
|
||||
foreach (var v in report.EmploymentVerifications)
|
||||
{
|
||||
if (v.IsVerified) verifiedCount++;
|
||||
else unverifiedCount++;
|
||||
}
|
||||
|
||||
int criticalFlags = 0;
|
||||
int warningFlags = 0;
|
||||
foreach (var f in report.Flags)
|
||||
{
|
||||
if (f.Severity == "Critical") criticalFlags++;
|
||||
else if (f.Severity == "Warning") warningFlags++;
|
||||
}
|
||||
|
||||
reportDataList.Add(new RealCV.Web.Services.PdfReportData
|
||||
{
|
||||
CandidateName = Path.GetFileNameWithoutExtension(check.OriginalFileName) ?? "Unknown",
|
||||
UploadDate = check.CreatedAt,
|
||||
Score = report.OverallScore,
|
||||
ScoreLabel = report.ScoreLabel,
|
||||
VerifiedEmployers = verifiedCount,
|
||||
UnverifiedEmployers = unverifiedCount,
|
||||
GapMonths = report.TimelineAnalysis.TotalGapMonths,
|
||||
OverlapMonths = report.TimelineAnalysis.TotalOverlapMonths,
|
||||
CriticalFlags = criticalFlags,
|
||||
WarningFlags = warningFlags
|
||||
});
|
||||
}
|
||||
|
||||
var pdfBytes = PdfReportService.GenerateReport(reportDataList);
|
||||
var base64 = Convert.ToBase64String(pdfBytes);
|
||||
var fileName = "RealCV_Report_" + DateTime.Now.ToString("yyyyMMdd_HHmmss") + ".pdf";
|
||||
await JSRuntime.InvokeVoidAsync("downloadFile", fileName, base64, "application/pdf");
|
||||
|
||||
await AuditService.LogAsync(_userId, AuditActions.ReportExported, null, null, $"Exported {reportDataList.Count} reports to PDF");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Error exporting PDF");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isExporting = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private bool HasCompletedChecks()
|
||||
{
|
||||
foreach (var c in _checks)
|
||||
{
|
||||
if (c.Status == "Completed") return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Selection methods
|
||||
private void ToggleSelection(Guid id)
|
||||
{
|
||||
if (_selectedIds.Contains(id))
|
||||
_selectedIds.Remove(id);
|
||||
else
|
||||
_selectedIds.Add(id);
|
||||
}
|
||||
|
||||
private void ToggleSelectAll()
|
||||
{
|
||||
if (IsAllSelected())
|
||||
_selectedIds.Clear();
|
||||
else
|
||||
_selectedIds = _checks.Select(c => c.Id).ToHashSet();
|
||||
}
|
||||
|
||||
private bool IsAllSelected() => _checks.Count > 0 && _selectedIds.Count == _checks.Count;
|
||||
|
||||
// Single delete
|
||||
private void ConfirmDelete(CVCheckDto check)
|
||||
{
|
||||
_checkToDelete = check;
|
||||
_checksToDelete = [];
|
||||
_isBulkDelete = false;
|
||||
_showDeleteModal = true;
|
||||
}
|
||||
|
||||
// Bulk delete
|
||||
private void ConfirmDeleteSelected()
|
||||
{
|
||||
_checksToDelete = _checks.Where(c => _selectedIds.Contains(c.Id)).ToList();
|
||||
_checkToDelete = null;
|
||||
_isBulkDelete = true;
|
||||
_showDeleteModal = true;
|
||||
}
|
||||
|
||||
private void CancelDelete()
|
||||
{
|
||||
_showDeleteModal = false;
|
||||
_checkToDelete = null;
|
||||
_checksToDelete = [];
|
||||
_isBulkDelete = false;
|
||||
}
|
||||
|
||||
private async Task ExecuteDelete()
|
||||
{
|
||||
if (_isDeleting) return;
|
||||
|
||||
_isDeleting = true;
|
||||
try
|
||||
{
|
||||
if (_isBulkDelete)
|
||||
{
|
||||
var failedCount = 0;
|
||||
foreach (var check in _checksToDelete)
|
||||
{
|
||||
var success = await CVCheckService.DeleteCheckAsync(check.Id, _userId);
|
||||
if (success)
|
||||
{
|
||||
_checks.RemoveAll(c => c.Id == check.Id);
|
||||
_selectedIds.Remove(check.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (failedCount > 0)
|
||||
{
|
||||
_errorMessage = $"Failed to delete {failedCount} CV check(s). Please try again.";
|
||||
}
|
||||
}
|
||||
else if (_checkToDelete != null)
|
||||
{
|
||||
var success = await CVCheckService.DeleteCheckAsync(_checkToDelete.Id, _userId);
|
||||
if (success)
|
||||
{
|
||||
_checks.RemoveAll(c => c.Id == _checkToDelete.Id);
|
||||
_selectedIds.Remove(_checkToDelete.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
_errorMessage = "Failed to delete the CV check. Please try again.";
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Error deleting CV check(s)");
|
||||
_errorMessage = "Failed to delete CV check(s). Please try again.";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isDeleting = false;
|
||||
_showDeleteModal = false;
|
||||
_checkToDelete = null;
|
||||
_checksToDelete = [];
|
||||
_isBulkDelete = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
36
src/RealCV.Web/Components/Pages/Error.razor
Normal file
36
src/RealCV.Web/Components/Pages/Error.razor
Normal file
@@ -0,0 +1,36 @@
|
||||
@page "/Error"
|
||||
@using System.Diagnostics
|
||||
|
||||
<PageTitle>Error</PageTitle>
|
||||
|
||||
<h1 class="text-danger">Error.</h1>
|
||||
<h2 class="text-danger">An error occurred while processing your request.</h2>
|
||||
|
||||
@if (ShowRequestId)
|
||||
{
|
||||
<p>
|
||||
<strong>Request ID:</strong> <code>@RequestId</code>
|
||||
</p>
|
||||
}
|
||||
|
||||
<h3>Development Mode</h3>
|
||||
<p>
|
||||
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
|
||||
</p>
|
||||
<p>
|
||||
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
|
||||
It can result in displaying sensitive information from exceptions to end users.
|
||||
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
|
||||
and restarting the app.
|
||||
</p>
|
||||
|
||||
@code{
|
||||
[CascadingParameter]
|
||||
private HttpContext? HttpContext { get; set; }
|
||||
|
||||
private string? RequestId { get; set; }
|
||||
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
|
||||
|
||||
protected override void OnInitialized() =>
|
||||
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
|
||||
}
|
||||
226
src/RealCV.Web/Components/Pages/Home.razor
Normal file
226
src/RealCV.Web/Components/Pages/Home.razor
Normal file
@@ -0,0 +1,226 @@
|
||||
@page "/"
|
||||
|
||||
<PageTitle>RealCV - Verify CVs with Confidence</PageTitle>
|
||||
|
||||
<!-- Hero Section with Gradient -->
|
||||
<section class="hero-section text-white py-5">
|
||||
<div class="container hero-content">
|
||||
<div class="row align-items-center py-5">
|
||||
<div class="col-lg-7">
|
||||
<h1 class="display-3 fw-bold mb-4" style="letter-spacing: -0.03em; color: #FFFFFF; text-shadow: 0 2px 10px rgba(0,0,0,0.8), 0 0 40px rgba(255,255,255,0.3);">
|
||||
Verify CVs with<br />
|
||||
<span style="color: #60A5FA; text-shadow: 0 2px 8px rgba(0,0,0,0.4);">Confidence</span>
|
||||
</h1>
|
||||
<p class="lead mb-3 opacity-90" style="font-size: 1.25rem; line-height: 1.7;">
|
||||
RealCV uses AI-powered analysis and official company records to verify employment history,
|
||||
detect timeline inconsistencies, and flag potential issues in candidate CVs.
|
||||
</p>
|
||||
<p class="mb-4 d-inline-flex align-items-center px-3 py-2 rounded-pill" style="font-size: 0.85rem; background: rgba(255,255,255,0.15); border: 1px solid rgba(255,255,255,0.25);">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-2" viewBox="0 0 16 16">
|
||||
<path d="M8 16s6-5.686 6-10A6 6 0 0 0 2 6c0 4.314 6 10 6 10zm0-7a3 3 0 1 1 0-6 3 3 0 0 1 0 6z"/>
|
||||
</svg>
|
||||
For UK employment history
|
||||
</p>
|
||||
<div class="d-flex gap-3 flex-wrap">
|
||||
<a href="/check" class="btn btn-lg px-4 py-3" style="background-color: #2563EB; color: white; box-shadow: 0 4px 14px rgba(37, 99, 235, 0.4);">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-shield-check me-2" viewBox="0 0 16 16">
|
||||
<path d="M5.338 1.59a61.44 61.44 0 0 0-2.837.856.481.481 0 0 0-.328.39c-.554 4.157.726 7.19 2.253 9.188a10.725 10.725 0 0 0 2.287 2.233c.346.244.652.42.893.533.12.057.218.095.293.118a.55.55 0 0 0 .101.025.615.615 0 0 0 .1-.025c.076-.023.174-.061.294-.118.24-.113.547-.29.893-.533a10.726 10.726 0 0 0 2.287-2.233c1.527-1.997 2.807-5.031 2.253-9.188a.48.48 0 0 0-.328-.39c-.651-.213-1.75-.56-2.837-.855C9.552 1.29 8.531 1.067 8 1.067c-.53 0-1.552.223-2.662.524zM5.072.56C6.157.265 7.31 0 8 0s1.843.265 2.928.56c1.11.3 2.229.655 2.887.87a1.54 1.54 0 0 1 1.044 1.262c.596 4.477-.787 7.795-2.465 9.99a11.775 11.775 0 0 1-2.517 2.453 7.159 7.159 0 0 1-1.048.625c-.28.132-.581.24-.829.24s-.548-.108-.829-.24a7.158 7.158 0 0 1-1.048-.625 11.777 11.777 0 0 1-2.517-2.453C1.928 10.487.545 7.169 1.141 2.692A1.54 1.54 0 0 1 2.185 1.43 62.456 62.456 0 0 1 5.072.56z"/>
|
||||
<path d="M10.854 5.146a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708 0l-1.5-1.5a.5.5 0 1 1 .708-.708L7.5 7.793l2.646-2.647a.5.5 0 0 1 .708 0z"/>
|
||||
</svg>
|
||||
Start Verification
|
||||
</a>
|
||||
<a href="#features" class="btn btn-lg px-4 py-3" style="background: transparent; border: 2px solid rgba(255,255,255,0.3); color: white;">
|
||||
Learn More
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-down ms-2" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M8 1a.5.5 0 0 1 .5.5v11.793l3.146-3.147a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 .708-.708L7.5 13.293V1.5A.5.5 0 0 1 8 1z"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-5 text-center mt-5 mt-lg-0">
|
||||
<div class="position-relative">
|
||||
<!-- CV Verification Illustration -->
|
||||
<svg width="280" height="280" viewBox="0 0 280 280" fill="none" xmlns="http://www.w3.org/2000/svg" style="filter: drop-shadow(0 20px 40px rgba(0,0,0,0.3));">
|
||||
<!-- Document background -->
|
||||
<rect x="60" y="30" width="160" height="200" rx="8" fill="white" fill-opacity="0.95"/>
|
||||
<!-- Document lines -->
|
||||
<rect x="85" y="60" width="80" height="8" rx="4" fill="#CBD5E1"/>
|
||||
<rect x="85" y="80" width="110" height="6" rx="3" fill="#E2E8F0"/>
|
||||
<rect x="85" y="95" width="100" height="6" rx="3" fill="#E2E8F0"/>
|
||||
<rect x="85" y="110" width="90" height="6" rx="3" fill="#E2E8F0"/>
|
||||
<rect x="85" y="135" width="70" height="8" rx="4" fill="#CBD5E1"/>
|
||||
<rect x="85" y="155" width="110" height="6" rx="3" fill="#E2E8F0"/>
|
||||
<rect x="85" y="170" width="95" height="6" rx="3" fill="#E2E8F0"/>
|
||||
<rect x="85" y="185" width="105" height="6" rx="3" fill="#E2E8F0"/>
|
||||
<!-- Checkmark circle -->
|
||||
<circle cx="200" cy="190" r="45" fill="#22C55E"/>
|
||||
<path d="M180 190L193 203L220 176" stroke="white" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Features Section -->
|
||||
<section id="features" class="py-5" style="background-color: var(--truecv-bg-page);">
|
||||
<div class="container">
|
||||
<div class="text-center mb-5">
|
||||
<h2 class="fw-bold mb-3" style="font-size: 2.25rem;">How RealCV Works</h2>
|
||||
<p class="text-muted" style="font-size: 1.125rem;">Comprehensive CV verification in three key areas</p>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<!-- Employment Verification -->
|
||||
<div class="col-md-4">
|
||||
<div class="card h-100 feature-card">
|
||||
<div class="card-body text-center p-4">
|
||||
<div class="feature-icon mx-auto mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M12.5 16a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Zm1.679-4.493-1.335 2.226a.75.75 0 0 1-1.174.144l-.774-.773a.5.5 0 0 1 .708-.708l.547.548 1.17-1.951a.5.5 0 1 1 .858.514Z"/>
|
||||
<path d="M2 1a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v6.5a.5.5 0 0 1-1 0V1H3v14h3v-2.5a.5.5 0 0 1 .5-.5H8v4H3a1 1 0 0 1-1-1V1Z"/>
|
||||
<path d="M4.5 2a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1Zm3 0a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1Zm3 0a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1Zm-6 3a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1Zm3 0a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1Zm3 0a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1Zm-6 3a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1Zm3 0a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1Z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h4 class="card-title fw-bold mb-3">Employment Verification</h4>
|
||||
<p class="card-text text-muted mb-0">
|
||||
Cross-reference claimed employers with official records to verify
|
||||
company existence and match accuracy.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timeline Analysis -->
|
||||
<div class="col-md-4">
|
||||
<div class="card h-100 feature-card">
|
||||
<div class="card-body text-center p-4">
|
||||
<div class="feature-icon mx-auto mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M9 7a1 1 0 0 1 1-1h5v2h-5a1 1 0 0 1-1-1zM1 9h4a1 1 0 0 1 0 2H1V9z"/>
|
||||
<path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5zM1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4H1z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h4 class="card-title fw-bold mb-3">Timeline Analysis</h4>
|
||||
<p class="card-text text-muted mb-0">
|
||||
Detect unexplained employment gaps and overlapping job periods that may indicate
|
||||
inconsistencies in the candidate's work history.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI-Powered Parsing -->
|
||||
<div class="col-md-4">
|
||||
<div class="card h-100 feature-card">
|
||||
<div class="card-body text-center p-4">
|
||||
<div class="feature-icon mx-auto mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M5 0a.5.5 0 0 1 .5.5V2h1V.5a.5.5 0 0 1 1 0V2h1V.5a.5.5 0 0 1 1 0V2h1V.5a.5.5 0 0 1 1 0V2A2.5 2.5 0 0 1 14 4.5h1.5a.5.5 0 0 1 0 1H14v1h1.5a.5.5 0 0 1 0 1H14v1h1.5a.5.5 0 0 1 0 1H14v1h1.5a.5.5 0 0 1 0 1H14a2.5 2.5 0 0 1-2.5 2.5v1.5a.5.5 0 0 1-1 0V14h-1v1.5a.5.5 0 0 1-1 0V14h-1v1.5a.5.5 0 0 1-1 0V14h-1v1.5a.5.5 0 0 1-1 0V14A2.5 2.5 0 0 1 2 11.5H.5a.5.5 0 0 1 0-1H2v-1H.5a.5.5 0 0 1 0-1H2v-1H.5a.5.5 0 0 1 0-1H2v-1H.5a.5.5 0 0 1 0-1H2A2.5 2.5 0 0 1 4.5 2V.5A.5.5 0 0 1 5 0zm-.5 3A1.5 1.5 0 0 0 3 4.5v7A1.5 1.5 0 0 0 4.5 13h7a1.5 1.5 0 0 0 1.5-1.5v-7A1.5 1.5 0 0 0 11.5 3h-7zM5 6.5A1.5 1.5 0 0 1 6.5 5h3A1.5 1.5 0 0 1 11 6.5v3A1.5 1.5 0 0 1 9.5 11h-3A1.5 1.5 0 0 1 5 9.5v-3zM6.5 6a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.5-.5h-3z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h4 class="card-title fw-bold mb-3">AI-Powered Parsing</h4>
|
||||
<p class="card-text text-muted mb-0">
|
||||
Advanced AI extracts and structures CV data from PDF and DOCX files, ensuring
|
||||
accurate information capture for analysis.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- How It Works Section -->
|
||||
<section class="py-5" style="background-color: white;">
|
||||
<div class="container">
|
||||
<div class="text-center mb-5">
|
||||
<h2 class="fw-bold mb-3" style="font-size: 2.25rem;">Get Started in Minutes</h2>
|
||||
<p class="text-muted" style="font-size: 1.125rem;">Simple three-step verification process</p>
|
||||
</div>
|
||||
|
||||
<div class="row g-4 justify-content-center">
|
||||
<div class="col-md-4">
|
||||
<div class="text-center">
|
||||
<div class="d-inline-flex align-items-center justify-content-center mb-4" style="width: 72px; height: 72px; border-radius: 16px; background: linear-gradient(135deg, #2563EB 0%, #1D4ED8 100%); color: white; box-shadow: 0 8px 20px rgba(37, 99, 235, 0.25);">
|
||||
<span class="fw-bold" style="font-size: 1.75rem; font-family: 'JetBrains Mono', monospace;">1</span>
|
||||
</div>
|
||||
<h5 class="fw-bold mb-2">Upload CV</h5>
|
||||
<p class="text-muted">Upload the candidate's CV in PDF or DOCX format</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="text-center">
|
||||
<div class="d-inline-flex align-items-center justify-content-center mb-4" style="width: 72px; height: 72px; border-radius: 16px; background: linear-gradient(135deg, #2563EB 0%, #1D4ED8 100%); color: white; box-shadow: 0 8px 20px rgba(37, 99, 235, 0.25);">
|
||||
<span class="fw-bold" style="font-size: 1.75rem; font-family: 'JetBrains Mono', monospace;">2</span>
|
||||
</div>
|
||||
<h5 class="fw-bold mb-2">AI Analysis</h5>
|
||||
<p class="text-muted">Our AI parses the CV and verifies against official records</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="text-center">
|
||||
<div class="d-inline-flex align-items-center justify-content-center mb-4" style="width: 72px; height: 72px; border-radius: 16px; background: linear-gradient(135deg, #2563EB 0%, #1D4ED8 100%); color: white; box-shadow: 0 8px 20px rgba(37, 99, 235, 0.25);">
|
||||
<span class="fw-bold" style="font-size: 1.75rem; font-family: 'JetBrains Mono', monospace;">3</span>
|
||||
</div>
|
||||
<h5 class="fw-bold mb-2">Get Report</h5>
|
||||
<p class="text-muted">Receive a detailed veracity report with actionable insights</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-5">
|
||||
<a href="/check" class="btn btn-primary btn-lg px-5 py-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-rocket-takeoff me-2" viewBox="0 0 16 16">
|
||||
<path d="M9.752 6.193c.599.6 1.73.437 2.528-.362.798-.799.96-1.932.362-2.531-.599-.6-1.73-.438-2.528.361-.798.8-.96 1.933-.362 2.532Z"/>
|
||||
<path d="M15.811 3.312c-.363 1.534-1.334 3.626-3.64 6.218l-.24 2.408a2.56 2.56 0 0 1-.732 1.526L8.817 15.85a.51.51 0 0 1-.867-.434l.27-1.899c.04-.28-.013-.593-.131-.956a9.42 9.42 0 0 0-.249-.657l-.082-.202c-.815-.197-1.578-.662-2.191-1.277-.614-.615-1.079-1.379-1.275-2.195l-.203-.083a9.556 9.556 0 0 0-.655-.248c-.363-.119-.675-.172-.955-.132l-1.896.27A.51.51 0 0 1 .15 7.17l2.382-2.386c.41-.41.947-.67 1.524-.734h.006l2.4-.238C9.005 1.55 11.087.582 12.623.208c.89-.217 1.59-.232 2.08-.188.244.023.435.06.57.093.067.017.12.033.16.045.184.06.279.13.351.295l.029.073a3.475 3.475 0 0 1 .157.721c.055.485.051 1.178-.159 2.065Zm-4.828 7.475.04-.04-.107 1.081a1.536 1.536 0 0 1-.44.913l-1.298 1.3.054-.38c.072-.506-.034-.993-.172-1.418a8.548 8.548 0 0 0-.164-.45c.738-.065 1.462-.38 2.087-1.006ZM5.205 5c-.625.626-.94 1.351-1.004 2.09a8.497 8.497 0 0 0-.45-.164c-.424-.138-.91-.244-1.416-.172l-.38.054 1.3-1.3c.245-.246.566-.401.91-.44l1.08-.107-.04.039Zm9.406-3.961c-.38-.034-.967-.027-1.746.163-1.558.38-3.917 1.496-6.937 4.521-.62.62-.799 1.34-.687 2.051.107.676.483 1.362 1.048 1.928.564.565 1.25.941 1.924 1.049.71.112 1.429-.067 2.048-.688 3.079-3.083 4.192-5.444 4.556-6.987.183-.771.18-1.345.138-1.713a2.835 2.835 0 0 0-.045-.283 3.078 3.078 0 0 0-.3-.041Z"/>
|
||||
<path d="M7.009 12.139a7.632 7.632 0 0 1-1.804-1.352A7.568 7.568 0 0 1 3.794 8.86c-1.102.992-1.965 5.054-1.839 5.18.125.126 4.189-.736 5.054-1.901Z"/>
|
||||
</svg>
|
||||
Start Your First Check
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Trust indicators -->
|
||||
<section class="py-4" style="background-color: var(--truecv-bg-muted); border-top: 1px solid var(--truecv-gray-200);">
|
||||
<div class="container">
|
||||
<div class="row align-items-center justify-content-center text-center g-4">
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="d-flex align-items-center justify-content-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="text-primary" viewBox="0 0 16 16">
|
||||
<path d="M5.338 1.59a61.44 61.44 0 0 0-2.837.856.481.481 0 0 0-.328.39c-.554 4.157.726 7.19 2.253 9.188a10.725 10.725 0 0 0 2.287 2.233c.346.244.652.42.893.533.12.057.218.095.293.118a.55.55 0 0 0 .101.025.615.615 0 0 0 .1-.025c.076-.023.174-.061.294-.118.24-.113.547-.29.893-.533a10.726 10.726 0 0 0 2.287-2.233c1.527-1.997 2.807-5.031 2.253-9.188a.48.48 0 0 0-.328-.39c-.651-.213-1.75-.56-2.837-.855C9.552 1.29 8.531 1.067 8 1.067c-.53 0-1.552.223-2.662.524zM5.072.56C6.157.265 7.31 0 8 0s1.843.265 2.928.56c1.11.3 2.229.655 2.887.87a1.54 1.54 0 0 1 1.044 1.262c.596 4.477-.787 7.795-2.465 9.99a11.775 11.775 0 0 1-2.517 2.453 7.159 7.159 0 0 1-1.048.625c-.28.132-.581.24-.829.24s-.548-.108-.829-.24a7.158 7.158 0 0 1-1.048-.625 11.777 11.777 0 0 1-2.517-2.453C1.928 10.487.545 7.169 1.141 2.692A1.54 1.54 0 0 1 2.185 1.43 62.456 62.456 0 0 1 5.072.56z"/>
|
||||
<path d="M10.854 5.146a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708 0l-1.5-1.5a.5.5 0 1 1 .708-.708L7.5 7.793l2.646-2.647a.5.5 0 0 1 .708 0z"/>
|
||||
</svg>
|
||||
<span class="fw-medium text-muted small">Secure & Encrypted</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="d-flex align-items-center justify-content-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="text-primary" viewBox="0 0 16 16">
|
||||
<path d="M1 2.828c.885-.37 2.154-.769 3.388-.893 1.33-.134 2.458.063 3.112.752v9.746c-.935-.53-2.12-.603-3.213-.493-1.18.12-2.37.461-3.287.811V2.828zm7.5-.141c.654-.689 1.782-.886 3.112-.752 1.234.124 2.503.523 3.388.893v9.923c-.918-.35-2.107-.692-3.287-.81-1.094-.111-2.278-.039-3.213.492V2.687zM8 1.783C7.015.936 5.587.81 4.287.94c-1.514.153-3.042.672-3.994 1.105A.5.5 0 0 0 0 2.5v11a.5.5 0 0 0 .707.455c.882-.4 2.303-.881 3.68-1.02 1.409-.142 2.59.087 3.223.877a.5.5 0 0 0 .78 0c.633-.79 1.814-1.019 3.222-.877 1.378.139 2.8.62 3.681 1.02A.5.5 0 0 0 16 13.5v-11a.5.5 0 0 0-.293-.455c-.952-.433-2.48-.952-3.994-1.105C10.413.809 8.985.936 8 1.783z"/>
|
||||
</svg>
|
||||
<span class="fw-medium text-muted small">Official Records</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="d-flex align-items-center justify-content-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="text-primary" viewBox="0 0 16 16">
|
||||
<path d="M9.05.435c-.58-.58-1.52-.58-2.1 0L.436 6.95c-.58.58-.58 1.519 0 2.098l6.516 6.516c.58.58 1.519.58 2.098 0l6.516-6.516c.58-.58.58-1.519 0-2.098L9.05.435zM5.495 6a.5.5 0 0 1 .5-.5h5.01a.5.5 0 0 1 0 1H5.995a.5.5 0 0 1-.5-.5zm0 2a.5.5 0 0 1 .5-.5h4.01a.5.5 0 0 1 0 1h-4.01a.5.5 0 0 1-.5-.5zm0 2a.5.5 0 0 1 .5-.5h3.01a.5.5 0 0 1 0 1h-3.01a.5.5 0 0 1-.5-.5z"/>
|
||||
</svg>
|
||||
<span class="fw-medium text-muted small">AI-Powered</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="d-flex align-items-center justify-content-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="text-primary" viewBox="0 0 16 16">
|
||||
<path d="M8.515 1.019A7 7 0 0 0 8 1V0a8 8 0 0 1 .589.022l-.074.997zm2.004.45a7.003 7.003 0 0 0-.985-.299l.219-.976c.383.086.76.2 1.126.342l-.36.933zm1.37.71a7.01 7.01 0 0 0-.439-.27l.493-.87a8.025 8.025 0 0 1 .979.654l-.615.789a6.996 6.996 0 0 0-.418-.302zm1.834 1.79a6.99 6.99 0 0 0-.653-.796l.724-.69c.27.285.52.59.747.91l-.818.576zm.744 1.352a7.08 7.08 0 0 0-.214-.468l.893-.45a7.976 7.976 0 0 1 .45 1.088l-.95.313a7.023 7.023 0 0 0-.179-.483zm.53 2.507a6.991 6.991 0 0 0-.1-1.025l.985-.17c.067.386.106.778.116 1.17l-1 .025zm-.131 1.538c.033-.17.06-.339.081-.51l.993.123a7.957 7.957 0 0 1-.23 1.155l-.964-.267c.046-.165.086-.332.12-.501zm-.952 2.379c.184-.29.346-.594.486-.908l.914.405c-.16.36-.345.706-.555 1.038l-.845-.535zm-.964 1.205c.122-.122.239-.248.35-.378l.758.653a8.073 8.073 0 0 1-.401.432l-.707-.707z"/>
|
||||
<path d="M8 1a7 7 0 1 0 4.95 11.95l.707.707A8.001 8.001 0 1 1 8 0v1z"/>
|
||||
<path d="M7.5 3a.5.5 0 0 1 .5.5v5.21l3.248 1.856a.5.5 0 0 1-.496.868l-3.5-2A.5.5 0 0 1 7 9V3.5a.5.5 0 0 1 .5-.5z"/>
|
||||
</svg>
|
||||
<span class="fw-medium text-muted small">Fast Results</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
1056
src/RealCV.Web/Components/Pages/Report.razor
Normal file
1056
src/RealCV.Web/Components/Pages/Report.razor
Normal file
File diff suppressed because it is too large
Load Diff
24
src/RealCV.Web/Components/Routes.razor
Normal file
24
src/RealCV.Web/Components/Routes.razor
Normal file
@@ -0,0 +1,24 @@
|
||||
<CascadingAuthenticationState>
|
||||
<Router AppAssembly="typeof(Program).Assembly">
|
||||
<Found Context="routeData">
|
||||
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)">
|
||||
<NotAuthorized>
|
||||
@if (context.User.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
<RedirectToLogin />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="container py-5">
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<h4 class="alert-heading">Access Denied</h4>
|
||||
<p>You are not authorized to access this page.</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</NotAuthorized>
|
||||
</AuthorizeRouteView>
|
||||
<FocusOnNavigate RouteData="routeData" Selector="h1" />
|
||||
</Found>
|
||||
</Router>
|
||||
</CascadingAuthenticationState>
|
||||
9
src/RealCV.Web/Components/Shared/RedirectToLogin.razor
Normal file
9
src/RealCV.Web/Components/Shared/RedirectToLogin.razor
Normal file
@@ -0,0 +1,9 @@
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
@code {
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
var returnUrl = Uri.EscapeDataString(NavigationManager.Uri);
|
||||
NavigationManager.NavigateTo($"/account/login?returnUrl={returnUrl}", forceLoad: true);
|
||||
}
|
||||
}
|
||||
21
src/RealCV.Web/Components/_Imports.razor
Normal file
21
src/RealCV.Web/Components/_Imports.razor
Normal file
@@ -0,0 +1,21 @@
|
||||
@using System.IO
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http.Json
|
||||
@using System.Security.Claims
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.Extensions.Logging
|
||||
@using Microsoft.JSInterop
|
||||
@using RealCV.Web
|
||||
@using RealCV.Web.Components
|
||||
@using RealCV.Web.Components.Shared
|
||||
@using RealCV.Web.Services
|
||||
@using RealCV.Application.Interfaces
|
||||
@using RealCV.Application.DTOs
|
||||
@using RealCV.Application.Models
|
||||
@using RealCV.Domain.Enums
|
||||
12
src/RealCV.Web/HangfireAuthorizationFilter.cs
Normal file
12
src/RealCV.Web/HangfireAuthorizationFilter.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using Hangfire.Dashboard;
|
||||
|
||||
namespace RealCV.Web;
|
||||
|
||||
public class HangfireAuthorizationFilter : IDashboardAuthorizationFilter
|
||||
{
|
||||
public bool Authorize(DashboardContext context)
|
||||
{
|
||||
var httpContext = context.GetHttpContext();
|
||||
return httpContext.User.Identity?.IsAuthenticated ?? false;
|
||||
}
|
||||
}
|
||||
230
src/RealCV.Web/Program.cs
Normal file
230
src/RealCV.Web/Program.cs
Normal file
@@ -0,0 +1,230 @@
|
||||
using System.Threading.RateLimiting;
|
||||
using Hangfire;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Serilog;
|
||||
using RealCV.Infrastructure;
|
||||
using RealCV.Infrastructure.Data;
|
||||
using RealCV.Infrastructure.Identity;
|
||||
using RealCV.Web;
|
||||
using RealCV.Web.Components;
|
||||
using RealCV.Web.Services;
|
||||
|
||||
// Configure Serilog
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.WriteTo.Console()
|
||||
.CreateBootstrapLogger();
|
||||
|
||||
try
|
||||
{
|
||||
Log.Information("Starting RealCV web application");
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Configure Serilog from appsettings
|
||||
builder.Host.UseSerilog((context, services, configuration) => configuration
|
||||
.ReadFrom.Configuration(context.Configuration)
|
||||
.ReadFrom.Services(services)
|
||||
.Enrich.FromLogContext());
|
||||
|
||||
// Add services to the container.
|
||||
builder.Services.AddRazorComponents()
|
||||
.AddInteractiveServerComponents();
|
||||
|
||||
// Add Infrastructure services (DbContext, Hangfire, HttpClients, Services)
|
||||
builder.Services.AddInfrastructure(builder.Configuration);
|
||||
|
||||
// Add Web services
|
||||
builder.Services.AddScoped<IPdfReportService, PdfReportService>();
|
||||
|
||||
// Add Identity with secure password requirements
|
||||
builder.Services.AddIdentity<ApplicationUser, IdentityRole<Guid>>(options =>
|
||||
{
|
||||
options.Password.RequireDigit = true;
|
||||
options.Password.RequireLowercase = true;
|
||||
options.Password.RequireUppercase = true;
|
||||
options.Password.RequireNonAlphanumeric = true;
|
||||
options.Password.RequiredLength = 12;
|
||||
options.Password.RequiredUniqueChars = 4;
|
||||
options.SignIn.RequireConfirmedAccount = false;
|
||||
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5);
|
||||
options.Lockout.MaxFailedAccessAttempts = 5;
|
||||
options.Lockout.AllowedForNewUsers = true;
|
||||
})
|
||||
.AddEntityFrameworkStores<ApplicationDbContext>()
|
||||
.AddDefaultTokenProviders();
|
||||
|
||||
builder.Services.ConfigureApplicationCookie(options =>
|
||||
{
|
||||
options.LoginPath = "/account/login";
|
||||
options.LogoutPath = "/account/logout";
|
||||
options.AccessDeniedPath = "/account/login";
|
||||
});
|
||||
|
||||
// Add Cascading Authentication State
|
||||
builder.Services.AddCascadingAuthenticationState();
|
||||
builder.Services.AddScoped<AuthenticationStateProvider, RevalidatingIdentityAuthenticationStateProvider<ApplicationUser>>();
|
||||
|
||||
// Add health checks
|
||||
builder.Services.AddHealthChecks()
|
||||
.AddDbContextCheck<ApplicationDbContext>("database");
|
||||
|
||||
// Add rate limiting for login endpoint
|
||||
builder.Services.AddRateLimiter(options =>
|
||||
{
|
||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
options.AddFixedWindowLimiter("login", limiterOptions =>
|
||||
{
|
||||
limiterOptions.PermitLimit = 5;
|
||||
limiterOptions.Window = TimeSpan.FromMinutes(1);
|
||||
limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
|
||||
limiterOptions.QueueLimit = 0;
|
||||
});
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Seed default admin user and clear company cache
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
|
||||
var defaultEmail = builder.Configuration["DefaultAdmin:Email"];
|
||||
var defaultPassword = builder.Configuration["DefaultAdmin:Password"];
|
||||
|
||||
if (!string.IsNullOrEmpty(defaultEmail) && !string.IsNullOrEmpty(defaultPassword))
|
||||
{
|
||||
if (await userManager.FindByEmailAsync(defaultEmail) == null)
|
||||
{
|
||||
var adminUser = new ApplicationUser
|
||||
{
|
||||
UserName = defaultEmail,
|
||||
Email = defaultEmail,
|
||||
EmailConfirmed = true
|
||||
};
|
||||
var result = await userManager.CreateAsync(adminUser, defaultPassword);
|
||||
if (result.Succeeded)
|
||||
{
|
||||
Log.Information("Created default admin user: {Email}", defaultEmail);
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Warning("Failed to create admin user: {Errors}", string.Join(", ", result.Errors.Select(e => e.Description)));
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Information("No default admin credentials configured - skipping admin user seeding");
|
||||
}
|
||||
|
||||
// Clear company cache on startup to ensure fresh API lookups
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
var cacheCount = await dbContext.CompanyCache.CountAsync();
|
||||
if (cacheCount > 0)
|
||||
{
|
||||
dbContext.CompanyCache.RemoveRange(dbContext.CompanyCache);
|
||||
await dbContext.SaveChangesAsync();
|
||||
Log.Information("Cleared {Count} entries from company cache", cacheCount);
|
||||
}
|
||||
}
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (!app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
||||
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
|
||||
app.UseHsts();
|
||||
}
|
||||
|
||||
// Only use HTTPS redirection when not running in Docker/container
|
||||
if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER")))
|
||||
{
|
||||
// Running in container - skip HTTPS redirect (handled by reverse proxy)
|
||||
}
|
||||
else
|
||||
{
|
||||
app.UseHttpsRedirection();
|
||||
}
|
||||
|
||||
app.UseStaticFiles();
|
||||
app.UseAntiforgery();
|
||||
|
||||
// Add Serilog request logging
|
||||
app.UseSerilogRequestLogging();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseRateLimiter();
|
||||
|
||||
// Add Hangfire Dashboard (only in development, requires authentication)
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseHangfireDashboard("/hangfire", new DashboardOptions
|
||||
{
|
||||
Authorization = [new HangfireAuthorizationFilter()]
|
||||
});
|
||||
}
|
||||
|
||||
// Login endpoint
|
||||
app.MapPost("/account/perform-login", async (
|
||||
HttpContext context,
|
||||
SignInManager<ApplicationUser> signInManager) =>
|
||||
{
|
||||
var form = await context.Request.ReadFormAsync();
|
||||
var email = form["email"].ToString();
|
||||
var password = form["password"].ToString();
|
||||
var rememberMe = form["rememberMe"].ToString() == "true";
|
||||
var returnUrl = form["returnUrl"].ToString();
|
||||
|
||||
Log.Information("Login attempt for {Email}", email);
|
||||
|
||||
// Validate returnUrl is local to prevent open redirect attacks
|
||||
if (string.IsNullOrEmpty(returnUrl) || !Uri.IsWellFormedUriString(returnUrl, UriKind.Relative) || returnUrl.StartsWith("//"))
|
||||
{
|
||||
returnUrl = "/dashboard";
|
||||
}
|
||||
|
||||
var result = await signInManager.PasswordSignInAsync(email, password, rememberMe, lockoutOnFailure: true);
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
Log.Information("User {Email} logged in successfully", email);
|
||||
return Results.LocalRedirect(returnUrl);
|
||||
}
|
||||
else if (result.IsLockedOut)
|
||||
{
|
||||
Log.Warning("User {Email} account is locked out", email);
|
||||
return Results.Redirect("/account/login?error=Account+locked.+Try+again+later.");
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Warning("Failed login attempt for {Email}", email);
|
||||
return Results.Redirect("/account/login?error=Invalid+email+or+password.");
|
||||
}
|
||||
}).RequireRateLimiting("login");
|
||||
|
||||
// Logout endpoint
|
||||
app.MapPost("/account/logout", async (SignInManager<ApplicationUser> signInManager) =>
|
||||
{
|
||||
await signInManager.SignOutAsync();
|
||||
return Results.Redirect("/");
|
||||
}).RequireAuthorization();
|
||||
|
||||
// Health check endpoint
|
||||
app.MapHealthChecks("/health");
|
||||
|
||||
app.MapRazorComponents<App>()
|
||||
.AddInteractiveServerRenderMode();
|
||||
|
||||
app.Run();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Fatal(ex, "Application terminated unexpectedly");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Log.CloseAndFlush();
|
||||
}
|
||||
38
src/RealCV.Web/Properties/launchSettings.json
Normal file
38
src/RealCV.Web/Properties/launchSettings.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
"iisSettings": {
|
||||
"windowsAuthentication": false,
|
||||
"anonymousAuthentication": true,
|
||||
"iisExpress": {
|
||||
"applicationUrl": "http://localhost:9979",
|
||||
"sslPort": 44360
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:5114",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "https://localhost:7176;http://localhost:5114",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"IIS Express": {
|
||||
"commandName": "IISExpress",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
24
src/RealCV.Web/RealCV.Web.csproj
Normal file
24
src/RealCV.Web/RealCV.Web.csproj
Normal file
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\RealCV.Infrastructure\RealCV.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="8.0.*" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.*">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="8.0.*" />
|
||||
<PackageReference Include="QuestPDF" Version="2025.12.3" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.AspNetCore.Components.Server;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace RealCV.Web;
|
||||
|
||||
public class RevalidatingIdentityAuthenticationStateProvider<TUser>
|
||||
: RevalidatingServerAuthenticationStateProvider where TUser : class
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly IdentityOptions _options;
|
||||
|
||||
public RevalidatingIdentityAuthenticationStateProvider(
|
||||
ILoggerFactory loggerFactory,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptions<IdentityOptions> optionsAccessor)
|
||||
: base(loggerFactory)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_options = optionsAccessor.Value;
|
||||
}
|
||||
|
||||
protected override TimeSpan RevalidationInterval => TimeSpan.FromMinutes(30);
|
||||
|
||||
protected override async Task<bool> ValidateAuthenticationStateAsync(
|
||||
AuthenticationState authenticationState, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<TUser>>();
|
||||
return await ValidateSecurityStampAsync(userManager, authenticationState.User);
|
||||
}
|
||||
|
||||
private async Task<bool> ValidateSecurityStampAsync(UserManager<TUser> userManager, ClaimsPrincipal principal)
|
||||
{
|
||||
var user = await userManager.GetUserAsync(principal);
|
||||
if (user is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!userManager.SupportsUserSecurityStamp)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var principalStamp = principal.FindFirstValue(_options.ClaimsIdentity.SecurityStampClaimType);
|
||||
var userStamp = await userManager.GetSecurityStampAsync(user);
|
||||
return principalStamp == userStamp;
|
||||
}
|
||||
}
|
||||
9
src/RealCV.Web/Services/IPdfReportService.cs
Normal file
9
src/RealCV.Web/Services/IPdfReportService.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using RealCV.Application.Models;
|
||||
|
||||
namespace RealCV.Web.Services;
|
||||
|
||||
public interface IPdfReportService
|
||||
{
|
||||
byte[] GenerateSingleReport(string candidateName, VeracityReport report);
|
||||
byte[] GenerateReport(List<PdfReportData> data);
|
||||
}
|
||||
334
src/RealCV.Web/Services/PdfReportService.cs
Normal file
334
src/RealCV.Web/Services/PdfReportService.cs
Normal file
@@ -0,0 +1,334 @@
|
||||
using QuestPDF.Fluent;
|
||||
using QuestPDF.Helpers;
|
||||
using QuestPDF.Infrastructure;
|
||||
using RealCV.Application.Helpers;
|
||||
using RealCV.Application.Models;
|
||||
|
||||
namespace RealCV.Web.Services;
|
||||
|
||||
public class PdfReportService : IPdfReportService
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates a detailed PDF report for a single CV verification.
|
||||
/// </summary>
|
||||
public byte[] GenerateSingleReport(string candidateName, VeracityReport report)
|
||||
{
|
||||
QuestPDF.Settings.License = LicenseType.Community;
|
||||
|
||||
var document = Document.Create(container =>
|
||||
{
|
||||
container.Page(page =>
|
||||
{
|
||||
page.Size(PageSizes.A4);
|
||||
page.Margin(40);
|
||||
page.DefaultTextStyle(x => x.FontSize(10));
|
||||
|
||||
page.Header().Element(c => ComposeSingleReportHeader(c, candidateName, report));
|
||||
page.Content().Element(c => ComposeSingleReportContent(c, report));
|
||||
page.Footer().Element(ComposeFooter);
|
||||
});
|
||||
});
|
||||
|
||||
return document.GeneratePdf();
|
||||
}
|
||||
|
||||
private void ComposeSingleReportHeader(IContainer container, string candidateName, VeracityReport report)
|
||||
{
|
||||
var scoreColor = report.OverallScore > ScoreThresholds.High ? Colors.Green.Darken1 :
|
||||
(report.OverallScore >= ScoreThresholds.Medium ? Colors.Orange.Darken1 : Colors.Red.Darken1);
|
||||
|
||||
container.Column(column =>
|
||||
{
|
||||
column.Item().Row(row =>
|
||||
{
|
||||
row.RelativeItem().Column(col =>
|
||||
{
|
||||
col.Item().Text("RealCV").Bold().FontSize(24).FontColor(Colors.Blue.Darken2);
|
||||
col.Item().Text("CV Verification Report").FontSize(14).FontColor(Colors.Grey.Darken1);
|
||||
});
|
||||
row.ConstantItem(100).AlignRight().Column(col =>
|
||||
{
|
||||
col.Item().AlignRight().Text(report.GeneratedAt.ToString("dd MMM yyyy")).FontSize(10).FontColor(Colors.Grey.Medium);
|
||||
col.Item().AlignRight().Text(report.GeneratedAt.ToString("HH:mm")).FontSize(9).FontColor(Colors.Grey.Medium);
|
||||
});
|
||||
});
|
||||
|
||||
column.Item().PaddingTop(15).Row(row =>
|
||||
{
|
||||
row.RelativeItem().Column(col =>
|
||||
{
|
||||
col.Item().Text("Candidate: " + candidateName).Bold().FontSize(12);
|
||||
});
|
||||
row.ConstantItem(120).Border(2).BorderColor(scoreColor).Padding(10).AlignCenter().Column(col =>
|
||||
{
|
||||
col.Item().AlignCenter().Text(report.OverallScore.ToString()).Bold().FontSize(28).FontColor(scoreColor);
|
||||
col.Item().AlignCenter().Text("RealCV Score").FontSize(10).FontColor(scoreColor);
|
||||
});
|
||||
});
|
||||
|
||||
column.Item().PaddingTop(10).LineHorizontal(1).LineColor(Colors.Grey.Lighten2);
|
||||
});
|
||||
}
|
||||
|
||||
private void ComposeSingleReportContent(IContainer container, VeracityReport report)
|
||||
{
|
||||
container.PaddingVertical(15).Column(column =>
|
||||
{
|
||||
// Employment Verification Section
|
||||
column.Item().Text("Employment Verification").Bold().FontSize(12).FontColor(Colors.Blue.Darken2);
|
||||
column.Item().PaddingTop(10).Table(table =>
|
||||
{
|
||||
table.ColumnsDefinition(columns =>
|
||||
{
|
||||
columns.RelativeColumn(3); // Claimed
|
||||
columns.RelativeColumn(2); // Period
|
||||
columns.RelativeColumn(3); // Matched
|
||||
columns.ConstantColumn(45); // Score
|
||||
columns.ConstantColumn(55); // Status
|
||||
});
|
||||
|
||||
table.Header(header =>
|
||||
{
|
||||
header.Cell().Background(Colors.Blue.Darken2).Padding(5).Text("Claimed Employer").Bold().FontSize(9).FontColor(Colors.White);
|
||||
header.Cell().Background(Colors.Blue.Darken2).Padding(5).Text("Period").Bold().FontSize(9).FontColor(Colors.White);
|
||||
header.Cell().Background(Colors.Blue.Darken2).Padding(5).Text("Matched Company").Bold().FontSize(9).FontColor(Colors.White);
|
||||
header.Cell().Background(Colors.Blue.Darken2).Padding(5).AlignCenter().Text("Match").Bold().FontSize(9).FontColor(Colors.White);
|
||||
header.Cell().Background(Colors.Blue.Darken2).Padding(5).AlignCenter().Text("Status").Bold().FontSize(9).FontColor(Colors.White);
|
||||
});
|
||||
|
||||
bool alternate = false;
|
||||
foreach (var emp in report.EmploymentVerifications)
|
||||
{
|
||||
var bgColor = alternate ? Colors.Grey.Lighten4 : Colors.White;
|
||||
var period = emp.ClaimedStartDate.HasValue
|
||||
? $"{emp.ClaimedStartDate.Value:MMM yyyy} - {(emp.ClaimedEndDate.HasValue ? emp.ClaimedEndDate.Value.ToString("MMM yyyy") : "Present")}"
|
||||
: "Not specified";
|
||||
|
||||
table.Cell().Background(bgColor).Padding(4).Text(emp.ClaimedCompany).FontSize(9);
|
||||
table.Cell().Background(bgColor).Padding(4).Text(period).FontSize(8);
|
||||
table.Cell().Background(bgColor).Padding(4).Text(emp.MatchedCompanyName ?? "No match").FontSize(9).FontColor(emp.IsVerified ? Colors.Black : Colors.Grey.Medium);
|
||||
table.Cell().Background(bgColor).Padding(4).AlignCenter().Text(emp.MatchScore + "%").FontSize(9);
|
||||
table.Cell().Background(bgColor).Padding(4).AlignCenter().Text(emp.IsVerified ? "Verified" : "Unverified").FontSize(8)
|
||||
.FontColor(emp.IsVerified ? Colors.Green.Darken1 : Colors.Orange.Darken1);
|
||||
|
||||
alternate = !alternate;
|
||||
}
|
||||
});
|
||||
|
||||
// Timeline Section
|
||||
column.Item().PaddingTop(20).Text("Timeline Analysis").Bold().FontSize(12).FontColor(Colors.Blue.Darken2);
|
||||
column.Item().PaddingTop(10).Row(row =>
|
||||
{
|
||||
row.RelativeItem().Border(1).BorderColor(Colors.Grey.Lighten2).Padding(10).Column(col =>
|
||||
{
|
||||
col.Item().Text("Employment Gaps").Bold().FontSize(10);
|
||||
if (report.TimelineAnalysis.Gaps.Count > 0)
|
||||
{
|
||||
foreach (var gap in report.TimelineAnalysis.Gaps)
|
||||
{
|
||||
col.Item().PaddingTop(5).Text($"{gap.StartDate:MMM yyyy} - {gap.EndDate:MMM yyyy} ({gap.Months} months)").FontSize(9);
|
||||
}
|
||||
col.Item().PaddingTop(5).Text($"Total: {report.TimelineAnalysis.TotalGapMonths} months").Bold().FontSize(9).FontColor(Colors.Orange.Darken1);
|
||||
}
|
||||
else
|
||||
{
|
||||
col.Item().PaddingTop(5).Text("No significant gaps detected").FontSize(9).FontColor(Colors.Green.Darken1);
|
||||
}
|
||||
});
|
||||
|
||||
row.ConstantItem(15);
|
||||
|
||||
row.RelativeItem().Border(1).BorderColor(Colors.Grey.Lighten2).Padding(10).Column(col =>
|
||||
{
|
||||
col.Item().Text("Concurrent Employment").Bold().FontSize(10);
|
||||
if (report.TimelineAnalysis.Overlaps.Count > 0)
|
||||
{
|
||||
foreach (var overlap in report.TimelineAnalysis.Overlaps)
|
||||
{
|
||||
col.Item().PaddingTop(5).Text($"{overlap.Company1} & {overlap.Company2}").FontSize(9);
|
||||
col.Item().Text($" {overlap.OverlapStart:MMM yyyy} - {overlap.OverlapEnd:MMM yyyy} ({overlap.Months} mo)").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
col.Item().PaddingTop(5).Text("No overlapping positions").FontSize(9).FontColor(Colors.Green.Darken1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Flags Section
|
||||
if (report.Flags.Count > 0)
|
||||
{
|
||||
column.Item().PaddingTop(20).Text("Flags Raised").Bold().FontSize(12).FontColor(Colors.Blue.Darken2);
|
||||
|
||||
var criticalFlags = report.Flags.Where(f => f.Severity == "Critical").ToList();
|
||||
var warningFlags = report.Flags.Where(f => f.Severity == "Warning").ToList();
|
||||
var infoFlags = report.Flags.Where(f => f.Severity == "Info").ToList();
|
||||
|
||||
if (criticalFlags.Count > 0)
|
||||
{
|
||||
column.Item().PaddingTop(10).Background(Colors.Red.Lighten5).Border(1).BorderColor(Colors.Red.Lighten3).Padding(10).Column(col =>
|
||||
{
|
||||
col.Item().Text("Critical Issues").Bold().FontSize(10).FontColor(Colors.Red.Darken1);
|
||||
foreach (var flag in criticalFlags)
|
||||
{
|
||||
col.Item().PaddingTop(5).Text($"• {flag.Title} ({flag.ScoreImpact} pts)").FontSize(9).Bold();
|
||||
col.Item().Text($" {flag.Description}").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (warningFlags.Count > 0)
|
||||
{
|
||||
column.Item().PaddingTop(10).Background(Colors.Orange.Lighten5).Border(1).BorderColor(Colors.Orange.Lighten3).Padding(10).Column(col =>
|
||||
{
|
||||
col.Item().Text("Warnings").Bold().FontSize(10).FontColor(Colors.Orange.Darken1);
|
||||
foreach (var flag in warningFlags)
|
||||
{
|
||||
col.Item().PaddingTop(5).Text($"• {flag.Title} ({flag.ScoreImpact} pts)").FontSize(9).Bold();
|
||||
col.Item().Text($" {flag.Description}").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (infoFlags.Count > 0)
|
||||
{
|
||||
column.Item().PaddingTop(10).Background(Colors.Blue.Lighten5).Border(1).BorderColor(Colors.Blue.Lighten3).Padding(10).Column(col =>
|
||||
{
|
||||
col.Item().Text("Information").Bold().FontSize(10).FontColor(Colors.Blue.Darken1);
|
||||
foreach (var flag in infoFlags)
|
||||
{
|
||||
col.Item().PaddingTop(5).Text($"• {flag.Title}").FontSize(9).Bold();
|
||||
col.Item().Text($" {flag.Description}").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a summary PDF report for multiple CV verifications (batch report).
|
||||
/// </summary>
|
||||
public byte[] GenerateReport(List<PdfReportData> data)
|
||||
{
|
||||
QuestPDF.Settings.License = LicenseType.Community;
|
||||
|
||||
var document = Document.Create(container =>
|
||||
{
|
||||
container.Page(page =>
|
||||
{
|
||||
page.Size(PageSizes.A4);
|
||||
page.Margin(40);
|
||||
page.DefaultTextStyle(x => x.FontSize(10));
|
||||
|
||||
page.Header().Element(ComposeHeader);
|
||||
page.Content().Element(c => ComposeContent(c, data));
|
||||
page.Footer().Element(ComposeFooter);
|
||||
});
|
||||
});
|
||||
|
||||
return document.GeneratePdf();
|
||||
}
|
||||
|
||||
private void ComposeHeader(IContainer container)
|
||||
{
|
||||
container.Column(column =>
|
||||
{
|
||||
column.Item().Row(row =>
|
||||
{
|
||||
row.RelativeItem().Column(col =>
|
||||
{
|
||||
col.Item().Text("RealCV").Bold().FontSize(24).FontColor(Colors.Blue.Darken2);
|
||||
col.Item().Text("CV Verification Report Summary").FontSize(14).FontColor(Colors.Grey.Darken1);
|
||||
});
|
||||
row.ConstantItem(100).AlignRight().Text(DateTime.Now.ToString("dd MMM yyyy")).FontSize(10).FontColor(Colors.Grey.Medium);
|
||||
});
|
||||
column.Item().PaddingTop(10).LineHorizontal(1).LineColor(Colors.Grey.Lighten2);
|
||||
});
|
||||
}
|
||||
|
||||
private void ComposeContent(IContainer container, List<PdfReportData> data)
|
||||
{
|
||||
container.PaddingVertical(20).Column(column =>
|
||||
{
|
||||
column.Item().Text("Summary of " + data.Count + " Verified CVs").Bold().FontSize(12);
|
||||
column.Item().PaddingTop(15);
|
||||
|
||||
column.Item().Table(table =>
|
||||
{
|
||||
table.ColumnsDefinition(columns =>
|
||||
{
|
||||
columns.RelativeColumn(3);
|
||||
columns.RelativeColumn(2);
|
||||
columns.ConstantColumn(50);
|
||||
columns.ConstantColumn(45);
|
||||
columns.ConstantColumn(45);
|
||||
columns.ConstantColumn(45);
|
||||
columns.ConstantColumn(45);
|
||||
});
|
||||
|
||||
table.Header(header =>
|
||||
{
|
||||
header.Cell().Background(Colors.Blue.Darken2).Padding(5).Text("Candidate").Bold().FontColor(Colors.White);
|
||||
header.Cell().Background(Colors.Blue.Darken2).Padding(5).Text("Date").Bold().FontColor(Colors.White);
|
||||
header.Cell().Background(Colors.Blue.Darken2).Padding(5).AlignCenter().Text("Score").Bold().FontColor(Colors.White);
|
||||
header.Cell().Background(Colors.Blue.Darken2).Padding(5).AlignCenter().Text("Verified").Bold().FontColor(Colors.White);
|
||||
header.Cell().Background(Colors.Blue.Darken2).Padding(5).AlignCenter().Text("Gaps").Bold().FontColor(Colors.White);
|
||||
header.Cell().Background(Colors.Blue.Darken2).Padding(5).AlignCenter().Text("Critical").Bold().FontColor(Colors.White);
|
||||
header.Cell().Background(Colors.Blue.Darken2).Padding(5).AlignCenter().Text("Warnings").Bold().FontColor(Colors.White);
|
||||
});
|
||||
|
||||
bool alternate = false;
|
||||
foreach (var item in data)
|
||||
{
|
||||
var bgColor = alternate ? Colors.Grey.Lighten4 : Colors.White;
|
||||
var scoreColor = item.Score > ScoreThresholds.High ? Colors.Green.Darken1 : (item.Score >= ScoreThresholds.Medium ? Colors.Orange.Darken1 : Colors.Red.Darken1);
|
||||
|
||||
table.Cell().Background(bgColor).Padding(5).Text(item.CandidateName);
|
||||
table.Cell().Background(bgColor).Padding(5).Text(item.UploadDate.ToString("dd MMM yy"));
|
||||
table.Cell().Background(bgColor).Padding(5).AlignCenter().Text(item.Score.ToString()).Bold().FontColor(scoreColor);
|
||||
table.Cell().Background(bgColor).Padding(5).AlignCenter().Text(item.VerifiedEmployers + "/" + (item.VerifiedEmployers + item.UnverifiedEmployers));
|
||||
table.Cell().Background(bgColor).Padding(5).AlignCenter().Text(item.GapMonths + "mo");
|
||||
table.Cell().Background(bgColor).Padding(5).AlignCenter().Text(item.CriticalFlags.ToString()).FontColor(item.CriticalFlags > 0 ? Colors.Red.Darken1 : Colors.Grey.Medium);
|
||||
table.Cell().Background(bgColor).Padding(5).AlignCenter().Text(item.WarningFlags.ToString()).FontColor(item.WarningFlags > 0 ? Colors.Orange.Darken1 : Colors.Grey.Medium);
|
||||
|
||||
alternate = !alternate;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private void ComposeFooter(IContainer container)
|
||||
{
|
||||
container.Column(column =>
|
||||
{
|
||||
column.Item().LineHorizontal(1).LineColor(Colors.Grey.Lighten2);
|
||||
column.Item().PaddingTop(10).Row(row =>
|
||||
{
|
||||
row.RelativeItem().Text("Generated by RealCV - CV Verification Platform").FontSize(8).FontColor(Colors.Grey.Medium);
|
||||
row.RelativeItem().AlignRight().Text(x =>
|
||||
{
|
||||
x.Span("Page ").FontSize(8).FontColor(Colors.Grey.Medium);
|
||||
x.CurrentPageNumber().FontSize(8).FontColor(Colors.Grey.Medium);
|
||||
x.Span(" of ").FontSize(8).FontColor(Colors.Grey.Medium);
|
||||
x.TotalPages().FontSize(8).FontColor(Colors.Grey.Medium);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public class PdfReportData
|
||||
{
|
||||
public string CandidateName { get; set; } = "";
|
||||
public DateTime UploadDate { get; set; }
|
||||
public int Score { get; set; }
|
||||
public string ScoreLabel { get; set; } = "";
|
||||
public int VerifiedEmployers { get; set; }
|
||||
public int UnverifiedEmployers { get; set; }
|
||||
public int GapMonths { get; set; }
|
||||
public int OverlapMonths { get; set; }
|
||||
public int CriticalFlags { get; set; }
|
||||
public int WarningFlags { get; set; }
|
||||
}
|
||||
10
src/RealCV.Web/appsettings.Production.json
Normal file
10
src/RealCV.Web/appsettings.Production.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Server=127.0.0.1;Database=RealCV;User Id=SA;Password=TrueCV_Sql2024!;TrustServerCertificate=True;",
|
||||
"HangfireConnection": "Server=127.0.0.1;Database=RealCV_Hangfire;User Id=SA;Password=TrueCV_Sql2024!;TrustServerCertificate=True;"
|
||||
},
|
||||
"UseLocalStorage": true,
|
||||
"LocalStorage": {
|
||||
"StoragePath": "/var/www/realcv/uploads"
|
||||
}
|
||||
}
|
||||
34
src/RealCV.Web/appsettings.json
Normal file
34
src/RealCV.Web/appsettings.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Server=.;Database=RealCV;Trusted_Connection=True;TrustServerCertificate=True;",
|
||||
"HangfireConnection": "Server=.;Database=RealCV_Hangfire;Trusted_Connection=True;TrustServerCertificate=True;"
|
||||
},
|
||||
"CompaniesHouse": {
|
||||
"BaseUrl": "https://api.company-information.service.gov.uk",
|
||||
"ApiKey": ""
|
||||
},
|
||||
"Anthropic": {
|
||||
"ApiKey": ""
|
||||
},
|
||||
"DefaultAdmin": {
|
||||
"Email": "",
|
||||
"Password": ""
|
||||
},
|
||||
"AzureBlob": {
|
||||
"ConnectionString": "",
|
||||
"ContainerName": "cv-uploads"
|
||||
},
|
||||
"Serilog": {
|
||||
"MinimumLevel": {
|
||||
"Default": "Information",
|
||||
"Override": {
|
||||
"Microsoft": "Warning",
|
||||
"Microsoft.Hosting.Lifetime": "Information"
|
||||
}
|
||||
},
|
||||
"WriteTo": [
|
||||
{ "Name": "Console" }
|
||||
]
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
1234
src/RealCV.Web/wwwroot/app.css
Normal file
1234
src/RealCV.Web/wwwroot/app.css
Normal file
File diff suppressed because it is too large
Load Diff
7
src/RealCV.Web/wwwroot/bootstrap/bootstrap.min.css
vendored
Normal file
7
src/RealCV.Web/wwwroot/bootstrap/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
src/RealCV.Web/wwwroot/bootstrap/bootstrap.min.css.map
Normal file
1
src/RealCV.Web/wwwroot/bootstrap/bootstrap.min.css.map
Normal file
File diff suppressed because one or more lines are too long
BIN
src/RealCV.Web/wwwroot/favicon.png
Normal file
BIN
src/RealCV.Web/wwwroot/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
src/RealCV.Web/wwwroot/images/TrueCV_Logo.png
Normal file
BIN
src/RealCV.Web/wwwroot/images/TrueCV_Logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
21
src/RealCV.Web/wwwroot/js/app.js
Normal file
21
src/RealCV.Web/wwwroot/js/app.js
Normal file
@@ -0,0 +1,21 @@
|
||||
// TrueCV JavaScript utilities
|
||||
|
||||
function downloadFile(fileName, base64Content, contentType) {
|
||||
const link = document.createElement('a');
|
||||
link.download = fileName;
|
||||
link.href = `data:${contentType};base64,${base64Content}`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
function downloadCsvFile(fileName, csvContent) {
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = fileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(link.href);
|
||||
}
|
||||
Reference in New Issue
Block a user