diff --git a/.gitignore b/.gitignore index c4885ecd..e98ff6e0 100644 --- a/.gitignore +++ b/.gitignore @@ -246,3 +246,9 @@ src/Analysim.Web/Logs # .DS_Store + +# JupyterLite build artifacts +src/Analysim.Web/ClientApp/src/assets/jupyter/venv/ +src/Analysim.Web/ClientApp/src/assets/jupyter/analysim-jupyterlite-bridge/labextension/ +src/Analysim.Web/ClientApp/src/assets/jupyter/analysim-jupyterlite-bridge/lib/ +src/Analysim.Web/ClientApp/.jupyterlite.doit.db \ No newline at end of file diff --git a/src/Analysim.Web/ClientApp/src/app/dashboard/dashboard.component.html b/src/Analysim.Web/ClientApp/src/app/dashboard/dashboard.component.html index df8e1baa..d863fd09 100644 --- a/src/Analysim.Web/ClientApp/src/app/dashboard/dashboard.component.html +++ b/src/Analysim.Web/ClientApp/src/app/dashboard/dashboard.component.html @@ -1,29 +1,320 @@ - -
- -
-
-
- -
-
-
- -
-
-
-
- -
-
-
- -
- -
+
+
+
+

Dashboard

+
+ +
+ + +
+
+ +
+
+
+
+ + + +

Overview

+
+ +
+ + Total Projects + {{ overview?.totalProjects || 0 }} +
+
+ + Data Files + {{ overview?.totalDataFiles || 0 }} +
+
+ + Notebooks + {{ overview?.totalNotebooks || 0 }} +
+
+ + Storage + {{ storageLabel }} +
+
+ +
+
+
+ + + +

Research Impact

+
+ +
+ + Recommends + {{ researchImpact?.recommends || 0 }} +
+
+ + Followers + {{ researchImpact?.followers || 0 }} +
+
+ + Members + {{ researchImpact?.members || 0 }} +
+
+ + Forks + {{ researchImpact?.forks || 0 }} +
+
+ + + +
+
+
+ + + +

Activity

+
+ +
+
+
+ + +
+ {{ month.month }} +
+
+ +
+ Created + Updated +
+

Growth Rate: {{ growthRateLabel }}

+
+ +
+
+
+
+ + + +

{{ activeTab === 'projects' ? 'Projects' : 'Datasets' }}

+
+ +
+ + +
+ +
+ + + {{ activeTab === 'projects' ? totalProjectResults : datasetPlaceholders.length }} Results +
+
+ +
+
Loading projects...
+ +
+
+ +
+ +
+

{{ project.name }}

+

{{ project.description || 'No description available.' }}

+
+ {{ tag }}
+
+ +
+ {{ project.dataFileCount }} files + {{ project.notebookCount }} notebooks + {{ project.popularityScore }} popularity +
+ +
+ {{ project.visibility }} + {{ project.membersCount }} members + {{ project.lastUpdated | date:'mediumDate' }} +
+
+ +
+
+ +
+

You don’t have any projects yet.

+

Create your first project to start organizing datasets and running analyses.

+ +
+ +
+ No projects match these filters.
- -
-
- +
+ +
+
+
+ +
+ +
+

{{ dataset.name }}

+

{{ dataset.description }}

+
+ +
+ {{ dataset.size }} + {{ dataset.rows }} + {{ dataset.columns }} +
+ +
+ {{ dataset.visibility }} + {{ dataset.usage }} + {{ dataset.updated }} +
+
+
+ + + +
+ +
+ {{ errorMessage }} +
+ + + +
+ Loading dashboard... +
+
diff --git a/src/Analysim.Web/ClientApp/src/app/dashboard/dashboard.component.scss b/src/Analysim.Web/ClientApp/src/app/dashboard/dashboard.component.scss index e69de29b..49d3c09c 100644 --- a/src/Analysim.Web/ClientApp/src/app/dashboard/dashboard.component.scss +++ b/src/Analysim.Web/ClientApp/src/app/dashboard/dashboard.component.scss @@ -0,0 +1,720 @@ +.dashboard-shell { + min-height: calc(100vh - 88px); + padding: 28px clamp(16px, 4vw, 56px) 44px; + background: + linear-gradient(180deg, #f4f8fa 0%, #ffffff 42%), + #ffffff; + color: var(--c-brand-800); +} + +.dashboard-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + max-width: 1480px; + margin: 0 auto 24px; +} + +.eyebrow { + margin: 0 0 4px; + color: var(--c-accent-600); + font-size: 12px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0; +} + +h1 { + margin: 0; + color: #10243f; + font-size: 42px; + line-height: 1; + font-weight: 800; + letter-spacing: 0; +} + +.header-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.primary-action, +.secondary-action, +.filter-button, +.tabs button, +.sort-tools select, +.sort-direction, +.filters-panel select, +.filters-panel input { + min-height: 38px; + border: 1px solid #c8d6dd; + border-radius: 8px; + background: #ffffff; + color: #10243f; + font-weight: 750; + letter-spacing: 0; +} + +.primary-action, +.secondary-action { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 0 14px; + border-radius: 0; +} + +.primary-action { + border-color: var(--c-accent-600); + background: var(--c-accent-600); + color: #ffffff; +} + +.secondary-action:hover, +.filter-button.secondary:not(:disabled):hover { + border-color: var(--c-accent-600); + color: var(--c-accent-600); +} + +.dashboard-grid { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + gap: 16px; + max-width: 1480px; + margin: 0 auto; +} + +.metric-panel, +.popular-panel, +.activity-panel, +.results-panel, +.filters-panel { + position: relative; + overflow: hidden; + border: 1px solid #dbe6ea; + border-radius: 0; + background: #ffffff; + box-shadow: 0 12px 30px rgba(16, 36, 63, 0.06); +} + +.metric-panel, +.popular-panel, +.activity-panel { + grid-column: span 3; + min-height: 190px; +} + +.popular-panel { + display: flex; + flex-direction: column; +} + +.results-panel { + grid-column: span 9; + background: #f8fcfd; + border-color: #cfe3e9; +} + +.filters-panel { + grid-column: span 3; + background: #f8fcfd; + border-color: #cfe3e9; +} + +.panel-strip { + height: 5px; + background: #0a376d; +} + +.results-panel .panel-strip, +.filters-panel .panel-strip { + background: #20879b; +} + +.card-heading { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + margin: 14px 20px 14px; +} + +.card-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 44px; + height: 44px; + border-radius: 999px; + font-size: 19px; +} + +.card-icon i, +.card-icon svg { + display: block; + width: 19px; + height: 19px; +} + +.card-icon-overview, +.card-icon-impact, +.card-icon-popular, +.card-icon-activity, +.card-icon-results, +.card-icon-filters { + background: rgba(10, 55, 109, 0.1); + color: #0a376d; +} + +h2 { + margin: 0; + color: #10243f; + font-size: 22px; + line-height: 1.1; + font-weight: 800; + letter-spacing: 0; +} + +.metric-row { + display: grid; + grid-template-columns: 46px minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + padding: 6px 20px; + color: #49616e; + font-size: 16px; + font-weight: 700; +} + +.metric-row i { + display: grid; + place-items: center; + width: 40px; + height: 40px; + border-radius: 8px; + background: #e8f4f6; + color: var(--c-accent-600); + font-size: 20px; +} + +.metric-row strong { + color: #10243f; + font-size: 18px; +} + +.popular-list { + display: grid; + flex: 1; + grid-auto-rows: minmax(24px, 1fr); + gap: 6px; + min-height: 0; + padding: 0 20px 16px; +} + +.popular-item { + display: grid; + grid-template-columns: minmax(0, 1fr) 104px 34px; + gap: 12px; + align-items: center; + color: #10243f; + font-size: 15px; + font-weight: 800; + min-height: 0; + text-decoration: none; +} + +.popular-item:hover span:first-child { + color: var(--c-accent-600); + text-decoration: underline; +} + +.score-track { + height: 10px; + overflow: hidden; + border-radius: 999px; + background: #dce9ee; +} + +.score-fill { + display: block; + height: 100%; + border-radius: inherit; + background: var(--c-accent-600); +} + +.popular-item strong { + color: #5a707b; + font-size: 13px; +} + +.activity-chart { + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 8px; + align-items: end; + height: 104px; + padding: 0 18px; +} + +.activity-month { + display: grid; + justify-items: center; + gap: 6px; +} + +.activity-bars { + display: flex; + align-items: end; + gap: 7px; + height: 68px; +} + +.bar { + width: 12px; + min-height: 4px; + border-radius: 999px; +} + +.bar-created { + background: #10243f; +} + +.bar-updated { + background: var(--c-accent-600); +} + +.month-label, +.activity-legend, +.growth-rate { + color: #5a707b; + font-weight: 800; +} + +.activity-legend { + display: flex; + gap: 16px; + padding: 0 20px; + font-size: 13px; +} + +.legend-dot { + display: inline-block; + width: 9px; + height: 9px; + margin-right: 6px; + border-radius: 50%; +} + +.legend-dot.created { + background: #10243f; +} + +.legend-dot.updated { + background: var(--c-accent-600); +} + +.growth-rate { + margin: 10px 20px 0; + color: var(--c-accent-600); + font-size: 16px; +} + +.filters-panel :disabled { + opacity: 0.58; + cursor: not-allowed; +} + +.results-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 18px; + border-bottom: 1px solid #e3edf1; +} + +.toolbar-heading { + justify-content: flex-start; + min-width: 190px; + margin: 0; +} + +.toolbar-heading .card-icon { + width: 42px; + height: 42px; +} + +.toolbar-heading .card-icon i, +.toolbar-heading .card-icon svg { + width: 17px; + height: 17px; +} + +.toolbar-heading h2 { + font-size: 20px; +} + +.tabs { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.tabs button { + padding: 0 14px; + color: #5a707b; + font-size: 15px; +} + +.tabs button.active { + border-color: var(--c-accent-600); + background: #e8f4f6; + color: var(--c-accent-600); +} + +.sort-tools { + display: flex; + align-items: center; + gap: 10px; + color: #5a707b; + font-size: 14px; + font-weight: 800; +} + +.sort-tools select { + padding: 0 10px; +} + +.sort-direction { + width: 40px; +} + +.result-list { + position: relative; +} + +.result-item { + display: grid; + grid-template-columns: 64px minmax(220px, 1fr) minmax(140px, 200px) minmax(140px, 210px); + gap: 18px; + align-items: center; + min-height: 126px; + padding: 18px 20px; + border-bottom: 1px solid #e3edf1; + cursor: pointer; +} + +.result-item:last-child { + border-bottom: 0; +} + +.result-item:hover { + background: #f8fbfc; +} + +.result-item.placeholder { + cursor: default; +} + +.file-badge { + display: grid; + place-items: center; + width: 58px; + height: 58px; + border-radius: 12px; + background: #e8f4f6; + color: var(--c-accent-600); + font-size: 28px; +} + +.project-badge { + background: #edf3f8; + color: var(--c-brand-700); +} + +.result-copy h3 { + margin: 0 0 6px; + color: #10243f; + font-size: 21px; + line-height: 1.18; + font-weight: 800; +} + +.result-copy p { + display: -webkit-box; + margin: 0; + overflow: hidden; + color: #5a707b; + font-size: 15px; + line-height: 1.4; + font-weight: 650; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +.tag-row { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 10px; +} + +.tag-row span { + padding: 3px 8px; + border-radius: 999px; + background: #edf3f8; + color: #49616e; + font-size: 11px; + font-weight: 800; +} + +.result-metrics, +.result-status { + display: grid; + gap: 10px; + color: #10243f; + font-size: 15px; + font-weight: 800; +} + +.result-metrics span, +.result-status span { + display: grid; + grid-template-columns: 22px minmax(0, 1fr); + gap: 9px; + align-items: center; + min-width: 0; +} + +.result-metrics i, +.result-status i { + color: var(--c-accent-600); + font-size: 16px; +} + +.empty-projects { + display: grid; + justify-items: center; + gap: 12px; + padding: 42px 24px; + color: #10243f; + text-align: center; +} + +.empty-projects-icon { + display: grid; + place-items: center; + width: 64px; + height: 64px; + border: 1px solid #cfe3e9; + background: rgba(10, 55, 109, 0.08); + color: #0a376d; + font-size: 28px; +} + +.empty-projects h3 { + margin: 0; + font-size: 24px; + font-weight: 800; + line-height: 1.15; +} + +.empty-projects p { + max-width: 520px; + margin: 0; + color: #5a707b; + font-size: 16px; + font-weight: 650; + line-height: 1.45; +} + +.empty-create-button { + display: inline-flex; + align-items: center; + min-height: 40px; + padding: 0 16px; + border: 1px solid var(--c-accent-600); + background: var(--c-accent-600); + color: #ffffff; + font-size: 15px; + font-weight: 800; + gap: 8px; +} + +.empty-create-button:hover { + border-color: #0a376d; + background: #0a376d; +} + +.filters-panel { + padding-bottom: 16px; +} + +.filters-panel .card-heading { + margin-bottom: 16px; +} + +.filters-panel select, +.filters-panel input, +.filter-button { + display: block; + width: calc(100% - 32px); + margin: 10px 16px; + padding: 0 10px; + font-size: 15px; +} + +.search-field { + position: relative; + display: block; + width: calc(100% - 32px); + min-height: 42px; + margin: 10px 16px; +} + +.filter-search-icon { + position: absolute; + top: 0; + bottom: 0; + left: 12px; + z-index: 1; + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + color: #5a707b; + line-height: 1; + pointer-events: none; +} + +.filter-search-icon i, +.filter-search-icon svg { + display: block; + width: 16px; + height: 16px; +} + +.search-field input { + display: block; + width: 100%; + height: 42px; + margin: 0; + padding-left: 38px; + box-sizing: border-box; +} + +.filter-button { + border-color: var(--c-accent-600); + background: var(--c-accent-600); + color: #ffffff; +} + +.filter-button.secondary { + border-color: #c8d6dd; + background: #ffffff; + color: #10243f; +} + +.empty-note, +.empty-list, +.inline-loader, +.dashboard-error, +.dashboard-loading { + color: #667000; + font-weight: 800; +} + +.empty-note { + margin: auto 20px; + padding: 0 0 18px; + text-align: center; +} + +.empty-list, +.inline-loader { + padding: 18px 20px; +} + +.dashboard-error, +.dashboard-loading { + max-width: 1480px; + margin: 24px auto; + padding: 22px; + border: 1px solid #dbe6ea; + border-radius: 0; + background: #ffffff; + box-shadow: 0 12px 30px rgba(16, 36, 63, 0.06); + text-align: center; +} + +@media (max-width: 1280px) { + .metric-panel, + .popular-panel, + .activity-panel { + grid-column: span 6; + } + + .results-panel, + .filters-panel { + grid-column: span 12; + } +} + +@media (max-width: 820px) { + .dashboard-shell { + padding: 20px 12px 36px; + } + + .dashboard-header { + align-items: stretch; + flex-direction: column; + } + + h1 { + font-size: 32px; + } + + .header-actions { + justify-content: flex-start; + } + + .dashboard-grid { + grid-template-columns: 1fr; + } + + .metric-panel, + .popular-panel, + .activity-panel, + .results-panel, + .filters-panel { + grid-column: span 1; + } + + .results-toolbar { + align-items: stretch; + flex-direction: column; + } + + .sort-tools { + flex-wrap: wrap; + } + + .result-item { + grid-template-columns: 54px minmax(0, 1fr); + } + + .file-badge { + width: 46px; + height: 46px; + font-size: 22px; + } + + .result-metrics, + .result-status { + grid-column: 1 / -1; + } +} diff --git a/src/Analysim.Web/ClientApp/src/app/dashboard/dashboard.component.ts b/src/Analysim.Web/ClientApp/src/app/dashboard/dashboard.component.ts index 4a012836..fbf2e188 100644 --- a/src/Analysim.Web/ClientApp/src/app/dashboard/dashboard.component.ts +++ b/src/Analysim.Web/ClientApp/src/app/dashboard/dashboard.component.ts @@ -1,7 +1,14 @@ import { Component, OnInit } from '@angular/core'; -import { User } from '../interfaces/user'; -import { AccountService } from '../services/account.service'; -import { Observable, from } from 'rxjs'; +import { Router } from '@angular/router'; +import { forkJoin } from 'rxjs'; +import { + DashboardActivity, + DashboardOverview, + DashboardProjectListItem, + DashboardResearchImpact, + DashboardService, + DashboardStorageUsage +} from '../services/dashboard.service'; @Component({ selector: 'app-dashboard', @@ -9,13 +16,189 @@ import { Observable, from } from 'rxjs'; styleUrls: ['./dashboard.component.scss'] }) export class DashboardComponent implements OnInit { + overview: DashboardOverview; + storageUsage: DashboardStorageUsage; + researchImpact: DashboardResearchImpact; + popularProjects: DashboardProjectListItem[] = []; + activity: DashboardActivity; + projects: DashboardProjectListItem[] = []; - constructor(private accountService : AccountService) { } + totalProjectResults = 0; + isLoading = true; + isProjectListLoading = false; + errorMessage = ''; + activeTab: 'projects' | 'datasets' = 'projects'; - currentUser$ : Observable = null + searchTerm = ''; + visibilityFilter = ''; + tagFilter = ''; + sortBy = 'lastUpdated'; + sortDirection = 'desc'; + page = 1; + pageSize = 10; - async ngOnInit() { - await this.accountService.currentUser.then((x) => this.currentUser$ = x) + // TODO: Replace the temporary recommends value when recommendations are implemented. + // TODO: Replace these rows when dataset metadata endpoints are available after data/project separation. + datasetPlaceholders = [ + { + name: 'Health Indicators 2026', + description: 'A preview row for health metrics, regional indicators, and yearly reporting coverage.', + size: '27.3 MB', + rows: '152,314 rows', + columns: '16 columns', + visibility: 'Public', + usage: 'Used in 3 Projects', + updated: '4 Days ago' + }, + { + name: 'Population census 2023', + description: 'A preview row for demographic distribution, density, and socioeconomic indicators.', + size: '36.2 MB', + rows: '112,624 rows', + columns: '13 columns', + visibility: 'Public', + usage: 'Used in 1 Project', + updated: '5 Days ago' + }, + { + name: 'Energy Consumption', + description: 'A preview row for energy usage patterns across residential and industrial sectors.', + size: '13.8 MB', + rows: '42,814 rows', + columns: '19 columns', + visibility: 'Public', + usage: 'Used in 2 Projects', + updated: '1 Week ago' + } + ]; + + constructor( + private dashboardService: DashboardService, + private router: Router + ) { } + + ngOnInit(): void { + this.loadDashboard(); + } + + loadDashboard(): void { + this.isLoading = true; + this.errorMessage = ''; + + forkJoin({ + overview: this.dashboardService.getOverview(), + storageUsage: this.dashboardService.getStorageUsage(), + researchImpact: this.dashboardService.getResearchImpact(), + popularProjects: this.dashboardService.getPopularProjects(4, 'public'), + activity: this.dashboardService.getActivity(6), + projects: this.dashboardService.getProjects(this.projectQuery()) + }).subscribe(result => { + this.overview = result.overview; + this.storageUsage = result.storageUsage; + this.researchImpact = result.researchImpact; + this.popularProjects = result.popularProjects; + this.activity = result.activity; + this.projects = result.projects.items; + this.totalProjectResults = result.projects.totalResults; + this.isLoading = false; + }, error => { + console.log(error); + this.errorMessage = 'Unable to load dashboard.'; + this.isLoading = false; + }); + } + + loadProjects(): void { + this.isProjectListLoading = true; + + this.dashboardService.getProjects(this.projectQuery()).subscribe(result => { + this.projects = result.items; + this.totalProjectResults = result.totalResults; + this.isProjectListLoading = false; + }, error => { + console.log(error); + this.isProjectListLoading = false; + }); + } + + applyFilters(): void { + this.page = 1; + this.loadProjects(); + } + + resetFilters(): void { + this.searchTerm = ''; + this.visibilityFilter = ''; + this.tagFilter = ''; + this.sortBy = 'lastUpdated'; + this.sortDirection = 'desc'; + this.page = 1; + this.loadProjects(); + } + + switchTab(tab: 'projects' | 'datasets'): void { + this.activeTab = tab; + } + + navigateToProject(project: DashboardProjectListItem): void { + const parts = project.route.split('/'); + if (parts.length >= 2) { + this.router.navigate(['/project', parts[0], parts[1]]); + } + } + + goToCreateProject(): void { + this.router.navigate(['/project/create']); + } + + goToExplorePublicProjects(): void { + this.router.navigate(['/explore']); + } + + get storageLabel(): string { + if (!this.storageUsage) return '0 / 0 GB'; + + return `${this.storageUsage.usedGigabytes} / ${this.storageUsage.quotaGigabytes} GB`; + } + + get maxActivityValue(): number { + if (!this.activity || !this.activity.months || this.activity.months.length === 0) return 1; + + return Math.max(...this.activity.months.map(month => Math.max(month.created, month.updated)), 1); + } + + get growthRateLabel(): string { + if (!this.activity) return '0%'; + + return `${this.activity.growthRate}%`; } + get hasAnyProjects(): boolean { + return (this.overview?.totalProjects || 0) > 0; + } + + get shouldShowFirstProjectEmptyState(): boolean { + return !this.isProjectListLoading && !this.hasAnyProjects; + } + + activityBarHeight(value: number): number { + return Math.max((value / this.maxActivityValue) * 60, value > 0 ? 10 : 4); + } + + projectLink(project: DashboardProjectListItem): any[] { + const parts = project.route.split('/'); + return parts.length >= 2 ? ['/project', parts[0], parts[1]] : ['/dashboard']; + } + + private projectQuery() { + return { + query: this.searchTerm, + visibility: this.visibilityFilter, + tag: this.tagFilter, + sortBy: this.sortBy, + sortDirection: this.sortDirection, + page: this.page, + pageSize: this.pageSize + }; + } } diff --git a/src/Analysim.Web/ClientApp/src/app/navbar/navbar.component.html b/src/Analysim.Web/ClientApp/src/app/navbar/navbar.component.html index 45aa1671..16f0f93c 100644 --- a/src/Analysim.Web/ClientApp/src/app/navbar/navbar.component.html +++ b/src/Analysim.Web/ClientApp/src/app/navbar/navbar.component.html @@ -1,95 +1,207 @@ -
+ + + + + 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"; + } +}