diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d8ce799..8394bbf3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,26 @@ -# Change log for Git4School +## [Unreleased] + +### Added + +#### Overview graph + +- ⭐ Student repository navigation with tooltip feedback in the students view + +#### Home + +- ⭐ Collapsible animated search bar in the assignment chooser + +#### General + +- ⭐ Support for self-hosted GitLab instances with multi-account management +- Automatic GitLab OAuth token refresh with rotation and HTTP interceptor +- ⭐ Visual **anonymization mode** for classroom presentations — student names, repositories and authors are hidden on demand via `AnonymizationService` + +### Fixed + +- Tooltip content adaptation, helper expansion and viewport boundaries + +--- ## 2.5.1 @@ -9,6 +31,8 @@ - **TP Group Extraction from README**: Correctly extract TP group located after a checked markdown checkbox (`- [x]` or `- [X]`) instead of capturing brackets. - **Multilingual Name Parsing**: Fix line-by-line parsing to properly extract student names when colons are placed inside bold/italic markdown formatting (e.g. `**Nom :** **Durand**`). +--- + ## 2.5.0 ### Added @@ -27,6 +51,408 @@ - Added ADR 0009 (`docs/adr/0009-detection-multilingue-et-reaffectation-noms-depots.md`). - Added unit test suite for multilingual name detection in `Utils`. +--- + +## 2.4.0 + +### Added + +#### General + +- ⭐ **GitLab support**: users can now connect to GitLab via OAuth 2.0 PKCE, in addition to GitHub + - Strategy Pattern architecture for multi-provider authentication (ADR-0006) + - ⭐ Dedicated split button UI to choose the authentication provider + - Secure _remember-me_ token storage and clean legacy auth flow +- Architecture Decision Records (ADR) showcase page published on the GitHub Wiki + +### Fixed + +- Unit test failures in `AccountsService`, token storage and GitLab callback specs + +--- + +## 2.3.0 + +### Modified + +#### General + +- Views have been renamed for consistency and clarity +- ⭐ Student repository navigation added to the students view + +--- + +## 2.2.1 + +### Added + +#### Questions chooser + +- ⭐ Improved dropdown, assistant and accordion button click feedback with chevron rotation animation + +### Fixed + +- Fixed a crash in the question completion graph caused by a question casing mismatch +- Prevented typeahead selection jumps during arrow navigation via a dedicated directive (ADR-0005) + +--- + +## 2.2.0 + +### Added + +#### Questions chooser + +- ⭐ A **questions assistant popover** with contextual help for commit closing rules configuration + - The panel moves alongside the suggestions popover so it is always visible +- ⭐ Support for **pasting multiple questions** at once using a delimiter separator +- Advanced mode for adding questions with finer-grained configuration + +### Fixed + +- Popover stacking above the navbar and hover flickering +- Outside click dismissal of the questions assistant popover inside modals +- Popover viewport overflow — auto-dropup and polished layout +- i18n label overflow in various UI elements + +--- + +## 2.1.1 + +### Fixed + +- Fixed animation lag on the disconnect button in the sidebar + +--- + +## 2.1.0 + +### Added + +#### Sidebar & Navigation + +- ⭐ **Git accounts management**: connected accounts are now displayed and managed directly in the sidebar and the connection modal + +#### Developer tools + +- A **developer toolbar** for debugging and toggling feature flags (hidden in production) + +### Modified + +#### General + +- Cyclomatic complexity reduced across several components; regression test suite added +- Codacy metrics configured; Stylelint integrated for SCSS quality +- Codacy issues resolved across the codebase + +### Fixed + +- Fixed a 1-month offset in the commit tooltip date format + +--- + +## 2.0.5 + +### Added + +#### Overview graph + +- ⭐ Milestone labels redesigned with **chronological overlap masking** to avoid visual clutter + +### Modified + +#### Home + +- ⭐ Assignment status badges improved; status filters interaction polished + +### Fixed + +- Milestone bar thickness, text width and cutout bounds aligned precisely + +--- + +## 2.0.4 + +### Modified + +#### Overview graph + +- ⭐ Session visualization redesigned with **sticky header badges**, custom labels, and a responsive layout +- Milestone and session updates are now applied smoothly without recreating the graph +- Overlay management refactored into `OverlayManagerService` to coordinate dropdown and popover closing +- Search input focus is maintained after pressing Enter + +### Fixed + +- Milestone drag pill clipping and dark mode text contrast +- Popovers no longer stack above the navbar +- Popovers auto-close when interacting with the overview graph +- Sidebar z-index stacking order corrected (above popovers and dropdowns, below modals) +- Zoom reset is restored after loading or filtering; search input focus is maintained +- Milestone drag progress circle color now matches the milestone type + +--- + +## 2.0.3 + +### Fixed + +- Date preview corrected when moving a milestone in the overview graph +- Milestone drag pill clipping and dark mode text contrast + +--- + +## 2.0.2 + +### Modified + +#### Overview graph + +- ⭐ Milestone types order inverted to better match the expected workflow +- ⭐ Milestone label upper strip is now clickable; strip hidden when no milestone to display +- Delete milestone/session shown in red in the context menu +- More subtle required-field message for milestone type +- Long-press is now required to drag milestones (prevents accidental drags) + +### Fixed + +- Click date calculation corrected on sessions and milestones + +--- + +## 2.0.1 + +### Fixed + +#### Assignment editor + +- Repositories search fixed (including forked repos) +- Toggling cycling between a repo and its forks works correctly +- Repository rows details finalized + +--- + +## 2.0.0 + +### Added + +#### General + +- ⭐ **Full UI redesign** — modern sidebar navigation, floating panel, custom modal system, dark theme overhaul +- ⭐ **Keyboard shortcuts** accessible from a dedicated modal with uniformized keycap style +- **Skeleton loading** for commits and repository search results +- Custom **toast service** and **tooltip service** replacing third-party libraries +- ⭐ **Onboarding tutorial** (initially automatic, then triggered manually from the sidebar) + +#### Home + +- ⭐ Assignment list now shows **search and filter** controls (saved in LocalStorage) +- ⭐ Assignments can be **filtered by forge** (only assignments from the connected forge are shown) +- Assignment tabs organized by forge +- Number of assignments displayed in the sidebar is configurable +- ⭐ Sidebar is **resizable**; width is saved in LocalStorage + +#### Overview graph + +- ⭐ **Drag milestones** directly on the graph (requires long-press) +- ⭐ Click on **grouped commits** to zoom in on them +- Graphs **fully adapt** to any screen size +- ⭐ Horizontal scroll possible with trackpad or Ctrl+scroll on macOS +- ⭐ Filter criteria support **exclusion** using `!` +- Filter criteria can be **moved** with Cmd+Arrow +- Same criteria can be **added multiple times** +- ⭐ Filter criteria visually **grouped** into pills +- ⭐ Rich **filter suggestions popover** with contextual help +- Legend elements are **clickable** to toggle visibility +- Personal notes icon shown on sessions that have notes +- ⭐ Personal notes shown in milestone tooltip on hover +- Message displayed in overview when there are no repos or commits + +#### Students / Questions graphs + +- Student names display fixed in commits and questions graphs +- Students now displayed with their names adjusted and truncated if too long + +### Modified + +#### General + +- All charts migrated from Chart.js to **D3.js** +- GraphQL API used for retrieving commits and user repositories (performance improvement) +- ⭐ Modals redesigned: slide-in from bottom, blurred backdrop +- Toast animations improved + +#### Assignment editor + +- ⭐ Repository search redesigned with a floating selection bar tool and modal +- Repository avatar now shown in the assignment repositories list +- Auto-scroll to the edited assignment when opening it from the list + +### Fixed + +- Fixed calendar popover placement and TP group selector +- Fixed scroll issue in the Home page +- Fixed question renaming and adding more than 14 questions +- Fixed display order of elements in the overview graph +- Fixed date picker calendar displaying below viewport boundaries +- Fixed question chooser in milestone edit + +--- + +## 1.6.2 + +### Fixed + +- Fixed session activity line display + +--- + +## 1.6.1 + +### Fixed + +- Fixed session display, milestone line length, and zoom on WebKit-based browsers + +--- + +## 1.6.0 + +### Modified + +#### Overview graph + +- Commits and questions graphs migrated from **Chart.js to D3.js** for more control and performance +- ⭐ Overview is now **responsive** and adapts to the screen size +- ⭐ GitHub authentication now uses a **popup** instead of a redirect + +### Fixed + +- Fixed scroll issues and contextual menu positioning +- Fixed tooltip rendered at wrong time and position +- Fixed zoom and size issues +- Fixed commit filter not working +- Fixed commit links opening in the same tab +- Fixed date alignment and display offset issues + +--- + +## 1.5.3 + +### Added + +#### Overview graph + +- ⭐ Teachers can now **add personal notes** to a milestone or session +- ⭐ Repository URL in the assignment editor is now **clickable** (opens in a new tab) + +#### Commits / Questions graphs + +- The maximum date of the sliders now adjusts to the latest commit date + +### Modified + +#### Overview graph + +- Long student names are now **truncated** in the overview graph + +### Deleted + +- Confirmation modal when importing multiple assignments has been removed + +### Fixed + +- Date slider maximum date corrected to reach the last commit (fix #119) + +--- + +## 1.5.2 + +### Modified + +#### General + +- ⭐ Now **loads more than 100 commits per repository** using GitHub API pagination +- Upload and download assignment icons updated + +### Fixed + +- Fixed commits not being retrieved when there are fewer than 100 +- Fixed live deploy to Firebase + +--- + +## 1.5.1 + +### Fixed + +- Firebase configuration updated + +--- + +## 1.5.0 + +### Added + +#### General + +- ⭐ Support for reading an **IDENTITY file** to retrieve student identity information from repositories + +#### Overview graph + +- ⭐ The currently filtered group is used when **adding a milestone or session** (fix #124) +- ⭐ Group filter is **kept active** for a session (fix #120) +- `"All"` milestones remain visible on a filtered overview graph + +### Modified + +#### CI/CD + +- A comment with the **staging link** is automatically added when deployed + +--- + +## 1.4.3 + +### Fixed + +- Zoom level is no longer reset on graph manipulation +- Fixed `repoAlreadyAddedValidator` — the validator was detecting itself as already present, preventing editing a repository + +--- + +## 1.4.2 + +### Added + +#### Assignment editor + +- Repository list is now **scrollable** +- ⭐ Repositories can be **sorted** by name and other properties +- Pressing **Enter** while editing a repository validates the row +- Confirmation dialog added when **deleting a repository** + +### Fixed + +- Fixed error when importing an assignment with a milestone (fix #106) +- Fixed save occurring when cancelling an empty row +- Fixed bug when sorting repositories with no name + +--- + +## 1.4.1 + +### Added + +#### Overview graph + +- ⭐ **Time picker** restored in the milestone edition modal (lost during the datepicker library change) + +### Fixed + +- Fixed error when importing an assignment with a milestone (fix #106) + +--- + ## 1.4.0 ### Modified diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 298d7696..d25addb5 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -1,7 +1,8 @@ import { LOCATION_INITIALIZED, registerLocaleData } from "@angular/common"; -import { HttpClient, HttpClientModule } from "@angular/common/http"; +import { HTTP_INTERCEPTORS, HttpClient, HttpClientModule } from "@angular/common/http"; import localeFr from "@angular/common/locales/fr"; import localeRu from "@angular/common/locales/ru"; +import { GitlabAuthInterceptor } from "@interceptors/gitlab-auth.interceptor"; import { APP_INITIALIZER, Injector, LOCALE_ID, NgModule } from "@angular/core"; import { FormsModule, ReactiveFormsModule } from "@angular/forms"; import { SharedUiModule } from "./shared/ui/shared-ui.module"; @@ -53,6 +54,7 @@ import { SidebarSettingsComponent } from "./components/nav-layouts/sidebar-setti import { AccountsComponent } from "./components/nav-layouts/sidebar-settings/accounts/accounts.component"; import { AddAccountModalComponent } from "./components/nav-layouts/sidebar-settings/accounts/add-account-modal/add-account-modal.component"; import { GitlabCallbackComponent } from "@components/auth-callback/gitlab-callback.component"; +import { MockGitlabInterceptor } from "./dev-mock/mock-gitlab.interceptor"; /** * Firebase configuration file @@ -174,6 +176,16 @@ export function appInitializerFactory(translate: TranslateService, injector: Inj }, DatabaseService, NgbActiveModal, + { + provide: HTTP_INTERCEPTORS, + useClass: GitlabAuthInterceptor, + multi: true, + }, + { + provide: HTTP_INTERCEPTORS, + useClass: MockGitlabInterceptor, + multi: true, + }, ], bootstrap: [AppComponent], }) diff --git a/src/app/components/graphs/commits/chart.scss b/src/app/components/graphs/commits/chart.scss index 5f1483ee..247dc7e8 100644 --- a/src/app/components/graphs/commits/chart.scss +++ b/src/app/components/graphs/commits/chart.scss @@ -144,13 +144,12 @@ body.dark-theme .commit { .session-header-fo { pointer-events: auto; - overflow: hidden; + overflow: visible; cursor: default; - mask: url(#milestone-badges-cutout-mask); - -webkit-mask: url(#milestone-badges-cutout-mask); } .session-pill { + box-sizing: border-box; cursor: default; transition: box-shadow var(--transition-fast), border-color var(--transition-fast); min-width: 0; @@ -193,6 +192,24 @@ body.dark-theme .commit { } } + .session-counter { + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 10px; + font-weight: 600; + color: var(--color-text-secondary); + white-space: nowrap; + height: 18px; + line-height: 18px; + padding: 0 2px; + margin: 0; + vertical-align: middle; + align-self: center; + user-select: none; + } + &:hover { .session-header-top-bar { opacity: 1; @@ -209,7 +226,7 @@ body.dark-theme .commit { } } -// Fallback legacy .session class +/* Fallback legacy .session class */ .session { stroke-width: 1px; stroke: var(--color-info); @@ -397,4 +414,102 @@ body.dark-theme .commit { font-weight: 600; user-select: none; } -} \ No newline at end of file +} + +/* Session edge arrows — Navigation entre séances superposées + * Ghost par défaut, reveal au hover, feedback physique au clic. + * Padding étendu pour une zone de clic confortable sans encombrer. */ +.session-edge-arrow { + box-sizing: border-box; + background: transparent; + border: 1px solid transparent; + border-radius: 6px; + color: var(--color-text-secondary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + height: 18px; + padding: 2px 4px; + flex-shrink: 0; + will-change: transform; + transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast), transform 120ms ease-out; + + svg { + display: block; + flex-shrink: 0; + } + + &:hover { + background: var(--color-background-secondary); + border-color: var(--color-border); + color: var(--color-primary); + } + + &:active { + transform: scale(0.88); + } +} + +/* Zone de sélection de zoom (Shift + Drag) — Craft & Subtle style */ +.chart-selection-group { + pointer-events: none; + animation: selectionFadeIn 120ms cubic-bezier(0.23, 1, 0.32, 1) forwards; + + &.selection-exiting { + animation: selectionFadeOut 180ms ease-out forwards; + } +} + +.chart-selection-bg { + fill: var(--color-primary); + fill-opacity: 0.07; + stroke: none; +} + +.chart-selection-edge { + stroke: var(--color-primary); + stroke-width: 1.5px; + stroke-opacity: 0.7; +} + +.chart-selection-top-bar { + fill: var(--color-primary); + opacity: 0.9; +} + +.chart-selection-badge-bg { + fill: var(--color-surface); + stroke: var(--color-border); + stroke-width: 1px; + filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.08)) drop-shadow(0 1px 2px rgba(0, 0, 0, 0.04)); +} + +.chart-selection-badge-text { + fill: var(--color-text-primary); + font-family: var(--font-family-sans); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; + user-select: none; +} + +@keyframes selectionFadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes selectionFadeOut { + from { + opacity: 1; + } + + to { + opacity: 0; + } +} diff --git a/src/app/components/graphs/commits/commits.component.html b/src/app/components/graphs/commits/commits.component.html index 2fa36eb8..26d16868 100644 --- a/src/app/components/graphs/commits/commits.component.html +++ b/src/app/components/graphs/commits/commits.component.html @@ -170,14 +170,8 @@ + + +
+ + + ${calendarSvg} + ${displayName} + + + + ${ + groupName + ? ` + + ${usersSvg} + ${groupName} + ` + : "" + } + + + ${ + hasNotes + ? ` + + ${noteSvg} + ` + : "" + } + + + + + + ${ + sessionIdxInGroup + 1 + }/${groupSize} +
+ + + + + `); + + const groupId = overview.getGroupId(overlapGroup); + const svc = overview.tooltipService; + const len = overlapGroup.length; + + const getIdx = () => overview.activeSessionIndices.get(groupId) ?? 0; + + this.attachTooltip( + fo.select(".session-edge-prev"), + svc, + () => { + const prevIdx = (getIdx() - 1 + len) % len; + const targetSession = overlapGroup[prevIdx]; + const title = `Séance précédente (${prevIdx + 1}/${len})`; + return this.buildSessionTooltipHtml(title, targetSession, overview); + }, + "bottom", + "270px", + ); + fo.select(".session-edge-prev").on("click", (e: MouseEvent) => { + e.stopPropagation(); + svc.hide(); + const prevIdx = (getIdx() - 1 + len) % len; + overview.activeSessionIndices.set(groupId, prevIdx); + const targetSession = overlapGroup[prevIdx]; + overview.lastFocusedSession = targetSession; + overview.updateSessionsTransforms(); + overview.updateSessionVisibility(true); + overview.zoomToSession(targetSession); + }); + + this.attachTooltip( + fo.select(".session-edge-next"), + svc, + () => { + const nextIdx = (getIdx() + 1) % len; + const targetSession = overlapGroup[nextIdx]; + const title = `Séance suivante (${nextIdx + 1}/${len})`; + return this.buildSessionTooltipHtml(title, targetSession, overview); + }, + "bottom", + "270px", + ); + fo.select(".session-edge-next").on("click", (e: MouseEvent) => { + e.stopPropagation(); + svc.hide(); + const nextIdx = (getIdx() + 1) % len; + overview.activeSessionIndices.set(groupId, nextIdx); + const targetSession = overlapGroup[nextIdx]; + overview.lastFocusedSession = targetSession; + overview.updateSessionsTransforms(); + overview.updateSessionVisibility(true); + overview.zoomToSession(targetSession); + }); + } + + /* ------------------------------------------------------------- + * Helper : Construit la carte tooltip riche d'une séance cible + * ------------------------------------------------------------- */ + private static buildSessionTooltipHtml(title: string, session: Session, overview: any): string { + const sName = this.escapeHtml(overview.getSessionDisplayName(session)); + const sGroup = session.tpGroup ? this.escapeHtml(session.tpGroup) : ""; + const timeFormatted = this.escapeHtml(overview.formatSessionTime(session)); + const hasNotes = !!(session.notes && session.notes.trim().length > 0); + const notesEscaped = hasNotes ? this.escapeHtml(session.notes.trim()) : ""; + + return ` +
+
+ ${title} +
+
+ + ${sName} + + ${ + sGroup + ? `${sGroup}` + : "" + } +
+
+ + ${timeFormatted} +
+ ${ + hasNotes + ? `
+

${notesEscaped}

+
` + : "" + } +
+ `; + } + + private static escapeHtml(str: string): string { + if (!str) return ""; + return str.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); + } + + /* ------------------------------------------------------------- + * Helper : Attache les events de tooltip sur une sélection D3 HTML. + * ------------------------------------------------------------- */ + private static attachTooltip( + selection: d3.Selection, + svc: any, + getText: () => string, + placement: "top" | "bottom" | "left" | "right" = "bottom", + maxWidth?: string | number, + ): void { + selection + .on("mouseenter", function (this: HTMLElement) { + svc.show(getText(), this, placement, undefined, maxWidth); + }) + .on("mouseleave", () => svc.hide()) + .on("mousedown", () => svc.hide()); + } +} diff --git a/src/app/components/graphs/questions-completion/questions-completion.component.html b/src/app/components/graphs/questions-completion/questions-completion.component.html index 0f642c19..fc9ed603 100644 --- a/src/app/components/graphs/questions-completion/questions-completion.component.html +++ b/src/app/components/graphs/questions-completion/questions-completion.component.html @@ -45,14 +45,8 @@ - - +
+ - - @@ -22,7 +29,7 @@ }}: - -
+
-
- - - - - + @@ -187,7 +223,7 @@
{{ 'HOME.FILTER.PROGRAM' | translate }}
@@ -214,7 +250,7 @@
{{ 'HOME.FILTER.YEAR' | translate }}
@@ -233,16 +269,17 @@
{{ 'HOME.FILTER.YEAR' | translate }}
- +
- -
+ +
- -
- -
- - +
@@ -311,29 +338,48 @@
{{ 'HOME.FILTER.YEAR' | translate }}
[disabled]="!isGithubConnected" [appTooltip]="(!isGithubConnected ? ('HOME.MUST-LOGIN-GITHUB' | translate) : '')" placement="left" - (click)="createAssignment('github', $event)"> + (click)="createAssignment('github', 'github.com', undefined, $event)"> GitHub - + GitLab - + GitLab (gitlab.com) + + + +
@@ -371,7 +417,9 @@
{{ 'HOME.FILTER.YEAR' | translate }}
'is-editing': editingAssignmentId === assignment.id, 'is-shrunk': (hoveredAssignment === assignment.id || isSelected(assignment.id)) && editingAssignmentId !== assignment.id, 'status-preview-dimmed': hoveredStatusPreview && assignment.id !== -1 && assignment.uiStatus !== hoveredStatusPreview - }"> + }" + [appTooltip]="!isAssignmentConnected(assignment) && assignment.id !== -1 ? getDisconnectedTooltip(assignment) : null" + placement="top">
@@ -383,6 +431,11 @@
{{ 'HOME.FILTER.YEAR' | translate }}
+ + + + +
@@ -486,18 +539,18 @@
{{ 'HOME.FILTER.YEAR' | translate }}
diff --git a/src/app/components/home/assignment-chooser/assignment-chooser.component.scss b/src/app/components/home/assignment-chooser/assignment-chooser.component.scss index d2c472c2..e8a9be68 100644 --- a/src/app/components/home/assignment-chooser/assignment-chooser.component.scss +++ b/src/app/components/home/assignment-chooser/assignment-chooser.component.scss @@ -6,6 +6,7 @@ .filter-group { display: inline-flex; + align-items: center; background: var(--filter-bg); border-radius: 10px; padding: 4px; @@ -21,22 +22,19 @@ background: var(--filter-indicator); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06); border-radius: 6px; - transition: transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1); - z-index: -1; + transition: transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1), width 0.3s cubic-bezier(0.25, 0.8, 0.25, 1); + z-index: 0; + pointer-events: none; } - &[data-active="all"] .filter-indicator { transform: translateX(0); } - - &[data-active="github"] .filter-indicator { transform: translateX(100px); } - - &[data-active="gitlab"] .filter-indicator { transform: translateX(200px); } - .filter-btn { + position: relative; + z-index: 1; background: transparent !important; box-shadow: none !important; border: none; - width: 100px; - padding: 0.5rem 0; + min-width: 100px; + padding: 0.5rem 0.75rem; text-align: center; font-size: 0.85rem; font-weight: 500; @@ -44,6 +42,11 @@ cursor: pointer; border-radius: 6px; transition: color 0.2s ease; + user-select: none; + display: inline-flex; + align-items: center; + justify-content: center; + white-space: nowrap; &:hover:not(.active) { color: var(--color-text-primary); @@ -268,6 +271,23 @@ align-items: center; justify-content: center; line-height: 1; + + .custom-gitlab-icon-wrap { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + + .sub-server-icon { + position: absolute; + bottom: -2px; + right: -3px; + width: 8px; + height: 8px; + color: currentColor; + opacity: 0.9; + } + } } .split-text { @@ -361,6 +381,25 @@ flex-shrink: 0; } + .custom-gitlab-dropdown-icon { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + flex-shrink: 0; + + .sub-server-svg { + position: absolute; + bottom: -2px; + right: -3px; + width: 8px; + height: 8px; + color: var(--color-text-secondary); + } + } + .provider-name { flex: 1; white-space: nowrap; @@ -400,46 +439,192 @@ .search-filters-group { - .search-input-wrapper { - position: relative; + display: flex; + align-items: center; + gap: 6px; - .search-icon { + .search-expandable-container { + position: relative; + display: inline-flex; + align-items: center; + flex: 0 0 auto; + width: 34px; + height: 34px; + box-sizing: border-box; + border: 1px solid transparent; + border-radius: 8px; + background-color: transparent; + cursor: pointer; + overflow: hidden; + user-select: none; + will-change: width, background-color, border-color, box-shadow; + transition: width 380ms cubic-bezier(0.2, 0.8, 0.2, 1), + background-color 240ms ease, + border-color 240ms ease, + box-shadow 240ms ease, + transform 150ms cubic-bezier(0.2, 0.8, 0.2, 1); + + .search-icon-wrapper { position: absolute; - left: 12px; top: 50%; - transform: translateY(-50%); - color: var(--color-text-muted); + left: 9px; z-index: 5; + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + color: var(--color-text-secondary); pointer-events: none; + transform: translateY(-50%); + transition: left 380ms cubic-bezier(0.2, 0.8, 0.2, 1), color 240ms ease; + + .search-icon { + width: 16px; + height: 16px; + } } - .modern-search-input { - padding-left: 36px; - border-radius: 8px; - border: 1px solid var(--color-border); - background-color: var(--color-surface); + .search-input { + width: 100%; + height: 100%; + padding: 0 8px 0 32px; + border: none; + outline: none; + background: transparent; color: var(--color-text-primary); - transition: border-color 0.15s ease-out, background-color 0.15s ease-out, box-shadow 0.15s ease-out; - font-size: 0.9rem; + font-size: 0.85rem; + font-weight: 400; + pointer-events: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + user-select: text; &::placeholder { color: var(--color-text-muted); - transition: color 0.2s ease; + opacity: 0; + transition: opacity 120ms ease; } + } - &:focus { - box-shadow: none !important; - outline: none; - border-color: var(--color-border) !important; + .clear-search-btn { + position: absolute; + top: 50%; + right: 6px; + z-index: 6; + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + padding: 0; + border: none; + border-radius: 50%; + background: var(--color-surface-hover); + color: var(--color-text-muted); + cursor: pointer; + transform: translateY(-50%); + transition: background-color 150ms ease, color 150ms ease, transform 120ms ease; + + &:hover { + background-color: var(--color-hover); + color: var(--color-text-primary); + } + + &:active { + transform: translateY(-50%) scale(0.9); + } + } + + &.expanded { + width: 240px; + cursor: default; + background-color: var(--color-surface); + border-color: var(--color-border); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); + + .search-icon-wrapper { + left: 10px; + color: var(--color-text-muted); + } + + .search-input { + padding-right: 28px; + cursor: text; + pointer-events: auto; &::placeholder { - color: transparent; + opacity: 1; + transition: opacity 240ms ease 120ms; + } + } + + &.focused { + border-color: var(--color-primary); + box-shadow: 0 0 0 3px var(--color-primary-bg); + + .search-icon-wrapper { + color: var(--color-primary); } } } + + &:not(.expanded):hover { + background-color: var(--filter-bg); + + .search-icon-wrapper { + color: var(--color-text-primary); + } + } + + &:not(.expanded):active { + transform: scale(0.94); + } + + &:not(.expanded):focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--color-primary-bg); + } } .filter-dropdown-container { + .btn-outline-ghost { + box-sizing: border-box; + height: 34px; + min-width: 34px; + padding: 0; + border-radius: 8px; + border: 1px solid transparent; + display: inline-flex; + align-items: center; + justify-content: center; + transition: background-color 240ms ease, + border-color 240ms ease, + box-shadow 240ms ease, + transform 150ms cubic-bezier(0.2, 0.8, 0.2, 1), + color 240ms ease; + + &:not(.has-active-filters) { + width: 34px; + } + + &.has-active-filters { + width: auto; + padding: 0 8px; + gap: 6px; + } + + &:active { + transform: scale(0.94); + } + + &:hover:not([aria-expanded="true"]) { + background-color: var(--filter-bg); + color: var(--color-text-primary); + } + } + .filter-badge { background-color: var(--filter-bg); color: var(--color-text-primary); @@ -447,7 +632,7 @@ padding: 0.1rem 0.4rem; border-radius: 99px; font-weight: 600; - margin-left: 0.5rem; + margin-left: 0.25rem; } } @@ -686,6 +871,27 @@ &.github .fa-github { color: var(--color-github, #24292e); } &.gitlab .fa-gitlab { color: var(--color-gitlab, #fc6d26); } + + .custom-gitlab-card-badge { + position: absolute; + bottom: -1px; + right: -1px; + width: 14px; + height: 14px; + border-radius: 50%; + background-color: var(--color-surface); + border: 1px solid var(--color-border); + color: var(--color-text-secondary); + display: flex; + align-items: center; + justify-content: center; + box-shadow: var(--shadow-sm); + + .card-server-svg { + width: 7px; + height: 7px; + } + } } .selection-checkbox-overlay { @@ -739,10 +945,14 @@ justify-content: center; border: none; background: transparent; - transition: all 0.2s; + transition: background-color 0.15s ease, color 0.15s ease, transform 0.15s ease; + backface-visibility: hidden; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; i { font-size: 1rem; + pointer-events: none; } &.text-primary { color: var(--color-primary) !important; } @@ -890,8 +1100,10 @@ } .sort-direction-svg { + pointer-events: none; + .sort-arrow { - transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1); + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); transform-origin: 19px 12px; transform: scaleY(1); } @@ -899,12 +1111,14 @@ &.is-asc .sort-arrow { transform: scaleY(-1); } +} - &:not(.is-asc).show-hover .sort-arrow { +.sort-direction-btn:hover { + .sort-direction-svg:not(.is-asc) .sort-arrow { transform: scaleY(-1); } - &.is-asc.show-hover .sort-arrow { + .sort-direction-svg.is-asc .sort-arrow { transform: scaleY(1); } } diff --git a/src/app/components/home/assignment-chooser/assignment-chooser.component.ts b/src/app/components/home/assignment-chooser/assignment-chooser.component.ts index 5427ae95..d38dbeb5 100644 --- a/src/app/components/home/assignment-chooser/assignment-chooser.component.ts +++ b/src/app/components/home/assignment-chooser/assignment-chooser.component.ts @@ -1,4 +1,16 @@ -import { Component, OnInit, TemplateRef, ViewChild, ChangeDetectorRef, OnDestroy, HostListener } from "@angular/core"; +import { + Component, + OnInit, + AfterViewInit, + TemplateRef, + ViewChild, + ViewChildren, + QueryList, + ChangeDetectorRef, + OnDestroy, + HostListener, + ElementRef, +} from "@angular/core"; import { Router } from "@angular/router"; import { Assignment } from "@models/Assignment.model"; import { GitProviderType } from "@models/GitAuthProvider.model"; @@ -14,20 +26,36 @@ import { ToastService } from "@services/toast.service"; import { Subscription } from "rxjs"; import * as moment from "moment"; +export interface ProviderFilterPill { + key: string; + label: string; + provider?: GitProviderType; + instanceHost?: string; + instanceName?: string; + isCustom?: boolean; +} + @Component({ selector: "assignment-chooser", templateUrl: "./assignment-chooser.component.html", styleUrls: ["./assignment-chooser.component.scss"], }) -export class AssignmentChooserComponent implements OnInit, OnDestroy { +export class AssignmentChooserComponent implements OnInit, AfterViewInit, OnDestroy { + @ViewChild("searchInput") searchInputRef?: ElementRef; + @ViewChildren("pillBtn") pillButtons?: QueryList>; + + indicatorLeft = 0; + indicatorWidth = 100; + assignments: any[]; // Using any to attach UI-specific properties temporarily sortField = "lastModificationDate"; sortDirection: "asc" | "desc" = "desc"; - filterType: "all" | "github" | "gitlab" = "all"; + filterType = "all"; searchQuery = ""; + searchFocused = false; advancedFilters = { status: { @@ -55,17 +83,17 @@ export class AssignmentChooserComponent implements OnInit, OnDestroy { // Provider split button dropdown state isProviderDropdownOpen = false; lastUsedProvider: GitProviderType = "github"; + lastUsedHost = "github.com"; + lastUsedInstanceName = ""; // Inline edit state editingAssignmentId: number | null = null; isCreatingNew = false; - isSortHovered = false; - sortWasClicked = false; - private dbSubscription?: Subscription; private overlaySub: Subscription | null = null; private accountsSub?: Subscription; + private pillButtonsSub?: Subscription; private statusPreviewTimeout: any = null; constructor( @@ -98,15 +126,152 @@ export class AssignmentChooserComponent implements OnInit, OnDestroy { this.closeAllCreationPopovers(); } + @HostListener("window:resize") + onWindowResize(): void { + this.updateIndicator(); + } + + get isSearchExpanded(): boolean { + return this.searchFocused || Boolean(this.searchQuery && this.searchQuery.trim().length > 0); + } + + get providerFilterPills(): ProviderFilterPill[] { + const pills: ProviderFilterPill[] = [{ key: "all", label: "HOME.FILTER.ALL" }]; + + const hasGithub = + this.accountsService.isGithubConnected || (this.assignments && this.assignments.some((a) => !a.provider || a.provider === "github")); + if (hasGithub) { + pills.push({ + key: "github", + label: "HOME.FILTER.GITHUB", + provider: "github", + instanceHost: "github.com", + }); + } + + const hasGitlabCloud = + this.accountsService.isGitlabConnected || + (this.assignments && + this.assignments.some( + (a) => a.provider === "gitlab" && (!a.instanceHost || a.instanceHost === "gitlab.com" || a.resolvedInstanceHost === "gitlab.com"), + )); + if (hasGitlabCloud) { + pills.push({ + key: "gitlab", + label: "HOME.FILTER.GITLAB", + provider: "gitlab", + instanceHost: "gitlab.com", + }); + } + + const customHostMap = new Map(); + + const connectedCustom = this.accountsService.gitlabCustomAuthService?.getAccounts() || []; + connectedCustom.forEach((acc) => { + if (acc.instanceHost) { + customHostMap.set(acc.instanceHost, { + name: acc.instanceName || this.cleanHostname(acc.instanceHost), + isConnected: true, + }); + } + }); + + if (this.assignments) { + this.assignments.forEach((a) => { + const host = a.instanceHost || (a.provider === "gitlab" && a.resolvedInstanceHost !== "gitlab.com" ? a.resolvedInstanceHost : null); + if (a.provider === "gitlab" && host && host !== "gitlab.com" && !customHostMap.has(host)) { + customHostMap.set(host, { + name: a.instanceName || this.cleanHostname(host), + isConnected: this.accountsService.hasAccountForHost("gitlab", host), + }); + } + }); + } + + customHostMap.forEach((info, host) => { + pills.push({ + key: `gitlab:${host}`, + label: `GitLab (${info.name})`, + provider: "gitlab", + instanceHost: host, + instanceName: info.name, + isCustom: true, + }); + }); + + return pills; + } + + get selectedFilterPill(): ProviderFilterPill | undefined { + return this.providerFilterPills.find((p) => p.key === this.filterType); + } + + get isCustomLastUsed(): boolean { + return this.lastUsedProvider === "gitlab" && Boolean(this.lastUsedHost && this.lastUsedHost !== "gitlab.com"); + } + + get canCreateLastUsed(): boolean { + return this.accountsService.hasAccountForHost(this.lastUsedProvider, this.lastUsedHost); + } + + get createButtonLabel(): string { + if (this.lastUsedProvider === "github") { + return this.translateService.instant("HOME.CREATE-ASSIGNMENT-GITHUB"); + } + if (this.lastUsedProvider === "gitlab") { + if (this.isCustomLastUsed) { + return this.translateService.instant("HOME.CREATE-ASSIGNMENT-GITLAB-CUSTOM", { + host: this.lastUsedInstanceName || this.lastUsedHost, + }); + } + return this.translateService.instant("HOME.CREATE-ASSIGNMENT-GITLAB"); + } + return this.translateService.instant("HOME.CREATE-ASSIGNMENT"); + } + + get createButtonTooltip(): string { + if (!this.canCreateLastUsed) { + if (this.lastUsedProvider === "gitlab") { + if (this.isCustomLastUsed) { + return this.translateService.instant("HOME.MUST-LOGIN-ASSIGNMENT-GITLAB-CUSTOM", { + host: this.lastUsedInstanceName || this.lastUsedHost, + }); + } + return this.translateService.instant("HOME.MUST-LOGIN-GITLAB"); + } + return this.translateService.instant("HOME.MUST-LOGIN-GITHUB"); + } + return ""; + } + + get connectedCustomGitlabAccounts(): any[] { + return this.accountsService.gitlabCustomAuthService?.getAccounts() || []; + } + get filteredAssignments() { let result = this.assignments; // Always show the assignment currently being created at the top, even if it doesn't match filters const newAssignment = this.assignments.find((a) => a.id === -1); - // Apply old filterType + // Apply filterType if (this.filterType !== "all") { - result = result.filter((a) => (a as any).uiType === this.filterType || a.id === -1); + if (this.filterType === "github") { + result = result.filter((a) => a.id === -1 || !a.provider || a.provider === "github"); + } else if (this.filterType === "gitlab" || this.filterType === "gitlab-cloud") { + result = result.filter( + (a) => + a.id === -1 || + (a.provider === "gitlab" && (!a.instanceHost || a.instanceHost === "gitlab.com" || a.resolvedInstanceHost === "gitlab.com")), + ); + } else if (this.filterType.startsWith("gitlab:")) { + const targetHost = this.filterType.substring(7); + result = result.filter( + (a) => a.id === -1 || (a.provider === "gitlab" && (a.instanceHost === targetHost || a.resolvedInstanceHost === targetHost)), + ); + } else { + result = result.filter((a) => (a as any).uiType === this.filterType || a.id === -1); + } } // Apply search query @@ -178,8 +343,135 @@ export class AssignmentChooserComponent implements OnInit, OnDestroy { this.savePreferences(); } - setFilter(type: "all" | "github" | "gitlab") { + onSearchContainerClick(event: MouseEvent): void { + if (!this.isSearchExpanded) { + event.preventDefault(); + this.searchFocused = true; + this.cdr.markForCheck(); + requestAnimationFrame(() => { + this.searchInputRef?.nativeElement?.focus(); + }); + } else if (document.activeElement !== this.searchInputRef?.nativeElement) { + this.searchInputRef?.nativeElement?.focus(); + } + } + + onSearchContainerEnter(event: Event): void { + if (!this.isSearchExpanded) { + event.preventDefault(); + this.searchFocused = true; + this.cdr.markForCheck(); + requestAnimationFrame(() => { + this.searchInputRef?.nativeElement?.focus(); + }); + } + } + + onSearchFocus(): void { + this.searchFocused = true; + } + + onSearchBlur(): void { + this.searchFocused = false; + } + + onSearchEscape(event?: Event): void { + if (event) { + event.stopPropagation(); + } + this.searchQuery = ""; + this.searchFocused = false; + this.searchInputRef?.nativeElement?.blur(); + } + + clearSearch(focusAfter = true): void { + this.searchQuery = ""; + if (focusAfter) { + this.searchInputRef?.nativeElement?.focus(); + } else { + this.searchFocused = false; + this.searchInputRef?.nativeElement?.blur(); + } + } + + setFilter(type: string): void { this.filterType = type; + this.updateIndicator(); + this.cdr.markForCheck(); + } + + updateIndicator(): void { + if (!this.pillButtons || this.pillButtons.length === 0) { + return; + } + const pills = this.providerFilterPills; + let activeIndex = pills.findIndex((p) => p.key === this.filterType); + if (activeIndex === -1) { + activeIndex = 0; + } + const buttonsArray = this.pillButtons.toArray(); + const targetBtn = buttonsArray[activeIndex]; + if (targetBtn && targetBtn.nativeElement) { + const el = targetBtn.nativeElement; + const groupEl = el.parentElement; + const groupPaddingLeft = groupEl ? parseFloat(getComputedStyle(groupEl).paddingLeft) || 4 : 4; + this.indicatorLeft = Math.max(0, el.offsetLeft - groupPaddingLeft); + this.indicatorWidth = el.offsetWidth || 100; + this.cdr.markForCheck(); + } + } + + isAssignmentConnected(assignment: any): boolean { + if (!assignment) { + return false; + } + const provider = (assignment.provider || assignment.uiType || "github") as GitProviderType; + const host = assignment.instanceHost || (assignment.resolvedInstanceHost ? assignment.resolvedInstanceHost : undefined); + return this.accountsService.hasAccountForHost(provider, host); + } + + isCustomGitlab(assignment: any): boolean { + if (!assignment) { + return false; + } + const provider = assignment.provider || assignment.uiType; + const host = assignment.instanceHost || assignment.resolvedInstanceHost; + return provider === "gitlab" && Boolean(host && host !== "gitlab.com"); + } + + getAssignmentTooltip(assignment: any): string { + if (this.isAssignmentConnected(assignment)) { + return "HOME.SELECT-ASSIGNMENT-TOOLTIP"; + } + return this.getDisconnectedTooltip(assignment); + } + + getAssignmentEditTooltip(assignment: any): string { + if (this.isAssignmentConnected(assignment)) { + return "HOME.EDIT-ASSIGNMENT-TOOLTIP"; + } + return this.getDisconnectedTooltip(assignment); + } + + getAssignmentDeleteTooltip(assignment: any): string { + if (this.isAssignmentConnected(assignment)) { + return "HOME.DELETE-ASSIGNMENT-TOOLTIP"; + } + return this.getDisconnectedTooltip(assignment); + } + + getDisconnectedTooltip(assignment: any): string { + const provider = assignment?.provider || "github"; + const host = assignment?.instanceHost || assignment?.resolvedInstanceHost; + if (provider === "gitlab") { + if (host && host !== "gitlab.com") { + return this.translateService.instant("HOME.MUST-LOGIN-ASSIGNMENT-GITLAB-CUSTOM", { + host: assignment?.instanceName || host, + }); + } + return this.translateService.instant("HOME.MUST-LOGIN-ASSIGNMENT-GITLAB"); + } + return this.translateService.instant("HOME.MUST-LOGIN-ASSIGNMENT-GITHUB"); } onStatusMouseEnter(status: string) { @@ -240,14 +532,6 @@ export class AssignmentChooserComponent implements OnInit, OnDestroy { this.cdr.markForCheck(); } - isAssignmentConnected(assignment: any): boolean { - if (!assignment) { - return false; - } - const provider = (assignment.provider || assignment.uiType || "github") as GitProviderType; - return this.accountsService.hasAccount(provider); - } - get isGithubConnected(): boolean { return this.accountsService.isGithubConnected; } @@ -282,9 +566,28 @@ export class AssignmentChooserComponent implements OnInit, OnDestroy { this.accountsSub = this.accountsService.accounts$.subscribe(() => { this.cdr.markForCheck(); + requestAnimationFrame(() => { + this.updateIndicator(); + }); }); } + ngAfterViewInit(): void { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + this.updateIndicator(); + }); + }); + + if (this.pillButtons) { + this.pillButtonsSub = this.pillButtons.changes.subscribe(() => { + requestAnimationFrame(() => { + this.updateIndicator(); + }); + }); + } + } + ngOnDestroy(): void { if (this.dbSubscription) { this.dbSubscription.unsubscribe(); @@ -295,6 +598,9 @@ export class AssignmentChooserComponent implements OnInit, OnDestroy { if (this.accountsSub) { this.accountsSub.unsubscribe(); } + if (this.pillButtonsSub) { + this.pillButtonsSub.unsubscribe(); + } if (this.statusPreviewTimeout) { clearTimeout(this.statusPreviewTimeout); } @@ -316,6 +622,14 @@ export class AssignmentChooserComponent implements OnInit, OnDestroy { if (savedProvider && (savedProvider === "github" || savedProvider === "gitlab")) { this.lastUsedProvider = savedProvider; } + const savedHost = localStorage.getItem("git4school_last_host"); + if (savedHost) { + this.lastUsedHost = savedHost; + } + const savedName = localStorage.getItem("git4school_last_instance_name"); + if (savedName) { + this.lastUsedInstanceName = savedName; + } } savePreferences() { @@ -368,6 +682,8 @@ export class AssignmentChooserComponent implements OnInit, OnDestroy { const lastAssignment = this.assignments.find((a) => a.provider && a.id !== -1); if (lastAssignment && lastAssignment.provider) { this.lastUsedProvider = lastAssignment.provider; + this.lastUsedHost = lastAssignment.instanceHost || (lastAssignment.provider === "gitlab" ? "gitlab.com" : "github.com"); + this.lastUsedInstanceName = lastAssignment.instanceName || ""; } } @@ -376,6 +692,9 @@ export class AssignmentChooserComponent implements OnInit, OnDestroy { this.assignments.unshift(newAssignmentObj); } this.cdr.detectChanges(); + requestAnimationFrame(() => { + this.updateIndicator(); + }); }); } @@ -615,19 +934,8 @@ export class AssignmentChooserComponent implements OnInit, OnDestroy { return moment(dateStr).format(format); } - onSortMouseEnter() { - this.isSortHovered = true; - this.sortWasClicked = false; - } - - onSortMouseLeave() { - this.isSortHovered = false; - this.sortWasClicked = false; - } - toggleSortDirection() { this.sortDirection = this.sortDirection === "asc" ? "desc" : "asc"; - this.sortWasClicked = true; this.sortAssignments(); } @@ -638,9 +946,9 @@ export class AssignmentChooserComponent implements OnInit, OnDestroy { selectAssignment(assignment: any) { const provider = assignment.provider || "github"; - if (!this.accountsService.hasAccount(provider)) { - const errorKey = provider === "gitlab" ? "HOME.MUST-LOGIN-ASSIGNMENT-GITLAB" : "HOME.MUST-LOGIN-ASSIGNMENT-GITHUB"; - const msg = this.translateService.instant(errorKey); + const host = assignment.instanceHost || (assignment.resolvedInstanceHost ? assignment.resolvedInstanceHost : undefined); + if (!this.accountsService.hasAccountForHost(provider, host)) { + const msg = this.getDisconnectedTooltip(assignment); this.toastService.warning(this.translateService.instant("WARNING"), msg); return; } @@ -652,24 +960,39 @@ export class AssignmentChooserComponent implements OnInit, OnDestroy { } deleteAssignment(assignment: any) { - if (!this.isAssignmentConnected(assignment)) return; + if (!this.isAssignmentConnected(assignment)) { + const msg = this.getDisconnectedTooltip(assignment); + this.toastService.warning(this.translateService.instant("WARNING"), msg); + return; + } this.databaseService.deleteAssignment(assignment.id); } - createAssignment(provider?: GitProviderType, event?: MouseEvent) { + createAssignment(provider?: GitProviderType, instanceHost?: string, instanceName?: string, event?: MouseEvent) { if (event) { event.stopPropagation(); } - const targetProvider: GitProviderType = provider || (this.filterType !== "all" ? this.filterType : this.lastUsedProvider) || "github"; + const targetProvider: GitProviderType = provider || (this.filterType === "github" ? "github" : this.lastUsedProvider) || "github"; + const targetHost = instanceHost || (targetProvider === "gitlab" ? this.lastUsedHost || "gitlab.com" : "github.com"); - if (!this.accountsService.hasAccount(targetProvider)) { + if (!this.accountsService.hasAccountForHost(targetProvider, targetHost)) { if (this.accountsService.isEmpty()) { const msg = this.translateService.instant("HOME.MUST-LOGIN"); this.toastService.warning(this.translateService.instant("WARNING"), msg); return; } - const errorKey = targetProvider === "gitlab" ? "HOME.MUST-LOGIN-GITLAB" : "HOME.MUST-LOGIN-GITHUB"; - const msg = this.translateService.instant(errorKey); + let msg = ""; + if (targetProvider === "gitlab") { + if (targetHost !== "gitlab.com") { + msg = this.translateService.instant("HOME.MUST-LOGIN-ASSIGNMENT-GITLAB-CUSTOM", { + host: instanceName || targetHost, + }); + } else { + msg = this.translateService.instant("HOME.MUST-LOGIN-GITLAB"); + } + } else { + msg = this.translateService.instant("HOME.MUST-LOGIN-GITHUB"); + } this.toastService.warning(this.translateService.instant("WARNING"), msg); return; } @@ -678,13 +1001,25 @@ export class AssignmentChooserComponent implements OnInit, OnDestroy { this.closeAllCreationPopovers(); this.lastUsedProvider = targetProvider; + this.lastUsedHost = targetHost; + this.lastUsedInstanceName = instanceName || ""; try { localStorage.setItem("git4school_last_provider", targetProvider); + localStorage.setItem("git4school_last_host", targetHost); + if (instanceName) { + localStorage.setItem("git4school_last_instance_name", instanceName); + } else { + localStorage.removeItem("git4school_last_instance_name"); + } } catch (e) {} let assignment = new Assignment(); assignment.id = -1; // Temporary ID for creation assignment.provider = targetProvider; + assignment.instanceHost = targetHost; + if (instanceName) { + assignment.instanceName = instanceName; + } (assignment as any).uiType = targetProvider; (assignment as any).uiStatus = "prepared"; this.assignments.unshift(assignment); // Add to the top @@ -703,17 +1038,12 @@ export class AssignmentChooserComponent implements OnInit, OnDestroy { return; } - /* If a specific provider filter is active, create directly with that provider */ - if (this.filterType === "github") { - this.createAssignment("github"); - return; - } - if (this.filterType === "gitlab") { - this.createAssignment("gitlab"); + if (this.selectedFilterPill && this.selectedFilterPill.key !== "all") { + this.createAssignment(this.selectedFilterPill.provider, this.selectedFilterPill.instanceHost, this.selectedFilterPill.instanceName); return; } - this.createAssignment(this.lastUsedProvider); + this.createAssignment(this.lastUsedProvider, this.lastUsedHost, this.lastUsedInstanceName); } toggleProviderDropdown(event?: MouseEvent): void { @@ -732,6 +1062,10 @@ export class AssignmentChooserComponent implements OnInit, OnDestroy { return item?.id !== undefined ? item.id : index; } + trackByPillKey(index: number, pill: ProviderFilterPill): string { + return pill.key; + } + editAssignment(assignment: any) { if (!this.isAssignmentConnected(assignment) && assignment.id !== -1) return; @@ -766,8 +1100,16 @@ export class AssignmentChooserComponent implements OnInit, OnDestroy { onAssignmentSaved(assignment: Assignment) { if (assignment && assignment.provider) { this.lastUsedProvider = assignment.provider; + this.lastUsedHost = assignment.instanceHost || (assignment.provider === "gitlab" ? "gitlab.com" : "github.com"); + this.lastUsedInstanceName = assignment.instanceName || ""; try { localStorage.setItem("git4school_last_provider", assignment.provider); + localStorage.setItem("git4school_last_host", this.lastUsedHost); + if (assignment.instanceName) { + localStorage.setItem("git4school_last_instance_name", assignment.instanceName); + } else { + localStorage.removeItem("git4school_last_instance_name"); + } } catch (e) {} } this.isCreatingNew = false; @@ -804,4 +1146,12 @@ export class AssignmentChooserComponent implements OnInit, OnDestroy { this.importDB(file); } } + + private cleanHostname(host: string): string { + return host + .replace(/^https?:\/\//, "") + .replace(/^www\./, "") + .replace(/^gitlab\./, "") + .replace(/\/$/, ""); + } } diff --git a/src/app/components/home/assignment-chooser/configuration/edit-repositories/edit-repositories.component.ts b/src/app/components/home/assignment-chooser/configuration/edit-repositories/edit-repositories.component.ts index ced73f68..101137c6 100644 --- a/src/app/components/home/assignment-chooser/configuration/edit-repositories/edit-repositories.component.ts +++ b/src/app/components/home/assignment-chooser/configuration/edit-repositories/edit-repositories.component.ts @@ -51,7 +51,7 @@ export class EditRepositoriesComponent extends BaseTabEditConfigurationComponent } get isConnectedToProvider(): boolean { - return this.accountsService.hasAccount(this.provider); + return this.accountsService.hasAccountForHost(this.provider, this.assignment?.instanceHost || this.assignment?.resolvedInstanceHost); } private rotateMatrix: { [key: string]: SortDirection } = { @@ -288,6 +288,7 @@ export class EditRepositoriesComponent extends BaseTabEditConfigurationComponent let modalReference: CustomModalRef = this.modalService.open(ModalAddRepositoriesComponent, { size: "lg" }); modalReference.componentInstance.repoList = this.getFormControls.map((row) => Repository.withJSON(row.value)); modalReference.componentInstance.provider = this.provider; + modalReference.componentInstance.instanceHost = this.assignment?.instanceHost || this.assignment?.resolvedInstanceHost; modalReference.result.then( (result) => { diff --git a/src/app/components/home/assignment-chooser/configuration/edit-repositories/modal-add-repositories/modal-add-repositories.component.html b/src/app/components/home/assignment-chooser/configuration/edit-repositories/modal-add-repositories/modal-add-repositories.component.html index 5e94aa6a..919cbe59 100644 --- a/src/app/components/home/assignment-chooser/configuration/edit-repositories/modal-add-repositories/modal-add-repositories.component.html +++ b/src/app/components/home/assignment-chooser/configuration/edit-repositories/modal-add-repositories/modal-add-repositories.component.html @@ -256,7 +256,7 @@
{{ 'EDIT-REPOSITORIES.NO-REPOSITORIES' | translate }}
- +
diff --git a/src/app/components/home/assignment-chooser/configuration/edit-repositories/modal-add-repositories/modal-add-repositories.component.ts b/src/app/components/home/assignment-chooser/configuration/edit-repositories/modal-add-repositories/modal-add-repositories.component.ts index d5d455ef..da43eb9f 100644 --- a/src/app/components/home/assignment-chooser/configuration/edit-repositories/modal-add-repositories/modal-add-repositories.component.ts +++ b/src/app/components/home/assignment-chooser/configuration/edit-repositories/modal-add-repositories/modal-add-repositories.component.ts @@ -16,6 +16,7 @@ export class ModalAddRepositoriesComponent implements OnInit, OnDestroy, AfterVi @ViewChild("reposTable", { read: ElementRef }) datatable: ElementRef; @Input() repoList: Repository[]; @Input() provider: GitProviderType = "github"; + @Input() instanceHost?: string; rows: Repository[]; nameMatches: Repository[] = []; contentMatches: Repository[] = []; @@ -304,11 +305,17 @@ export class ModalAddRepositoriesComponent implements OnInit, OnDestroy, AfterVi } private updateResultsWithAuthenticatedUser(cursor?: string, isFirstPage = false) { - this.processIntermediateResponse(this.commitsService.getRepositoriesByAuthenticatedUser(cursor, 100, this.provider), isFirstPage); + this.processIntermediateResponse( + this.commitsService.getRepositoriesByAuthenticatedUser(cursor, 100, this.provider, this.instanceHost), + isFirstPage, + ); } private updateResultsWithSearchFilter(searchFilter: string, cursor?: string, isFirstPage = false) { - this.processIntermediateResponse(this.commitsService.getRepositoriesBySearch(searchFilter, cursor, 100, this.provider), isFirstPage); + this.processIntermediateResponse( + this.commitsService.getRepositoriesBySearch(searchFilter, cursor, 100, this.provider, this.instanceHost), + isFirstPage, + ); } private loadResults() { diff --git a/src/app/components/nav-layouts/app-nav-layout/app-nav-layout.component.html b/src/app/components/nav-layouts/app-nav-layout/app-nav-layout.component.html index a1b39ca6..7eda8413 100644 --- a/src/app/components/nav-layouts/app-nav-layout/app-nav-layout.component.html +++ b/src/app/components/nav-layouts/app-nav-layout/app-nav-layout.component.html @@ -52,6 +52,24 @@ diff --git a/src/app/components/nav-layouts/app-nav-layout/app-nav-layout.component.ts b/src/app/components/nav-layouts/app-nav-layout/app-nav-layout.component.ts index 683c906a..2fc37ec1 100644 --- a/src/app/components/nav-layouts/app-nav-layout/app-nav-layout.component.ts +++ b/src/app/components/nav-layouts/app-nav-layout/app-nav-layout.component.ts @@ -7,6 +7,7 @@ import { ConfigurationService } from "@services/configuration.service"; import { DataService } from "@services/data.service"; import { DatabaseService } from "@services/database.service"; import { ThemeService } from "@services/theme.service"; +import { AnonymizationService } from "@services/anonymization.service"; import { CustomModalService } from "@shared/ui/custom-modal/custom-modal.service"; import { CustomModalRef } from "@shared/ui/custom-modal/custom-modal-ref"; import { ShortcutsModalComponent } from "@shared/ui/shortcuts-modal/shortcuts-modal.component"; @@ -34,6 +35,7 @@ export class AppNavLayoutComponent implements OnInit { private databaseService: DatabaseService, private configurationService: ConfigurationService, public themeService: ThemeService, + public anonymizationService: AnonymizationService, private router: Router, private customModalService: CustomModalService, ) {} @@ -126,6 +128,11 @@ export class AppNavLayoutComponent implements OnInit { this.openCurrentAssignmentConfig(); } } + } else if (key === "a" && !event.ctrlKey && !event.metaKey && !event.altKey) { + if (this.dataService.dataLoaded() && !this.isHome && !this.hasActiveModal) { + event.preventDefault(); + this.anonymizationService.toggleAnonymousMode(); + } } } diff --git a/src/app/components/nav-layouts/sidebar-settings/accounts/accounts.component.html b/src/app/components/nav-layouts/sidebar-settings/accounts/accounts.component.html index 312b8cb0..b6a140c6 100644 --- a/src/app/components/nav-layouts/sidebar-settings/accounts/accounts.component.html +++ b/src/app/components/nav-layouts/sidebar-settings/accounts/accounts.component.html @@ -20,6 +20,19 @@ + + + + diff --git a/src/app/components/nav-layouts/sidebar-settings/accounts/accounts.component.scss b/src/app/components/nav-layouts/sidebar-settings/accounts/accounts.component.scss index 4dc49494..d79d62f1 100644 --- a/src/app/components/nav-layouts/sidebar-settings/accounts/accounts.component.scss +++ b/src/app/components/nav-layouts/sidebar-settings/accounts/accounts.component.scss @@ -60,6 +60,32 @@ $ease-spring-out: cubic-bezier(0.25, 1, 0.4, 1); border: 1px solid var(--color-border); } + .account-avatar-invalid-overlay { + position: absolute; + top: 0; + left: 0; + width: 34px; + height: 34px; + border-radius: 50%; + background-color: var(--color-danger-bg); + backdrop-filter: blur(1.5px); + -webkit-backdrop-filter: blur(1.5px); + color: var(--color-danger); + display: flex; + align-items: center; + justify-content: center; + border: 1.5px solid var(--color-danger-subtle); + box-shadow: 0 1px 4px rgba(220, 38, 38, 0.15); + cursor: default; + z-index: 1; + + svg { + width: 17px; + height: 17px; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.2)); + } + } + .provider-badge-icon { position: absolute; bottom: -3px; @@ -73,6 +99,7 @@ $ease-spring-out: cubic-bezier(0.25, 1, 0.4, 1); font-size: 0.6rem; background-color: var(--color-surface); border: 1px solid var(--color-border); + z-index: 2; svg { width: 10px; @@ -425,3 +452,4 @@ $ease-spring-out: cubic-bezier(0.25, 1, 0.4, 1); } } } + diff --git a/src/app/components/nav-layouts/sidebar-settings/accounts/add-account-modal/add-account-modal.component.html b/src/app/components/nav-layouts/sidebar-settings/accounts/add-account-modal/add-account-modal.component.html index 33ae8ae1..fcea464c 100644 --- a/src/app/components/nav-layouts/sidebar-settings/accounts/add-account-modal/add-account-modal.component.html +++ b/src/app/components/nav-layouts/sidebar-settings/accounts/add-account-modal/add-account-modal.component.html @@ -35,7 +35,7 @@
- +
@@ -104,13 +112,44 @@
- - {{ 'ACCOUNTS.COMING_SOON' | translate }} - +
+ + + {{ customInstancesCountText }} + + +
{{ 'ACCOUNTS.PLATFORM_GITLAB_SELF_TITLE' | translate }}
+ + +
+ + + +
@@ -137,7 +176,12 @@