-
- -
-
- Explore
-
-
+
+ {{ sectionTitle }}
- -
-
- Dashboard
-
-
+
-
- -
-
-
-
+
+
+
+
+
+
diff --git a/src/Analysim.Web/ClientApp/src/app/navbar/navbar.component.scss b/src/Analysim.Web/ClientApp/src/app/navbar/navbar.component.scss
index cdd912cc..9601926b 100644
--- a/src/Analysim.Web/ClientApp/src/app/navbar/navbar.component.scss
+++ b/src/Analysim.Web/ClientApp/src/app/navbar/navbar.component.scss
@@ -1,188 +1,404 @@
.navbar-body {
- padding-top: 4px;
- padding-bottom: 4px;
+ --navbar-height: 64px;
+
+ position: relative;
+ z-index: 1200;
+ display: flex;
+ flex-wrap: nowrap;
+ align-items: center;
+ min-height: 64px;
+ padding: 8px 20px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.14);
+ background: var(--c-brand-700, #062f63);
}
-.navbar-brand {
- margin-left: var(--navbar-brand-margin-left);
- margin-right: 1em;
+.sidebar-toggle {
+ display: inline-grid;
+ place-items: center;
+ flex: 0 0 auto;
+ width: 42px;
+ height: 42px;
+ margin: 0 16px 0 0;
+ padding: 0;
+ border: 1px solid rgba(255, 255, 255, 0.28);
+ border-radius: 8px;
+ background: transparent;
+ color: rgba(255, 255, 255, 0.9);
cursor: pointer;
+}
+
+.sidebar-toggle:hover,
+.sidebar-toggle.is-open {
+ border-color: rgba(255, 255, 255, 0.5);
+ background: rgba(255, 255, 255, 0.1);
+ color: #ffffff;
+}
+
+.navbar-brand {
display: inline-flex;
align-items: center;
+ flex: 0 0 auto;
+ margin: 0 16px 0 0;
+ cursor: pointer;
img {
- height: 36px;
+ display: block;
width: auto;
- max-height: 100%;
+ height: 34px;
object-fit: contain;
- display: block;
}
}
-.nav-item,
-.nav-link {
- border-bottom: none !important;
+.section-divider {
+ flex: 0 0 auto;
+ width: 1px;
+ height: 32px;
+ margin-right: 18px;
+ background: rgba(255, 255, 255, 0.55);
}
-/* Align form and input groups in navbar */
-.form,
-.input-group,
-.input-group-append {
- display: flex;
+.section-title {
+ display: inline-flex;
align-items: center;
+ min-width: 0;
+ min-height: 34px;
+ max-width: 34vw;
+ overflow: hidden;
+ color: #ffffff;
+ font-size: 22px;
+ font-weight: 750;
+ line-height: 1.2;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ letter-spacing: 0;
+}
+
+.nav-spacer {
+ flex: 1 1 auto;
+ min-width: 18px;
}
-/* Left nav layout */
-.left-nav {
+.nav-actions {
display: flex;
- gap: .5em;
- width: 75%;
+ align-items: center;
+ flex: 0 0 auto;
+ min-width: 0;
+ gap: 18px;
}
-/* Search takes remaining space on desktop */
.nav-search {
- flex: 1;
- min-width: 0;
- margin-left: .5em;
- max-width: 220px;
- transition: width 0.25s ease, max-width 0.25s ease;
+ width: min(280px, 28vw);
+ margin: 0;
}
-.nav-search:focus-within {
+.search-group {
+ position: relative;
+ display: block;
width: 100%;
- max-width: 50%;
+ height: 38px;
+ margin: 0;
}
-.nav-search form {
- width: 100%;
+.search-icon {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 12px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 18px;
+ color: rgba(255, 255, 255, 0.62);
+ line-height: 1;
+ pointer-events: none;
}
-.search-group {
+.search-icon i {
+ display: block;
+ line-height: 1;
+}
+
+.search-group input {
+ display: block;
width: 100%;
+ height: 38px;
+ padding: 0 14px 0 38px;
+ border: 1px solid rgba(255, 255, 255, 0.58);
+ border-radius: 8px;
+ background: rgba(4, 36, 73, 0.78);
+ color: #ffffff;
+ font-size: 16px;
+ font-weight: 650;
+ line-height: 38px;
+ outline: none;
+}
+
+.search-group input::placeholder {
+ color: rgba(255, 255, 255, 0.82);
+}
+
+.search-group input:focus {
+ border-color: #ffffff;
+ background: rgba(4, 36, 73, 0.95);
+ box-shadow: 0 0 0 3px rgba(37, 109, 133, 0.35);
+}
+
+.right-nav {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ margin-bottom: 0;
+ gap: 8px;
}
-.search-group .form-control {
- border-color: var(--border-color);
+.nav-link,
+.nav-interactive {
+ color: rgba(255, 255, 255, 0.88);
+ font-weight: 700;
+}
+
+.nav-link:hover,
+.nav-interactive:hover {
+ color: #ffffff;
+}
- flex: 1;
- width: auto;
+.compact-link .nav-link {
+ display: grid;
+ place-items: center;
+ width: 38px;
+ height: 38px;
+ border-radius: 8px;
+}
- transition: width 0.25s ease;
+.compact-link .nav-link:hover {
+ background: rgba(255, 255, 255, 0.12);
}
-.search-group .search-btn.btn {
+.nav-profile-trigger {
+ display: inline-grid;
+ place-items: center;
+ width: 44px;
+ height: 44px;
+ padding: 0;
+ border: 0;
+ border-radius: 999px !important;
background: transparent;
- border-color: var(--border-color);
color: inherit;
- padding-left: var(--space-3) !important;
- padding-right: var(--space-3) !important;
+ cursor: pointer;
}
-.right-nav {
+.nav-profile-trigger:hover,
+.nav-profile-trigger.is-open {
+ background: rgba(255, 255, 255, 0.12);
+}
+
+.nav-avatar {
+ display: block;
+ width: 38px;
+ height: 38px;
+ border: 2px solid rgba(255, 255, 255, 0.9);
+ border-radius: 999px !important;
+ object-fit: cover;
+ background: #ffffff;
+}
+
+.profile-menu-host {
+ position: relative;
+}
+
+.profile-menu {
+ position: absolute;
+ top: calc(100% + 10px);
+ right: 0;
+ z-index: 1300;
display: flex;
- gap: .5em;
+ flex-direction: column;
+ min-width: 200px;
+ padding: 10px;
+ border: 1px solid #dbe6ea;
+ border-radius: 8px;
+ background: #ffffff;
+ box-shadow: 0 18px 40px rgba(16, 36, 63, 0.22);
}
-/* Profile dropdown trigger */
-.nav-profile-trigger {
- display: inline-flex;
+.profile-menu-link {
+ display: flex;
align-items: center;
- justify-content: center;
- gap: var(--space-2);
+ min-height: 40px;
+ width: 100%;
+ padding: 8px 12px;
+ border: 0;
+ border-radius: 6px;
+ background: transparent;
+ color: var(--c-text-0);
+ font-weight: 700;
+ line-height: 1.2;
+ text-align: left;
+ text-decoration: none;
+ cursor: pointer;
+}
- min-width: var(--navbar-dropdown-min-width);
- padding: var(--nav-link-padding-y) var(--nav-link-padding-x);
- border-radius: var(--nav-link-radius);
- line-height: 1;
+.profile-menu-button {
+ font: inherit;
+}
+
+.profile-menu-link:hover {
+ background: #eef8f9;
+ color: var(--c-brand-800);
text-decoration: none;
}
-.nav-profile-trigger:hover {
- background-color: var(--nav-hover-bg);
+.nav-drawer-backdrop {
+ position: fixed;
+ inset: var(--navbar-height) 0 0;
+ z-index: 1180;
+ border: 0;
+ background: rgba(7, 24, 43, 0.34);
+ cursor: default;
}
-/* User avatar */
-.nav-avatar {
- width: 30px;
- height: 30px;
- border-radius: 999px;
- object-fit: cover;
- display: block;
- border: 1px solid rgba(255, 255, 255, 0.2);
+.nav-drawer {
+ position: fixed;
+ top: var(--navbar-height);
+ bottom: 0;
+ left: 0;
+ z-index: 1190;
+ display: flex;
+ flex-direction: column;
+ width: min(320px, 86vw);
+ padding: 18px;
+ border-right: 1px solid #dbe6ea;
+ background: #ffffff;
+ box-shadow: 20px 0 44px rgba(16, 36, 63, 0.22);
+ animation: drawer-enter 160ms ease-out;
}
-/* Dropdown menu items */
-.dropdown-item {
- color: var(--c-text-0);
+.drawer-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding-bottom: 14px;
+ border-bottom: 1px solid #e3edf1;
+ color: var(--c-brand-800);
+ font-size: 20px;
+ font-weight: 800;
+ line-height: 1;
}
-.dropdown-item:hover {
- background-color: var(--background-color-tertiary) !important;
+.drawer-close {
+ display: grid;
+ place-items: center;
+ width: 34px;
+ height: 34px;
+ padding: 0;
+ border: 0;
+ border-radius: 6px;
+ background: transparent;
+ color: var(--c-text-0);
cursor: pointer;
+}
+
+.drawer-close:hover {
+ background: #eef8f9;
+ color: var(--c-brand-800);
+}
+
+.drawer-list {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ padding: 14px 0;
+}
+
+.drawer-footer {
+ margin-top: auto;
+ padding-top: 14px;
+ border-top: 1px solid #e3edf1;
+}
+
+.drawer-link {
+ display: flex;
+ align-items: center;
+ min-height: 42px;
+ padding: 9px 10px;
+ border-radius: 7px;
color: var(--c-text-0);
+ font-weight: 750;
+ text-decoration: none;
+ gap: 12px;
+}
+
+.drawer-link i {
+ width: 18px;
+ color: var(--c-brand-600, #167b91);
+ text-align: center;
+}
+
+.drawer-link:hover {
+ background: #eef8f9;
+ color: var(--c-brand-800);
+ text-decoration: none;
}
-/* Navbar shell theme adjustments */
-.navbar.bg-shell {
+.drawer-avatar {
+ width: 24px;
+ height: 24px;
+ border-radius: 999px !important;
+ object-fit: cover;
+}
- .nav-link,
- .navbar-brand,
- .nav-profile-trigger,
- .navbar-toggler {
- color: var(--nav-text-soft);
+@keyframes drawer-enter {
+ from {
+ transform: translateX(-100%);
}
- .nav-link:hover,
- .nav-interactive:hover {
- color: var(--text-on-dark);
+ to {
+ transform: translateX(0);
}
}
-/* MOBILE */
@media (max-width: 767.98px) {
- .navbar-collapse.collapse:not(.show) {
- display: none !important;
+ .navbar-body {
+ --navbar-height: 118px;
+
+ flex-wrap: wrap;
+ padding: 8px 12px;
}
- .navbar-collapse.show {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 0.75rem;
- padding: 0.75rem 0;
+ .sidebar-toggle {
+ margin-right: 12px;
}
- .left-nav {
- flex-direction: column;
- align-items: center;
- width: 100%;
- gap: 0.5rem;
- margin: 0;
+ .navbar-brand {
+ margin-right: 12px;
+
+ img {
+ height: 30px;
+ }
}
- .nav-search {
- order: -1;
- width: 100%;
- max-width: 520px;
- padding: 0 1rem;
- margin-right: 0;
+ .section-divider {
+ height: 28px;
+ margin-right: 12px;
}
- .nav-search form,
- .search-group {
- width: 100%;
+ .section-title {
+ max-width: calc(100vw - 220px);
+ font-size: 19px;
}
- .right-nav {
- display: flex;
- flex-direction: column;
- align-items: center;
+ .nav-actions {
width: 100%;
- padding: 0.25rem 1rem 0.75rem;
+ margin-top: 10px;
+ gap: 10px;
}
- .right-nav .nav-item.mr-2,
- .right-nav .nav-item.mr-3 {
- margin-right: 0 !important;
+ .nav-search {
+ flex: 1 1 auto;
+ width: auto;
+ }
+
+ .right-nav {
+ flex: 0 0 auto;
}
}
diff --git a/src/Analysim.Web/ClientApp/src/app/navbar/navbar.component.ts b/src/Analysim.Web/ClientApp/src/app/navbar/navbar.component.ts
index e3584899..0999adcf 100644
--- a/src/Analysim.Web/ClientApp/src/app/navbar/navbar.component.ts
+++ b/src/Analysim.Web/ClientApp/src/app/navbar/navbar.component.ts
@@ -1,13 +1,14 @@
-import { Component, OnInit } from '@angular/core';
+import { Component, ElementRef, HostListener, OnInit } from '@angular/core';
import { AccountService } from '../services/account.service';
import { ProjectService } from '../services/project.service';
import { Observable } from 'rxjs';
import { User } from '../interfaces/user';
import { Project } from '../interfaces/project';
import { NotificationService } from '../services/notification.service';
-import { ActivatedRoute, Router } from '@angular/router';
+import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
import { ExploreService } from '../services/explore.service';
import { DomSanitizer, SafeUrl } from '@angular/platform-browser';
+import { filter } from 'rxjs/operators';
@Component({
selector: 'app-navbar',
@@ -23,12 +24,16 @@ export class NavbarComponent implements OnInit {
public notfi: NotificationService,
private router: Router,
private route: ActivatedRoute,
+ private elementRef: ElementRef,
) {}
searchText: string = '';
termParam: string[];
projects: Project[];
profileImageUrl: SafeUrl;
+ sectionTitle = 'Home';
+ isMenuOpen = false;
+ isProfileMenuOpen = false;
loginStatus$: Observable
;
currentUser$: Observable = null;
@@ -36,6 +41,16 @@ export class NavbarComponent implements OnInit {
isAdmin$: Observable;
async ngOnInit() {
+ this.updateSectionTitle(this.router.url);
+ this.router.events
+ .pipe(
+ filter((event): event is NavigationEnd => event instanceof NavigationEnd),
+ )
+ .subscribe((event) => {
+ this.updateSectionTitle(event.urlAfterRedirects);
+ this.closeMenus();
+ });
+
this.loginStatus$ = this.accountService.isLoggedIn;
this.currentUser$ = await this.accountService.currentUser;
this.currentUser$.subscribe((x) => {
@@ -106,6 +121,8 @@ export class NavbarComponent implements OnInit {
}
onSubmit() {
+ this.closeMenus();
+
const rawTerm = this.searchText || '';
const searchTerms: string[] = Array.from(
@@ -135,6 +152,8 @@ export class NavbarComponent implements OnInit {
}
navigateHome() {
+ this.closeMenus();
+
if (this.accountService.checkLoginStatus()) {
this.router.navigate(['/dashboard']);
} else {
@@ -143,6 +162,96 @@ export class NavbarComponent implements OnInit {
}
onLogout() {
+ this.closeMenus();
this.accountService.logout();
}
+
+ toggleMenu() {
+ this.isMenuOpen = !this.isMenuOpen;
+ this.isProfileMenuOpen = false;
+ }
+
+ toggleProfileMenu() {
+ this.isProfileMenuOpen = !this.isProfileMenuOpen;
+ this.isMenuOpen = false;
+ }
+
+ closeMenu() {
+ this.isMenuOpen = false;
+ }
+
+ closeProfileMenu() {
+ this.isProfileMenuOpen = false;
+ }
+
+ closeMenus() {
+ this.closeMenu();
+ this.closeProfileMenu();
+ }
+
+ @HostListener('document:click', ['$event'])
+ onDocumentClick(event: MouseEvent) {
+ const target = event.target as Node;
+
+ if (!this.elementRef.nativeElement.contains(target)) {
+ this.closeMenus();
+ }
+ }
+
+ @HostListener('document:keydown.escape')
+ onEscape() {
+ this.closeMenus();
+ }
+
+ private updateSectionTitle(url: string) {
+ const parsedUrl = this.router.parseUrl(url || '/home');
+ const segments = parsedUrl.root.children['primary']?.segments || [];
+ const firstSegment = segments.length > 0 ? segments[0].path : 'home';
+
+ if (firstSegment === 'project') {
+ if (segments.length >= 3) {
+ this.sectionTitle = this.formatSegment(segments[2].path);
+ } else if (segments.length === 2) {
+ this.sectionTitle = 'Project';
+ } else {
+ this.sectionTitle = 'Projects';
+ }
+ return;
+ }
+
+ if (firstSegment === 'profile' && segments.length >= 2) {
+ this.sectionTitle = this.formatSegment(segments[1].path);
+ return;
+ }
+
+ const sectionTitles: { [key: string]: string } = {
+ home: 'Home',
+ dashboard: 'Dashboard',
+ explore: 'Explore',
+ login: 'Login',
+ register: 'Register',
+ aboutus: 'About',
+ contactus: 'Contact',
+ admin: 'Admin',
+ setting: 'Settings',
+ 'email-confirmation': 'Email Confirmation',
+ 'email-resend-verification': 'Email Confirmation',
+ emailForgotPass: 'Password Reset',
+ resetPassword: 'Password Reset',
+ '404': 'Not Found',
+ };
+
+ this.sectionTitle =
+ sectionTitles[firstSegment] || this.toTitleCase(firstSegment);
+ }
+
+ private toTitleCase(value: string): string {
+ return value
+ .replace(/[-_]/g, ' ')
+ .replace(/\b\w/g, (letter) => letter.toUpperCase());
+ }
+
+ private formatSegment(value: string): string {
+ return decodeURIComponent(value).replace(/[-_]/g, ' ');
+ }
}
diff --git a/src/Analysim.Web/ClientApp/src/app/services/dashboard.service.ts b/src/Analysim.Web/ClientApp/src/app/services/dashboard.service.ts
new file mode 100644
index 00000000..c102b523
--- /dev/null
+++ b/src/Analysim.Web/ClientApp/src/app/services/dashboard.service.ts
@@ -0,0 +1,165 @@
+import { Injectable } from '@angular/core';
+import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
+import { Observable, throwError } from 'rxjs';
+import { catchError, map } from 'rxjs/operators';
+
+export interface DashboardOverview {
+ totalProjects: number;
+ totalDataFiles: number;
+ totalNotebooks: number;
+ storageUsedBytes: number;
+ storageQuotaBytes: number;
+}
+
+export interface DashboardStorageUsage {
+ usedBytes: number;
+ quotaBytes: number;
+ usedGigabytes: number;
+ quotaGigabytes: number;
+ percentUsed: number;
+}
+
+export interface DashboardResearchImpact {
+ recommends: number;
+ followers: number;
+ members: number;
+ forks: number;
+ recommendsPlaceholder: boolean;
+}
+
+export interface DashboardProjectListItem {
+ projectID: number;
+ name: string;
+ visibility: string;
+ description: string;
+ route: string;
+ dateCreated: Date;
+ lastUpdated: Date;
+ currentUserRole: string;
+ ownerUserName: string;
+ dataFileCount: number;
+ notebookCount: number;
+ followersCount: number;
+ membersCount: number;
+ forksCount: number;
+ recommendsCount: number;
+ popularityScore: number;
+ tags: string[];
+}
+
+export interface DashboardPagedResult {
+ items: T[];
+ page: number;
+ pageSize: number;
+ totalResults: number;
+ totalPages: number;
+}
+
+export interface DashboardActivityMonth {
+ month: string;
+ year: number;
+ created: number;
+ updated: number;
+ total: number;
+}
+
+export interface DashboardActivity {
+ months: DashboardActivityMonth[];
+ growthRate: number;
+}
+
+export interface DashboardProjectQuery {
+ query?: string;
+ visibility?: string;
+ tag?: string;
+ sortBy?: string;
+ sortDirection?: string;
+ page?: number;
+ pageSize?: number;
+}
+
+@Injectable({
+ providedIn: 'root'
+})
+export class DashboardService {
+ private baseUrl = '/api/dashboard/';
+
+ constructor(private http: HttpClient) { }
+
+ getOverview(): Observable {
+ return this.getAuthorized('overview');
+ }
+
+ getStorageUsage(): Observable {
+ return this.getAuthorized('storage-usage');
+ }
+
+ getResearchImpact(): Observable {
+ return this.getAuthorized('research-impact');
+ }
+
+ getPopularProjects(limit: number = 4, scope: string = 'public'): Observable {
+ const params = new HttpParams()
+ .set('limit', limit.toString())
+ .set('scope', scope);
+
+ return this.getAuthorized('popular-projects', params);
+ }
+
+ getActivity(months: number = 6): Observable {
+ const params = new HttpParams().set('months', months.toString());
+ return this.getAuthorized('activity', params);
+ }
+
+ getProjects(filters: DashboardProjectQuery): Observable> {
+ return this.getAuthorized>('projects', this.buildProjectParams(filters));
+ }
+
+ getPublicProjects(filters: DashboardProjectQuery): Observable> {
+ return this.get>('public-projects', this.buildProjectParams(filters));
+ }
+
+ inviteCollaborator(projectID: number, identifier: string, userRole: string = 'member'): Observable {
+ const body = new FormData();
+ body.append('projectID', projectID.toString());
+ body.append('identifier', identifier);
+ body.append('userRole', userRole);
+
+ return this.http.post(this.baseUrl + 'collaborators', body, { headers: this.authHeaders() }).pipe(
+ map(response => response.result),
+ catchError(error => throwError(error))
+ );
+ }
+
+ private getAuthorized(resource: string, params?: HttpParams): Observable {
+ return this.http.get(this.baseUrl + resource, { headers: this.authHeaders(), params }).pipe(
+ map(response => response.result as T),
+ catchError(error => throwError(error))
+ );
+ }
+
+ private get(resource: string, params?: HttpParams): Observable {
+ return this.http.get(this.baseUrl + resource, { params }).pipe(
+ map(response => response.result as T),
+ catchError(error => throwError(error))
+ );
+ }
+
+ private buildProjectParams(filters: DashboardProjectQuery): HttpParams {
+ let params = new HttpParams();
+ const keys = Object.keys(filters) as Array;
+
+ keys.forEach(key => {
+ const value = filters[key];
+ if (value !== undefined && value !== null && value !== '') {
+ params = params.set(key, value.toString());
+ }
+ });
+
+ return params;
+ }
+
+ private authHeaders(): HttpHeaders {
+ return new HttpHeaders().set('Authorization', `Bearer ${localStorage.getItem('jwt')}`);
+ }
+}
diff --git a/src/Analysim.Web/ClientApp/src/assets/styles/abstracts/_tokens.scss b/src/Analysim.Web/ClientApp/src/assets/styles/abstracts/_tokens.scss
index b02e81a7..20f892bc 100644
--- a/src/Analysim.Web/ClientApp/src/assets/styles/abstracts/_tokens.scss
+++ b/src/Analysim.Web/ClientApp/src/assets/styles/abstracts/_tokens.scss
@@ -49,7 +49,7 @@
--border-w-1: 1px;
--border-w-2: 2px;
--border-style: solid;
- --radius-1: 4px;
+ --radius-1: 0;
/* Typography */
--font-sans: "Inter", system-ui, -apple-system, "Segoe UI",
diff --git a/src/Analysim.Web/ClientApp/src/assets/styles/base/_bootstrap-overrides.scss b/src/Analysim.Web/ClientApp/src/assets/styles/base/_bootstrap-overrides.scss
index 5ff79a4b..b916f377 100644
--- a/src/Analysim.Web/ClientApp/src/assets/styles/base/_bootstrap-overrides.scss
+++ b/src/Analysim.Web/ClientApp/src/assets/styles/base/_bootstrap-overrides.scss
@@ -18,6 +18,40 @@
}
}
+.form-control,
+.custom-select,
+.input-group-text,
+.input-group > .form-control,
+.input-group > .custom-select,
+.dropdown-menu,
+.card,
+.alert,
+.badge,
+.list-group-item,
+.modal-content,
+.nav-pills .nav-link,
+.page-link {
+ border-radius: var(--border-radius) !important;
+}
+
+.rounded,
+.rounded-sm,
+.rounded-lg,
+.rounded-circle {
+ border-radius: var(--border-radius) !important;
+}
+
+*,
+*::before,
+*::after {
+ border-radius: var(--border-radius) !important;
+}
+
+.spinner-border,
+.spinner-grow {
+ border-radius: 50% !important;
+}
+
/* Primary button */
.btn-primary {
background-color: var(--button-background-color) !important;
@@ -130,7 +164,7 @@
}
:root {
- --modal-radius: 4px;
+ --modal-radius: var(--border-radius);
--modal-border: var(--border-w-1) var(--border-style) var(--border-color);
--modal-header-padding-y: var(--space-3);
--modal-header-padding-x: var(--space-4);
diff --git a/src/Analysim.Web/Controllers/DashboardController.cs b/src/Analysim.Web/Controllers/DashboardController.cs
new file mode 100644
index 00000000..6af348f6
--- /dev/null
+++ b/src/Analysim.Web/Controllers/DashboardController.cs
@@ -0,0 +1,703 @@
+using Analysim.Web.ViewModels.Dashboard;
+using Core.Entities;
+using Infrastructure.Data;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Configuration;
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Security.Claims;
+using System.Threading.Tasks;
+
+namespace Web.Controllers
+{
+ [Route("api/[controller]")]
+ [ApiController]
+ public class DashboardController : ControllerBase
+ {
+ private const string FollowerRole = "follower";
+ private const string OwnerRole = "owner";
+ private const double BytesPerGigabyte = 1024d * 1024d * 1024d;
+
+ private readonly ApplicationDbContext _dbContext;
+ private readonly IConfiguration _configuration;
+
+ public DashboardController(ApplicationDbContext dbContext, IConfiguration configuration)
+ {
+ _dbContext = dbContext;
+ _configuration = configuration;
+ }
+
+ [Authorize]
+ [HttpGet("overview")]
+ public async Task GetOverview()
+ {
+ if (!TryGetCurrentUserId(out var userId))
+ return Unauthorized(new { message = "Invalid user identifier." });
+
+ var projectIds = await GetCurrentUserProjectIds(userId);
+ var storage = await BuildStorageUsage(userId);
+
+ var totalDataFiles = projectIds.Count == 0
+ ? 0
+ : await _dbContext.BlobFiles
+ .CountAsync(b => b.ProjectID.HasValue
+ && projectIds.Contains(b.ProjectID.Value)
+ && b.Extension != ".$$");
+
+ var totalNotebooks = projectIds.Count == 0
+ ? 0
+ : await _dbContext.Notebook
+ .CountAsync(n => n.ProjectID.HasValue
+ && projectIds.Contains(n.ProjectID.Value)
+ && n.Extension != ".$$"
+ && n.type != "folder");
+
+ return Ok(new
+ {
+ result = new DashboardOverviewVM
+ {
+ TotalProjects = projectIds.Count,
+ TotalDataFiles = totalDataFiles,
+ TotalNotebooks = totalNotebooks,
+ StorageUsedBytes = storage.UsedBytes,
+ StorageQuotaBytes = storage.QuotaBytes
+ },
+ message = "Dashboard overview received."
+ });
+ }
+
+ [Authorize]
+ [HttpGet("storage-usage")]
+ public async Task GetStorageUsage()
+ {
+ if (!TryGetCurrentUserId(out var userId))
+ return Unauthorized(new { message = "Invalid user identifier." });
+
+ var storage = await BuildStorageUsage(userId);
+
+ return Ok(new
+ {
+ result = storage,
+ message = "Dashboard storage usage received."
+ });
+ }
+
+ [Authorize]
+ [HttpGet("research-impact")]
+ public async Task GetResearchImpact()
+ {
+ if (!TryGetCurrentUserId(out var userId))
+ return Unauthorized(new { message = "Invalid user identifier." });
+
+ var projectIds = await GetCurrentUserProjectIds(userId);
+
+ if (projectIds.Count == 0)
+ {
+ return Ok(new
+ {
+ result = new DashboardResearchImpactVM
+ {
+ Recommends = 0,
+ Followers = 0,
+ Members = 0,
+ Forks = 0,
+ RecommendsPlaceholder = true
+ },
+ message = "Dashboard research impact received."
+ });
+ }
+
+ var followers = await _dbContext.ProjectUsers
+ .CountAsync(pu => projectIds.Contains(pu.ProjectID)
+ && pu.UserRole == FollowerRole
+ && pu.IsFollowing);
+
+ var members = await _dbContext.ProjectUsers
+ .CountAsync(pu => projectIds.Contains(pu.ProjectID)
+ && pu.UserRole != FollowerRole);
+
+ var forks = await _dbContext.Projects
+ .CountAsync(p => projectIds.Contains(p.ForkedFromProjectID));
+
+ return Ok(new
+ {
+ result = new DashboardResearchImpactVM
+ {
+ Recommends = 0,
+ Followers = followers,
+ Members = members,
+ Forks = forks,
+ RecommendsPlaceholder = true
+ },
+ message = "Dashboard research impact received."
+ });
+ }
+
+ [Authorize]
+ [HttpGet("popular-projects")]
+ public async Task GetPopularProjects(
+ [FromQuery] int limit = 4,
+ [FromQuery] string scope = "public")
+ {
+ limit = Math.Clamp(limit, 1, 20);
+ scope = string.IsNullOrWhiteSpace(scope) ? "public" : scope.Trim().ToLowerInvariant();
+
+ IQueryable query;
+ int? userId = null;
+
+ if (scope == "mine")
+ {
+ if (!TryGetCurrentUserId(out var currentUserId))
+ return Unauthorized(new { message = "Invalid user identifier." });
+
+ userId = currentUserId;
+ query = _dbContext.Projects
+ .Where(p => p.ProjectUsers.Any(pu =>
+ pu.UserID == currentUserId &&
+ pu.UserRole != FollowerRole));
+ }
+ else
+ {
+ query = _dbContext.Projects
+ .Where(p => p.Visibility.ToLower() == "public");
+ }
+
+ var projects = await BuildProjectListItems(query, userId);
+
+ var result = projects
+ .OrderByDescending(p => p.PopularityScore)
+ .ThenByDescending(p => p.LastUpdated)
+ .Take(limit)
+ .ToList();
+
+ return Ok(new
+ {
+ result,
+ message = "Popular projects received."
+ });
+ }
+
+ [Authorize]
+ [HttpGet("activity")]
+ public async Task GetActivity([FromQuery] int months = 6)
+ {
+ if (!TryGetCurrentUserId(out var userId))
+ return Unauthorized(new { message = "Invalid user identifier." });
+
+ months = Math.Clamp(months, 1, 24);
+
+ var projectIds = await GetCurrentUserProjectIds(userId);
+ var result = await BuildActivity(projectIds, months);
+
+ return Ok(new
+ {
+ result,
+ message = "Dashboard activity received."
+ });
+ }
+
+ [Authorize]
+ [HttpGet("projects")]
+ public async Task GetProjects(
+ [FromQuery] string query = "",
+ [FromQuery] string visibility = "",
+ [FromQuery] string tag = "",
+ [FromQuery] string sortBy = "lastUpdated",
+ [FromQuery] string sortDirection = "desc",
+ [FromQuery] int page = 1,
+ [FromQuery] int pageSize = 20)
+ {
+ if (!TryGetCurrentUserId(out var userId))
+ return Unauthorized(new { message = "Invalid user identifier." });
+
+ page = Math.Max(page, 1);
+ pageSize = Math.Clamp(pageSize, 1, 100);
+
+ var projectsQuery = _dbContext.Projects
+ .Where(p => p.ProjectUsers.Any(pu =>
+ pu.UserID == userId &&
+ pu.UserRole != FollowerRole));
+
+ projectsQuery = ApplyProjectFilters(projectsQuery, query, visibility, tag);
+
+ var projects = await BuildProjectListItems(projectsQuery, userId);
+ projects = SortProjects(projects, sortBy, sortDirection).ToList();
+
+ var totalResults = projects.Count;
+ var totalPages = (int)Math.Ceiling(totalResults / (double)pageSize);
+ var items = projects
+ .Skip((page - 1) * pageSize)
+ .Take(pageSize)
+ .ToList();
+
+ return Ok(new
+ {
+ result = new DashboardPagedResultVM
+ {
+ Items = items,
+ Page = page,
+ PageSize = pageSize,
+ TotalResults = totalResults,
+ TotalPages = totalPages
+ },
+ message = "Dashboard projects received."
+ });
+ }
+
+ [HttpGet("public-projects")]
+ public async Task GetPublicProjects(
+ [FromQuery] string query = "",
+ [FromQuery] string tag = "",
+ [FromQuery] string sortBy = "popularity",
+ [FromQuery] string sortDirection = "desc",
+ [FromQuery] int page = 1,
+ [FromQuery] int pageSize = 20)
+ {
+ page = Math.Max(page, 1);
+ pageSize = Math.Clamp(pageSize, 1, 100);
+
+ var projectsQuery = _dbContext.Projects
+ .Where(p => p.Visibility.ToLower() == "public");
+
+ projectsQuery = ApplyProjectFilters(projectsQuery, query, "", tag);
+
+ var projects = await BuildProjectListItems(projectsQuery, null);
+ projects = SortProjects(projects, sortBy, sortDirection).ToList();
+
+ var totalResults = projects.Count;
+ var totalPages = (int)Math.Ceiling(totalResults / (double)pageSize);
+ var items = projects
+ .Skip((page - 1) * pageSize)
+ .Take(pageSize)
+ .ToList();
+
+ return Ok(new
+ {
+ result = new DashboardPagedResultVM
+ {
+ Items = items,
+ Page = page,
+ PageSize = pageSize,
+ TotalResults = totalResults,
+ TotalPages = totalPages
+ },
+ message = "Public projects received."
+ });
+ }
+
+ [Authorize]
+ [HttpPost("collaborators")]
+ public async Task InviteCollaborator([FromForm] InviteCollaboratorVM formdata)
+ {
+ if (!ModelState.IsValid) return BadRequest(ModelState);
+
+ if (!TryGetCurrentUserId(out var userId))
+ return Unauthorized(new { message = "Invalid user identifier." });
+
+ var project = await _dbContext.Projects.FindAsync(formdata.ProjectID);
+ if (project == null) return NotFound(new { message = "Project Not Found" });
+
+ var isOwner = await _dbContext.ProjectUsers
+ .AnyAsync(pu => pu.ProjectID == formdata.ProjectID
+ && pu.UserID == userId
+ && pu.UserRole == OwnerRole);
+
+ if (!isOwner) return Unauthorized(new { message = "You are not the owner of the project" });
+
+ var role = string.IsNullOrWhiteSpace(formdata.UserRole)
+ ? "member"
+ : formdata.UserRole.Trim().ToLowerInvariant();
+
+ if (role != "member" && role != "admin")
+ return BadRequest(new { message = "Collaborator role must be member or admin." });
+
+ var identifier = formdata.Identifier.Trim().ToLowerInvariant();
+ var invitedUser = await _dbContext.Users
+ .FirstOrDefaultAsync(u =>
+ u.UserName.ToLower() == identifier ||
+ (u.Email != null && u.Email.ToLower() == identifier));
+
+ if (invitedUser == null) return NotFound(new { message = "User Not Found" });
+ if (invitedUser.Id == userId) return BadRequest(new { message = "Cannot invite yourself." });
+
+ var projectUser = await _dbContext.ProjectUsers
+ .Include(pu => pu.User)
+ .SingleOrDefaultAsync(pu =>
+ pu.ProjectID == formdata.ProjectID &&
+ pu.UserID == invitedUser.Id);
+
+ if (projectUser != null)
+ {
+ if (projectUser.UserRole != FollowerRole)
+ {
+ return Conflict(new
+ {
+ result = projectUser,
+ message = "User is already a collaborator on this project."
+ });
+ }
+
+ projectUser.UserRole = role;
+ projectUser.IsFollowing = true;
+ _dbContext.Entry(projectUser).State = EntityState.Modified;
+ await _dbContext.SaveChangesAsync();
+
+ return Ok(new
+ {
+ result = projectUser,
+ message = "Follower promoted to collaborator successfully."
+ });
+ }
+
+ projectUser = new ProjectUser
+ {
+ ProjectID = formdata.ProjectID,
+ UserID = invitedUser.Id,
+ UserRole = role,
+ IsFollowing = true
+ };
+
+ await _dbContext.ProjectUsers.AddAsync(projectUser);
+ await _dbContext.SaveChangesAsync();
+
+ _dbContext.Entry(projectUser).Reference(pu => pu.User).Load();
+
+ return Ok(new
+ {
+ result = projectUser,
+ message = "Collaborator invited successfully."
+ });
+ }
+
+ private bool TryGetCurrentUserId(out int userId)
+ {
+ var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
+ return int.TryParse(userIdClaim, out userId);
+ }
+
+ private async Task> GetCurrentUserProjectIds(int userId)
+ {
+ return await _dbContext.ProjectUsers
+ .Where(pu => pu.UserID == userId && pu.UserRole != FollowerRole)
+ .Select(pu => pu.ProjectID)
+ .ToListAsync();
+ }
+
+ private async Task BuildStorageUsage(int userId)
+ {
+ var usedBytes = await _dbContext.BlobFiles
+ .Where(b => b.UserID == userId)
+ .Select(b => (long)b.Size)
+ .SumAsync();
+
+ var quotaBytes = GetStorageQuotaBytes();
+ var percentUsed = quotaBytes > 0
+ ? Math.Round(usedBytes / (double)quotaBytes * 100, 2)
+ : 0;
+
+ return new DashboardStorageUsageVM
+ {
+ UsedBytes = usedBytes,
+ QuotaBytes = quotaBytes,
+ UsedGigabytes = Math.Round(usedBytes / BytesPerGigabyte, 2),
+ QuotaGigabytes = Math.Round(quotaBytes / BytesPerGigabyte, 2),
+ PercentUsed = percentUsed
+ };
+ }
+
+ private long GetStorageQuotaBytes()
+ {
+ return long.TryParse(_configuration["UserQuota"], out var quotaBytes)
+ ? quotaBytes
+ : 0;
+ }
+
+ private IQueryable ApplyProjectFilters(
+ IQueryable projects,
+ string query,
+ string visibility,
+ string tag)
+ {
+ if (!string.IsNullOrWhiteSpace(query))
+ {
+ var searchTerm = query.Trim().ToLower();
+ projects = projects.Where(p =>
+ p.Name.ToLower().Contains(searchTerm) ||
+ p.Route.ToLower().Contains(searchTerm) ||
+ p.Description.ToLower().Contains(searchTerm) ||
+ p.ProjectTags.Any(pt => pt.Tag.Name.ToLower().Contains(searchTerm)));
+ }
+
+ if (!string.IsNullOrWhiteSpace(visibility))
+ {
+ var visibilityFilter = visibility.Trim().ToLower();
+ projects = projects.Where(p => p.Visibility.ToLower() == visibilityFilter);
+ }
+
+ if (!string.IsNullOrWhiteSpace(tag))
+ {
+ var tagFilter = tag.Trim().ToLower();
+ projects = projects.Where(p =>
+ p.ProjectTags.Any(pt => pt.Tag.Name.ToLower().Contains(tagFilter)));
+ }
+
+ return projects;
+ }
+
+ private async Task> BuildProjectListItems(
+ IQueryable projectsQuery,
+ int? currentUserId)
+ {
+ var projects = await projectsQuery
+ .Include(p => p.BlobFiles)
+ .Include(p => p.Notebooks)
+ .Include(p => p.ProjectUsers).ThenInclude(pu => pu.User)
+ .Include(p => p.ProjectTags).ThenInclude(pt => pt.Tag)
+ .ToListAsync();
+
+ var projectIds = projects.Select(p => p.ProjectID).ToList();
+
+ var forkCounts = projectIds.Count == 0
+ ? new Dictionary()
+ : await _dbContext.Projects
+ .Where(p => projectIds.Contains(p.ForkedFromProjectID))
+ .GroupBy(p => p.ForkedFromProjectID)
+ .Select(g => new { ProjectID = g.Key, Count = g.Count() })
+ .ToDictionaryAsync(g => g.ProjectID, g => g.Count);
+
+ var items = projects.Select(project =>
+ {
+ var currentUserProjectRole = currentUserId.HasValue
+ ? project.ProjectUsers.FirstOrDefault(pu => pu.UserID == currentUserId.Value)?.UserRole
+ : null;
+
+ var ownerUserName = project.ProjectUsers
+ .FirstOrDefault(pu => pu.UserRole == OwnerRole)
+ ?.User
+ ?.UserName;
+
+ return new DashboardProjectListItemVM
+ {
+ ProjectID = project.ProjectID,
+ Name = project.Name ?? "",
+ Visibility = project.Visibility ?? "",
+ Description = project.Description ?? "",
+ Route = project.Route ?? "",
+ DateCreated = project.DateCreated,
+ LastUpdated = project.LastUpdated,
+ CurrentUserRole = currentUserProjectRole ?? "",
+ OwnerUserName = ownerUserName ?? GetOwnerFromRoute(project.Route),
+ DataFileCount = project.BlobFiles?.Count(b => b.Extension != ".$$") ?? 0,
+ NotebookCount = project.Notebooks?.Count(n => n.Extension != ".$$" && n.type != "folder") ?? 0,
+ FollowersCount = project.ProjectUsers?.Count(pu => pu.UserRole == FollowerRole && pu.IsFollowing) ?? 0,
+ MembersCount = project.ProjectUsers?.Count(pu => pu.UserRole != FollowerRole) ?? 0,
+ ForksCount = forkCounts.TryGetValue(project.ProjectID, out var forks) ? forks : 0,
+ RecommendsCount = 0,
+ Tags = project.ProjectTags?
+ .Where(pt => pt.Tag != null)
+ .Select(pt => pt.Tag.Name)
+ .ToList() ?? new List()
+ };
+ }).ToList();
+
+ ApplyPopularityScores(items);
+
+ return items;
+ }
+
+ private static IEnumerable SortProjects(
+ IEnumerable projects,
+ string sortBy,
+ string sortDirection)
+ {
+ var descending = !string.Equals(sortDirection, "asc", StringComparison.OrdinalIgnoreCase);
+ var normalizedSort = string.IsNullOrWhiteSpace(sortBy)
+ ? "lastupdated"
+ : sortBy.Trim().ToLowerInvariant();
+
+ Func selector = normalizedSort switch
+ {
+ "name" => p => p.Name,
+ "created" or "datecreated" => p => p.DateCreated,
+ "files" or "datafiles" => p => p.DataFileCount,
+ "notebooks" => p => p.NotebookCount,
+ "followers" => p => p.FollowersCount,
+ "members" => p => p.MembersCount,
+ "forks" => p => p.ForksCount,
+ "popularity" => p => p.PopularityScore,
+ _ => p => p.LastUpdated
+ };
+
+ return descending
+ ? projects.OrderByDescending(selector).ThenBy(p => p.Name)
+ : projects.OrderBy(selector).ThenBy(p => p.Name);
+ }
+
+ private static void ApplyPopularityScores(List projects)
+ {
+ var rawScores = projects.ToDictionary(
+ p => p.ProjectID,
+ p => CalculateRawPopularityScore(p));
+
+ var maxScore = rawScores.Values.DefaultIfEmpty(0).Max();
+
+ foreach (var project in projects)
+ {
+ project.PopularityScore = maxScore <= 0
+ ? 0
+ : (int)Math.Round(rawScores[project.ProjectID] / (double)maxScore * 100);
+ }
+ }
+
+ private static int CalculateRawPopularityScore(DashboardProjectListItemVM project)
+ {
+ return project.FollowersCount * 3
+ + project.MembersCount * 2
+ + project.ForksCount * 5
+ + project.RecommendsCount * 3;
+ }
+
+ private static string GetOwnerFromRoute(string route)
+ {
+ if (string.IsNullOrWhiteSpace(route)) return "";
+
+ var parts = route.Split('/');
+ return parts.Length > 0 ? parts[0] : "";
+ }
+
+ private async Task BuildActivity(List projectIds, int months)
+ {
+ var now = DateTimeOffset.UtcNow;
+ var firstMonth = new DateTimeOffset(now.Year, now.Month, 1, 0, 0, 0, TimeSpan.Zero)
+ .AddMonths(-(months - 1));
+
+ var monthBuckets = Enumerable.Range(0, months)
+ .Select(offset => firstMonth.AddMonths(offset))
+ .Select(month => new DashboardActivityMonthVM
+ {
+ Month = month.ToString("MMM", CultureInfo.InvariantCulture).ToLowerInvariant(),
+ Year = month.Year
+ })
+ .ToList();
+
+ if (projectIds.Count == 0)
+ {
+ return new DashboardActivityVM
+ {
+ Months = monthBuckets,
+ GrowthRate = 0
+ };
+ }
+
+ var projects = await _dbContext.Projects
+ .Where(p => projectIds.Contains(p.ProjectID)
+ && (p.DateCreated >= firstMonth || p.LastUpdated >= firstMonth))
+ .Select(p => new { p.DateCreated, p.LastUpdated })
+ .ToListAsync();
+
+ var blobFiles = await _dbContext.BlobFiles
+ .Where(b => b.ProjectID.HasValue
+ && projectIds.Contains(b.ProjectID.Value)
+ && (b.DateCreated >= firstMonth || b.LastModified >= firstMonth))
+ .Select(b => new { b.DateCreated, b.LastModified })
+ .ToListAsync();
+
+ var notebooks = await _dbContext.Notebook
+ .Where(n => n.ProjectID.HasValue
+ && projectIds.Contains(n.ProjectID.Value)
+ && (n.DateCreated >= firstMonth || n.LastModified >= firstMonth))
+ .Select(n => new { n.DateCreated, n.LastModified })
+ .ToListAsync();
+
+ var firstMonthDateTime = firstMonth.UtcDateTime;
+ var logs = await _dbContext.ProjectLogs
+ .Where(l => projectIds.Contains(l.ProjectID)
+ && (l.CreatedAt >= firstMonthDateTime || l.UpdatedAt >= firstMonthDateTime))
+ .Select(l => new { l.CreatedAt, l.UpdatedAt })
+ .ToListAsync();
+
+ foreach (var project in projects)
+ {
+ IncrementMonth(monthBuckets, project.DateCreated, true);
+ if (project.LastUpdated > project.DateCreated.AddSeconds(1))
+ IncrementMonth(monthBuckets, project.LastUpdated, false);
+ }
+
+ foreach (var blobFile in blobFiles)
+ {
+ IncrementMonth(monthBuckets, blobFile.DateCreated, true);
+ if (blobFile.LastModified > blobFile.DateCreated.AddSeconds(1))
+ IncrementMonth(monthBuckets, blobFile.LastModified, false);
+ }
+
+ foreach (var notebook in notebooks)
+ {
+ IncrementMonth(monthBuckets, notebook.DateCreated, true);
+ if (notebook.LastModified > notebook.DateCreated.AddSeconds(1))
+ IncrementMonth(monthBuckets, notebook.LastModified, false);
+ }
+
+ foreach (var log in logs)
+ {
+ IncrementMonth(monthBuckets, ToUtcOffset(log.CreatedAt), true);
+ if (log.UpdatedAt > log.CreatedAt.AddSeconds(1))
+ IncrementMonth(monthBuckets, ToUtcOffset(log.UpdatedAt), false);
+ }
+
+ foreach (var bucket in monthBuckets)
+ {
+ bucket.Total = bucket.Created + bucket.Updated;
+ }
+
+ return new DashboardActivityVM
+ {
+ Months = monthBuckets,
+ GrowthRate = CalculateGrowthRate(monthBuckets)
+ };
+ }
+
+ private static void IncrementMonth(
+ List buckets,
+ DateTimeOffset date,
+ bool created)
+ {
+ var bucket = buckets.FirstOrDefault(b => b.Year == date.Year
+ && string.Equals(
+ b.Month,
+ date.ToString("MMM", CultureInfo.InvariantCulture).ToLowerInvariant(),
+ StringComparison.Ordinal));
+
+ if (bucket == null) return;
+
+ if (created)
+ bucket.Created++;
+ else
+ bucket.Updated++;
+ }
+
+ private static DateTimeOffset ToUtcOffset(DateTime date)
+ {
+ var utcDate = date.Kind == DateTimeKind.Unspecified
+ ? DateTime.SpecifyKind(date, DateTimeKind.Utc)
+ : date.ToUniversalTime();
+
+ return new DateTimeOffset(utcDate);
+ }
+
+ private static double CalculateGrowthRate(List buckets)
+ {
+ if (buckets.Count < 2) return 0;
+
+ var previous = buckets[buckets.Count - 2].Total;
+ var current = buckets[buckets.Count - 1].Total;
+
+ if (previous == 0) return current > 0 ? 100 : 0;
+
+ return Math.Round((current - previous) / (double)previous * 100, 2);
+ }
+ }
+}
diff --git a/src/Analysim.Web/ViewModels/Dashboard/DashboardVMs.cs b/src/Analysim.Web/ViewModels/Dashboard/DashboardVMs.cs
new file mode 100644
index 00000000..ac460aa2
--- /dev/null
+++ b/src/Analysim.Web/ViewModels/Dashboard/DashboardVMs.cs
@@ -0,0 +1,89 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+
+namespace Analysim.Web.ViewModels.Dashboard
+{
+ public class DashboardOverviewVM
+ {
+ public int TotalProjects { get; set; }
+ public int TotalDataFiles { get; set; }
+ public int TotalNotebooks { get; set; }
+ public long StorageUsedBytes { get; set; }
+ public long StorageQuotaBytes { get; set; }
+ }
+
+ public class DashboardStorageUsageVM
+ {
+ public long UsedBytes { get; set; }
+ public long QuotaBytes { get; set; }
+ public double UsedGigabytes { get; set; }
+ public double QuotaGigabytes { get; set; }
+ public double PercentUsed { get; set; }
+ }
+
+ public class DashboardResearchImpactVM
+ {
+ public int Recommends { get; set; }
+ public int Followers { get; set; }
+ public int Members { get; set; }
+ public int Forks { get; set; }
+ public bool RecommendsPlaceholder { get; set; }
+ }
+
+ public class DashboardProjectListItemVM
+ {
+ public int ProjectID { get; set; }
+ public string Name { get; set; }
+ public string Visibility { get; set; }
+ public string Description { get; set; }
+ public string Route { get; set; }
+ public DateTimeOffset DateCreated { get; set; }
+ public DateTimeOffset LastUpdated { get; set; }
+ public string CurrentUserRole { get; set; }
+ public string OwnerUserName { get; set; }
+ public int DataFileCount { get; set; }
+ public int NotebookCount { get; set; }
+ public int FollowersCount { get; set; }
+ public int MembersCount { get; set; }
+ public int ForksCount { get; set; }
+ public int RecommendsCount { get; set; }
+ public int PopularityScore { get; set; }
+ public List Tags { get; set; } = new List();
+ }
+
+ public class DashboardPagedResultVM
+ {
+ public IEnumerable Items { get; set; }
+ public int Page { get; set; }
+ public int PageSize { get; set; }
+ public int TotalResults { get; set; }
+ public int TotalPages { get; set; }
+ }
+
+ public class DashboardActivityMonthVM
+ {
+ public string Month { get; set; }
+ public int Year { get; set; }
+ public int Created { get; set; }
+ public int Updated { get; set; }
+ public int Total { get; set; }
+ }
+
+ public class DashboardActivityVM
+ {
+ public IEnumerable Months { get; set; }
+ public double GrowthRate { get; set; }
+ }
+
+ public class InviteCollaboratorVM
+ {
+ [Required(ErrorMessage = "Project ID is a required field.")]
+ public int ProjectID { get; set; }
+
+ [Required(ErrorMessage = "User identifier is a required field.")]
+ public string Identifier { get; set; }
+
+ public string UserRole { get; set; } = "member";
+ }
+}