diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 3e39edc0..c1bf1607 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -23,6 +23,7 @@
"@angular/platform-browser-dynamic": "16.2.8",
"@angular/router": "16.2.8",
"@fontsource-variable/jetbrains-mono": "5.0.16",
+ "@fontsource-variable/space-grotesk": "^5.2.10",
"@fontsource/barlow": "^5.0.8",
"@fontsource/varela-round": "^5.0.8",
"@mdi/svg": "7.3.67",
@@ -3069,6 +3070,15 @@
"resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.0.16.tgz",
"integrity": "sha512-KTQ+QFW/5OALFDg4dgR5CrzsLncDK1FfNEDCkatWRwrsjS4BLkt3AzTcNBcND1MN3Yae63zSgVoy7ihYdCVi+A=="
},
+ "node_modules/@fontsource-variable/space-grotesk": {
+ "version": "5.2.10",
+ "resolved": "https://registry.npmjs.org/@fontsource-variable/space-grotesk/-/space-grotesk-5.2.10.tgz",
+ "integrity": "sha512-yJQO/o35/hAP3CFnpdFTwQku2yzJOae2HIpBmqkOVoxhhXJaQP3g+b6Jrz7u+eI7A5ZdCIf88uMWpBJdFiGr5w==",
+ "license": "OFL-1.1",
+ "funding": {
+ "url": "https://github.com/sponsors/ayuhito"
+ }
+ },
"node_modules/@fontsource/barlow": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/@fontsource/barlow/-/barlow-5.0.8.tgz",
@@ -17868,6 +17878,11 @@
"resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.0.16.tgz",
"integrity": "sha512-KTQ+QFW/5OALFDg4dgR5CrzsLncDK1FfNEDCkatWRwrsjS4BLkt3AzTcNBcND1MN3Yae63zSgVoy7ihYdCVi+A=="
},
+ "@fontsource-variable/space-grotesk": {
+ "version": "5.2.10",
+ "resolved": "https://registry.npmjs.org/@fontsource-variable/space-grotesk/-/space-grotesk-5.2.10.tgz",
+ "integrity": "sha512-yJQO/o35/hAP3CFnpdFTwQku2yzJOae2HIpBmqkOVoxhhXJaQP3g+b6Jrz7u+eI7A5ZdCIf88uMWpBJdFiGr5w=="
+ },
"@fontsource/barlow": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/@fontsource/barlow/-/barlow-5.0.8.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index abf1992a..de312c52 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -26,6 +26,7 @@
"@angular/platform-browser-dynamic": "16.2.8",
"@angular/router": "16.2.8",
"@fontsource-variable/jetbrains-mono": "5.0.16",
+ "@fontsource-variable/space-grotesk": "^5.2.10",
"@fontsource/barlow": "^5.0.8",
"@fontsource/varela-round": "^5.0.8",
"@mdi/svg": "7.3.67",
diff --git a/frontend/src/app/views/login/login.component.html b/frontend/src/app/views/login/login.component.html
index 0dbad458..c685f841 100644
--- a/frontend/src/app/views/login/login.component.html
+++ b/frontend/src/app/views/login/login.component.html
@@ -1,28 +1,52 @@
-
-
-
-
-

-
-
-
O-NEKO
+
+
+
+
+
+
+
+
+
+

+
-
-
-
+
+
O-NEKO
+
{{'views.login.tagline1' | translate}}
{{'views.login.tagline2' | translate}}
+
-
-
-
+
open source · apache-2.0
+
-
-
-
diff --git a/frontend/src/app/views/login/login.component.scss b/frontend/src/app/views/login/login.component.scss
index 3e5660b3..a0e284e5 100644
--- a/frontend/src/app/views/login/login.component.scss
+++ b/frontend/src/app/views/login/login.component.scss
@@ -1,39 +1,274 @@
-@use '@angular/material' as mat;
-@import "src/styles/mixins";
+$ease: cubic-bezier(.2, .8, .2, 1);
+
+login {
+ .login-page {
+ --accent: #ff8a00;
+ --accent-grad: linear-gradient(135deg, #ffdd00, #ff6200);
+ --pink: #ff4066;
+ --blue: #2f9dff;
+
+ display: flex;
+ min-height: 100vh;
+ background: var(--bg);
+ color: var(--text);
+ font-family: 'Space Grotesk Variable', sans-serif;
+
+ &.exiting {
+ animation: on-login-out .45s ease both;
+ }
+ }
+
+ .brand-panel {
+ flex: 1.25;
+ position: relative;
+ overflow: hidden;
+ min-width: 0;
+ background: linear-gradient(165deg, #13151e 0%, #191424 55%, #1e1520 100%);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 36px;
+
+ .ambient {
+ position: absolute;
+ transform: rotate(45deg);
+ }
+
+ .shape-1 { left: 12%; top: 18%; width: 22px; height: 22px; background: var(--pink); opacity: .28; animation: on-floaty 7s ease-in-out infinite; }
+ .shape-2 { right: 16%; top: 12%; width: 14px; height: 14px; background: var(--blue); opacity: .3; animation: on-floaty 5.5s ease-in-out 1s infinite; }
+ .shape-3 { left: 20%; bottom: 16%; width: 30px; height: 30px; border: 2px solid var(--accent); opacity: .22; animation: on-floaty 8s ease-in-out .5s infinite; }
+ .shape-4 { right: 12%; bottom: 26%; width: 18px; height: 18px; background: var(--accent); opacity: .2; animation: on-floaty 6.2s ease-in-out 2s infinite; }
+ .shape-5 { left: 45%; top: 8%; width: 10px; height: 10px; background: #ffdd00; opacity: .25; animation: on-floaty 5s ease-in-out 1.6s infinite; }
+ }
+
+ // The logo parts animate via the styles embedded in oneko-animated.svg.
+ .logo-wrap {
+ width: min(300px, 40vw);
+ animation: on-floaty 6s ease-in-out 1.6s infinite;
-.login {
- .o-neko-logo {
- z-index: 1000;
img {
- max-width: 280px;
+ width: 100%;
+ height: auto;
display: block;
- margin: 4em auto 2em;
- transition: all ease 0.8s;
+ filter: drop-shadow(0 18px 40px rgba(255, 98, 0, .25));
+ }
+ }
+
+ .brand-text {
+ text-align: center;
+ position: relative;
+ animation: on-fade-up .6s .9s $ease both;
+
+ .wordmark {
+ font-size: 34px;
+ font-weight: 700;
+ letter-spacing: .22em;
+ margin-left: .22em;
+ color: #f2f3f7;
}
- &.rotate {
- img {
- transform: scale(2);
+
+ .tagline {
+ margin-top: 10px;
+ font-size: 15px;
+ color: #9aa0b4;
+ max-width: 340px;
+ line-height: 1.5;
+ }
+ }
+
+ .brand-footer {
+ position: absolute;
+ bottom: 24px;
+ font-size: 12px;
+ color: #565c70;
+ font-family: 'JetBrains Mono Variable', monospace;
+ }
+
+ .form-panel {
+ width: clamp(380px, 36vw, 520px);
+ background: var(--surface);
+ border-left: 1px solid var(--border);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 48px;
+ position: relative;
+ box-sizing: border-box;
+ }
+
+ .panel-controls {
+ position: absolute;
+ top: 22px;
+ right: 22px;
+ display: flex;
+ gap: 8px;
+ align-items: center;
+
+ .lang-switch {
+ display: flex;
+ align-items: center;
+ height: 32px;
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ overflow: hidden;
+
+ button {
+ border: none;
+ height: 32px;
+ padding: 0 12px;
+ background: transparent;
+ color: var(--muted);
+ font-size: 11.5px;
+ font-weight: 700;
+ font-family: inherit;
+ letter-spacing: .05em;
+ cursor: pointer;
+
+ &.active {
+ background: var(--surface2);
+ color: var(--text);
+ }
+ }
+ }
+
+ .theme-toggle {
+ width: 32px;
+ height: 32px;
+ border-radius: 999px;
+ border: 1px solid var(--border);
+ background: transparent;
+ color: var(--text);
+ display: grid;
+ place-items: center;
+ padding: 0;
+ cursor: pointer;
+
+ &:hover {
+ border-color: var(--accent);
+ color: var(--accent);
}
}
}
+
.login-form {
- position: relative;
- h1 {
- text-align: center;
- font-weight: 500;
- font-family: "Varela Round", sans-serif;
- font-size: 36px;
- mix-blend-mode: overlay;
+ width: 100%;
+ max-width: 330px;
+ display: flex;
+ flex-direction: column;
+ gap: 18px;
+
+ .form-heading {
+ animation: on-fade-up .55s .25s $ease both;
+
+ h1 {
+ margin: 0;
+ font-size: 26px;
+ font-weight: 700;
+ font-family: inherit;
+ }
+
+ p {
+ margin: 6px 0 0;
+ font-size: 14px;
+ color: var(--muted);
+ }
}
- border-radius: $mat-radius * 2;
- padding: 2em;
- min-width: 280px;
- @include mat.elevation(4);
- mat-form-field {
- .mat-form-field-outline {
- border-radius: 5px;
+ .field {
+ display: flex;
+ flex-direction: column;
+ gap: 7px;
+
+ span {
+ font-size: 12.5px;
+ font-weight: 600;
+ color: var(--muted);
+ letter-spacing: .04em;
+ text-transform: uppercase;
+ }
+
+ input {
+ height: 46px;
+ border-radius: 10px;
+ border: 1.5px solid var(--border);
+ background: var(--input-bg);
+ color: var(--text);
+ padding: 0 14px;
+ font-size: 15px;
+ font-family: inherit;
+ transition: border-color .2s;
+
+ &:focus {
+ outline: none;
+ border-color: var(--accent);
+ }
}
}
+
+ .field-username { animation: on-fade-up .55s .38s $ease both; }
+ .field-password { animation: on-fade-up .55s .51s $ease both; }
+
+ button[type="submit"] {
+ height: 48px;
+ border: none;
+ border-radius: 10px;
+ background: var(--accent-grad);
+ color: #241300;
+ font-size: 15.5px;
+ font-weight: 700;
+ font-family: inherit;
+ letter-spacing: .02em;
+ margin-top: 6px;
+ cursor: pointer;
+ animation: on-fade-up .55s .64s $ease both;
+ transition: transform .15s ease, box-shadow .2s ease;
+
+ &:hover:not(:disabled) {
+ transform: translateY(-1px);
+ box-shadow: 0 8px 22px rgba(255, 98, 0, .35);
+ }
+
+ &:active {
+ transform: translateY(0);
+ }
+
+ &:disabled {
+ cursor: default;
+ }
+ }
+ }
+
+ @media (max-width: 720px) {
+ .brand-panel {
+ display: none;
+ }
+
+ .form-panel {
+ width: 100%;
+ border-left: none;
+ }
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ login .login-page,
+ login .login-page * {
+ animation: none !important;
}
}
+
+@keyframes on-fade-up {
+ from { opacity: 0; transform: translateY(14px); }
+ to { opacity: 1; transform: none; }
+}
+
+@keyframes on-floaty {
+ 0%, 100% { transform: translateY(0); }
+ 50% { transform: translateY(-12px); }
+}
+
+// Duration must stay below LoginComponent.EXIT_NAVIGATION_DELAY_MS.
+@keyframes on-login-out {
+ to { opacity: 0; transform: scale(.985); }
+}
diff --git a/frontend/src/app/views/login/login.component.theme.scss b/frontend/src/app/views/login/login.component.theme.scss
index 9742f603..c0730ecb 100644
--- a/frontend/src/app/views/login/login.component.theme.scss
+++ b/frontend/src/app/views/login/login.component.theme.scss
@@ -1,39 +1,23 @@
-@use '@angular/material' as mat;
-@import "src/styles/mixins";
-
@mixin login-component-theme($theme) {
- $primary: map-get($theme, primary);
- $accent: map-get($theme, accent);
- $background: map-get($theme, background);
$isDark: map-get($theme, is-dark);
- login {
- .o-neko-logo {
- filter: drop-shadow(1px 2px 2px rgba(black, .36));
- }
-
- .login {
- .login-form {
- h1 {
- color: white;
- }
-
- background: linear-gradient(225deg, mat.get-color-from-palette($primary, 700) 0%, mat.get-color-from-palette($accent, 900) 100%);
-
- button {
- @if ($isDark) {
- color: white;
- } @else {
- color: mat.get-color-from-palette($primary);
- }
- }
-
- mat-form-field {
- .mat-form-field-outline {
- background: map-get($background, background) !important;
- }
- }
- }
+ login .login-page {
+ @if $isDark {
+ --bg: #101218;
+ --surface: #1a1d29;
+ --surface2: #212536;
+ --border: rgba(255, 255, 255, .08);
+ --text: #edeef2;
+ --muted: #9297a8;
+ --input-bg: #12141d;
+ } @else {
+ --bg: #f4f3ef;
+ --surface: #ffffff;
+ --surface2: #f7f6f1;
+ --border: rgba(22, 24, 38, .1);
+ --text: #1b1d27;
+ --muted: #676c7e;
+ --input-bg: #f1f0eb;
}
}
}
diff --git a/frontend/src/app/views/login/login.component.ts b/frontend/src/app/views/login/login.component.ts
index 354ed95f..8cf245d2 100644
--- a/frontend/src/app/views/login/login.component.ts
+++ b/frontend/src/app/views/login/login.component.ts
@@ -1,7 +1,11 @@
import {Component, ViewEncapsulation} from "@angular/core";
import {MatLegacySnackBar as MatSnackBar} from "@angular/material/legacy-snack-bar";
import {Router} from "@angular/router";
+import {Select, Store} from "@ngxs/store";
+import {Observable} from "rxjs";
import {RestService} from "../../rest/rest.service";
+import {I18nState, OnekoLocale, SetLocale} from "../../store/i18n/i18n.state";
+import {SetThemeMode, ThemingState} from "../../store/theming/theming.state";
import {TimeoutSnackbarComponent} from "../../util/timout-snackbar/timeout.snackbar.component";
@Component({
@@ -12,20 +16,36 @@ import {TimeoutSnackbarComponent} from "../../util/timout-snackbar/timeout.snack
})
export class LoginComponent {
+ // Must be at least the duration of the .exiting animation in login.component.scss (.45s).
+ private static readonly EXIT_NAVIGATION_DELAY_MS = 500;
+
username = '';
password = '';
loggingIn = false;
+ exiting = false;
+
+ @Select(I18nState.locale) locale$: Observable
;
+ @Select(ThemingState.isDarkMode) isDarkMode$: Observable;
- constructor(private rest: RestService, private router: Router, private snackBar: MatSnackBar) {
+ constructor(private rest: RestService,
+ private router: Router,
+ private snackBar: MatSnackBar,
+ private store: Store) {
}
public login() {
+ if (this.loggingIn) {
+ return;
+ }
this.loggingIn = true;
this.rest.login(this.username, this.password).subscribe(success => {
- this.username = '';
- this.password = '';
- this.router.navigate(['']);
- this.loggingIn = false;
+ this.exiting = true;
+ setTimeout(() => {
+ this.username = '';
+ this.password = '';
+ this.router.navigate(['']);
+ this.loggingIn = false;
+ }, LoginComponent.EXIT_NAVIGATION_DELAY_MS);
}, error => {
this.loggingIn = false;
this.snackBar.openFromComponent(TimeoutSnackbarComponent, {
@@ -37,4 +57,12 @@ export class LoginComponent {
});
}
+ public setLocale(locale: OnekoLocale) {
+ this.store.dispatch(new SetLocale(locale));
+ }
+
+ public toggleTheme() {
+ const isDark = this.store.selectSnapshot(ThemingState.isDarkMode);
+ this.store.dispatch(new SetThemeMode(isDark ? 'light' : 'dark'));
+ }
}
diff --git a/frontend/src/assets/i18n/de.json b/frontend/src/assets/i18n/de.json
index 8ddcdec3..99e60790 100644
--- a/frontend/src/assets/i18n/de.json
+++ b/frontend/src/assets/i18n/de.json
@@ -37,7 +37,13 @@
},
"views": {
"login": {
- "doLogin": "Einloggen"
+ "doLogin": "Anmelden",
+ "signingIn": "Anmeldung…",
+ "switchTheme": "Theme wechseln",
+ "welcome": "Willkommen zurück",
+ "subtitle": "Melde dich an, um deine Deployments zu verwalten.",
+ "tagline1": "On-Demand-Preview-Umgebungen,",
+ "tagline2": "nativ in deinem Kubernetes-Cluster."
},
"logs": {
"activityLog": "Ereignisse"
diff --git a/frontend/src/assets/i18n/en.json b/frontend/src/assets/i18n/en.json
index fec6c0a4..19f26a3c 100644
--- a/frontend/src/assets/i18n/en.json
+++ b/frontend/src/assets/i18n/en.json
@@ -37,7 +37,13 @@
},
"views": {
"login": {
- "doLogin": "Login"
+ "doLogin": "Sign in",
+ "signingIn": "Signing in…",
+ "switchTheme": "Switch theme",
+ "welcome": "Welcome back",
+ "subtitle": "Sign in to manage your deployments.",
+ "tagline1": "On-demand preview environments,",
+ "tagline2": "native to your Kubernetes cluster."
},
"logs": {
"activityLog": "Event Log"
diff --git a/frontend/src/assets/oneko-animated.svg b/frontend/src/assets/oneko-animated.svg
new file mode 100644
index 00000000..49abf668
--- /dev/null
+++ b/frontend/src/assets/oneko-animated.svg
@@ -0,0 +1,56 @@
+
diff --git a/frontend/src/styles/fonts/fontsource-imports.scss b/frontend/src/styles/fonts/fontsource-imports.scss
index e732adc6..d8190ac3 100644
--- a/frontend/src/styles/fonts/fontsource-imports.scss
+++ b/frontend/src/styles/fonts/fontsource-imports.scss
@@ -1,3 +1,4 @@
@import "@fontsource/barlow/index.css";
@import "@fontsource/varela-round/index.css";
@import "@fontsource-variable/jetbrains-mono/index.css";
+@import "@fontsource-variable/space-grotesk/index.css";