diff --git a/apps/web/public/app.js b/apps/web/public/app.js
index 3867935..cb0a221 100644
--- a/apps/web/public/app.js
+++ b/apps/web/public/app.js
@@ -24,11 +24,19 @@ const state = {
""
};
+const LOGIN_SESSION_KEY = "cargoRadarLoginReady";
+const LOGIN_NAME_KEY = "cargoRadarLoginName";
+
const elements = {
+ loginScreen: document.getElementById("loginScreen"),
+ loginForm: document.getElementById("loginForm"),
+ loginNameInput: document.getElementById("loginNameInput"),
+ loginCodeInput: document.getElementById("loginCodeInput"),
connectionStatus: document.getElementById("connectionStatus"),
systemClock: document.getElementById("systemClock"),
refreshButton: document.getElementById("refreshButton"),
demoButton: document.getElementById("demoButton"),
+ logoutButton: document.getElementById("logoutButton"),
locationsTab: document.getElementById("locationsTab"),
driversTab: document.getElementById("driversTab"),
locationsPanel: document.getElementById("locationsPanel"),
@@ -113,13 +121,20 @@ const VEHICLE_TYPES = {
};
document.addEventListener("DOMContentLoaded", () => {
+ const sessionReady = initializeLoginState();
startSystemClock();
initializeMap();
bindActions();
+ if (sessionReady) {
+ startDataServices();
+ }
+});
+
+function startDataServices() {
refreshDrivers();
refreshLatestLocations();
connectEvents();
-});
+}
async function initializeMap() {
const kakaoJavaScriptKey = window.CARGORADAR_CONFIG?.kakaoJavaScriptKey || "";
@@ -247,8 +262,10 @@ function loadScript(id, src) {
}
function bindActions() {
+ elements.loginForm.addEventListener("submit", submitLoginForm);
elements.refreshButton.addEventListener("click", refreshLatestLocations);
elements.demoButton.addEventListener("click", postDemoLocation);
+ elements.logoutButton.addEventListener("click", showLoginScreen);
elements.locationsTab.addEventListener("click", () => setActiveView("locations"));
elements.driversTab.addEventListener("click", () => setActiveView("drivers"));
elements.driverForm.addEventListener("submit", submitDriverForm);
@@ -278,6 +295,63 @@ function bindActions() {
}
}
+function initializeLoginState() {
+ const sessionReady = window.localStorage.getItem(LOGIN_SESSION_KEY) === "true";
+ const savedName = window.localStorage.getItem(LOGIN_NAME_KEY) || "";
+ elements.loginNameInput.value = savedName;
+
+ if (sessionReady) {
+ hideLoginScreen({ instant: true });
+ return true;
+ }
+
+ showLoginScreen({ instant: true });
+ return false;
+}
+
+function submitLoginForm(event) {
+ event.preventDefault();
+
+ const name = elements.loginNameInput.value.trim();
+ const code = elements.loginCodeInput.value.trim();
+ state.adminToken = code || state.adminToken || "demo";
+
+ window.localStorage.setItem(LOGIN_SESSION_KEY, "true");
+ window.localStorage.setItem(LOGIN_NAME_KEY, name || "관제 담당자");
+ window.localStorage.setItem("cargoRadarAdminToken", state.adminToken);
+ elements.loginCodeInput.value = "";
+ hideLoginScreen();
+ startDataServices();
+}
+
+function showLoginScreen(options = {}) {
+ window.localStorage.removeItem(LOGIN_SESSION_KEY);
+ if (state.eventSource) {
+ state.eventSource.close();
+ state.eventSource = null;
+ }
+ document.body.classList.remove("is-login-closing");
+ document.body.classList.add("is-login-active");
+ if (options.instant) {
+ document.body.classList.add("is-login-ready");
+ }
+ window.setTimeout(() => elements.loginNameInput.focus(), options.instant ? 80 : 220);
+}
+
+function hideLoginScreen(options = {}) {
+ if (options.instant) {
+ document.body.classList.remove("is-login-active", "is-login-closing");
+ document.body.classList.add("is-login-ready");
+ return;
+ }
+
+ document.body.classList.add("is-login-closing");
+ window.setTimeout(() => {
+ document.body.classList.remove("is-login-active", "is-login-closing");
+ document.body.classList.add("is-login-ready");
+ }, 620);
+}
+
function startSystemClock() {
const updateClock = () => {
const now = new Date();
diff --git a/apps/web/public/assets/login-logistics-yard.png b/apps/web/public/assets/login-logistics-yard.png
new file mode 100644
index 0000000..0d707e5
Binary files /dev/null and b/apps/web/public/assets/login-logistics-yard.png differ
diff --git a/apps/web/public/index.html b/apps/web/public/index.html
index 53965d7..4f11aed 100644
--- a/apps/web/public/index.html
+++ b/apps/web/public/index.html
@@ -6,7 +6,43 @@
diff --git a/apps/web/public/styles.css b/apps/web/public/styles.css
index a5c7fdc..2c69766 100644
--- a/apps/web/public/styles.css
+++ b/apps/web/public/styles.css
@@ -60,11 +60,302 @@ button:hover {
background: #f9fbfc;
}
+body.is-login-active {
+ overflow: hidden;
+}
+
+body.is-login-active .app-shell {
+ filter: blur(8px);
+ pointer-events: none;
+ transform: scale(0.985);
+}
+
+.login-screen {
+ position: fixed;
+ inset: 0;
+ z-index: 2000;
+ display: none;
+ min-height: 100%;
+ overflow: hidden;
+ background: #101923;
+ color: #ffffff;
+}
+
+body.is-login-active .login-screen {
+ display: grid;
+ place-items: center;
+}
+
+body.is-login-closing .login-screen {
+ animation: login-screen-out 0.62s ease forwards;
+}
+
+.login-visual,
+.login-shade {
+ position: absolute;
+ inset: 0;
+}
+
+.login-visual {
+ background:
+ url("/assets/login-logistics-yard.png") center / cover no-repeat;
+ transform: scale(1.035);
+ animation: login-photo-settle 8s ease-out forwards;
+}
+
+.login-shade {
+ background:
+ linear-gradient(90deg, rgba(10, 18, 28, 0.78) 0%, rgba(10, 18, 28, 0.5) 46%, rgba(10, 18, 28, 0.42) 100%),
+ linear-gradient(0deg, rgba(9, 16, 24, 0.42), rgba(9, 16, 24, 0.08));
+}
+
+.login-route-line {
+ position: absolute;
+ width: 36vw;
+ height: 1px;
+ background: rgba(255, 255, 255, 0.38);
+ opacity: 0;
+ transform-origin: left center;
+ animation: login-line-pass 3.8s ease-in-out infinite;
+}
+
+.login-route-line::after {
+ position: absolute;
+ top: -3px;
+ right: 0;
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: #ffffff;
+ content: "";
+}
+
+.login-route-line-a {
+ --line-angle: -11deg;
+ top: 28%;
+ left: 8%;
+ transform: rotate(-11deg);
+}
+
+.login-route-line-b {
+ --line-angle: 16deg;
+ right: 9%;
+ bottom: 22%;
+ animation-delay: 1.4s;
+ transform: rotate(16deg);
+}
+
+.login-content {
+ position: relative;
+ z-index: 1;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(320px, 390px);
+ align-items: end;
+ gap: clamp(28px, 5vw, 72px);
+ width: min(1120px, calc(100% - 56px));
+}
+
+.login-copy {
+ display: grid;
+ gap: 14px;
+ max-width: 610px;
+ padding-bottom: 8px;
+ animation: login-copy-in 0.82s cubic-bezier(0.2, 0.72, 0.2, 1) both;
+}
+
+.login-brand-mark {
+ width: 46px;
+ height: 46px;
+ border-color: rgba(255, 255, 255, 0.2);
+ background: rgba(12, 23, 35, 0.78);
+}
+
+.login-eyebrow {
+ color: rgba(255, 255, 255, 0.72);
+ font-size: 13px;
+ font-weight: 600;
+}
+
+.login-copy h1 {
+ max-width: 640px;
+ margin: 0;
+ color: #ffffff;
+ font-size: clamp(36px, 5vw, 58px);
+ font-weight: 700;
+ line-height: 1.12;
+ word-break: keep-all;
+}
+
+.login-copy p {
+ max-width: 520px;
+ margin: 0;
+ color: rgba(255, 255, 255, 0.78);
+ font-size: 17px;
+ line-height: 1.65;
+ word-break: keep-all;
+}
+
+.login-signal-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-top: 4px;
+}
+
+.login-signal-list span {
+ min-height: 30px;
+ padding: 7px 10px;
+ border: 1px solid rgba(255, 255, 255, 0.22);
+ border-radius: 4px;
+ background: rgba(12, 23, 35, 0.42);
+ color: rgba(255, 255, 255, 0.82);
+ font-size: 13px;
+ font-weight: 500;
+}
+
+.login-card {
+ display: grid;
+ gap: 14px;
+ padding: 24px;
+ border: 1px solid rgba(255, 255, 255, 0.18);
+ border-radius: 8px;
+ background: rgba(255, 255, 255, 0.94);
+ box-shadow: 0 18px 54px rgba(5, 13, 22, 0.26);
+ color: var(--text);
+ animation: login-card-in 0.76s 0.14s cubic-bezier(0.2, 0.72, 0.2, 1) both;
+}
+
+.login-card > strong {
+ color: var(--navy);
+ font-size: 22px;
+ line-height: 1.2;
+}
+
+.login-card label {
+ display: grid;
+ gap: 6px;
+}
+
+.login-card label span {
+ color: var(--muted);
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.login-card input {
+ width: 100%;
+ min-height: 42px;
+ padding: 0 11px;
+ border: 1px solid #cbd5df;
+ border-radius: 5px;
+ background: #ffffff;
+ color: var(--text);
+ font: inherit;
+}
+
+.login-card input:focus {
+ border-color: #174ea6;
+ outline: 3px solid rgba(23, 78, 166, 0.16);
+}
+
+.login-card button {
+ min-height: 44px;
+ margin-top: 2px;
+ border-color: #174ea6;
+ border-radius: 5px;
+ background: #174ea6;
+ color: #ffffff;
+ font-weight: 600;
+}
+
+.login-card button:hover {
+ border-color: #123f86;
+ background: #123f86;
+}
+
+.login-card small {
+ color: var(--muted);
+ font-size: 12px;
+}
+
+@keyframes login-photo-settle {
+ from {
+ transform: scale(1.07) translateX(-1.4%);
+ }
+
+ to {
+ transform: scale(1.015) translateX(0);
+ }
+}
+
+@keyframes login-copy-in {
+ from {
+ opacity: 0;
+ transform: translateY(18px);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@keyframes login-card-in {
+ from {
+ opacity: 0;
+ transform: translateY(22px);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@keyframes login-line-pass {
+ 0% {
+ opacity: 0;
+ transform: translateX(-28px) rotate(var(--line-angle, 0deg));
+ }
+
+ 28%,
+ 68% {
+ opacity: 0.7;
+ }
+
+ 100% {
+ opacity: 0;
+ transform: translateX(44px) rotate(var(--line-angle, 0deg));
+ }
+}
+
+@keyframes login-screen-out {
+ to {
+ opacity: 0;
+ transform: scale(1.02);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .login-visual,
+ .login-route-line,
+ .login-copy,
+ .login-card,
+ body.is-login-closing .login-screen {
+ animation: none;
+ }
+
+ .app-shell {
+ transition: none;
+ }
+}
+
.app-shell {
display: grid;
grid-template-rows: 64px minmax(0, 1fr);
width: 100%;
height: 100%;
+ transition: filter 0.42s ease, transform 0.42s ease;
}
.topbar {
@@ -144,6 +435,10 @@ button:hover {
padding: 0 11px;
}
+.toolbar-button.secondary {
+ color: var(--muted);
+}
+
.button-icon,
.button-icon svg {
display: block;
@@ -1508,6 +1803,30 @@ button:hover {
}
@media (max-width: 860px) {
+ .login-content {
+ align-content: center;
+ grid-template-columns: 1fr;
+ gap: 24px;
+ width: min(520px, calc(100% - 32px));
+ }
+
+ .login-copy {
+ gap: 10px;
+ padding-bottom: 0;
+ }
+
+ .login-copy h1 {
+ font-size: clamp(30px, 9vw, 42px);
+ }
+
+ .login-copy p {
+ font-size: 15px;
+ }
+
+ .login-card {
+ padding: 20px;
+ }
+
.app-shell {
grid-template-rows: auto minmax(0, 1fr);
}
@@ -1593,6 +1912,33 @@ button:hover {
}
@media (max-width: 560px) {
+ .login-screen {
+ overflow: auto;
+ }
+
+ .login-content {
+ min-height: 100%;
+ padding: 26px 0;
+ }
+
+ .login-brand-mark {
+ width: 40px;
+ height: 40px;
+ }
+
+ .login-copy h1 {
+ font-size: 30px;
+ }
+
+ .login-signal-list {
+ gap: 6px;
+ }
+
+ .login-signal-list span {
+ min-height: 28px;
+ font-size: 12px;
+ }
+
.workspace {
grid-template-rows: minmax(300px, 42vh) minmax(0, 1fr);
}