From 010771b22a827a578e0f69759436777b6a066c66 Mon Sep 17 00:00:00 2001 From: copecone Date: Thu, 26 Mar 2026 00:14:43 +0900 Subject: [PATCH] Add Settings Popup --- public/guest_profile.png | Bin 0 -> 2751 bytes src/app/app.html | 6 ++ src/app/app.ts | 11 ++- src/app/components/dropdown/dropdown.scss | 2 +- src/app/components/popup/popup.html | 24 +++--- src/app/components/popup/popup.scss | 78 +++++++++++++++++ src/app/components/popup/popup.ts | 14 ++-- src/app/features/header-menu/header-menu.html | 4 +- src/app/features/header-menu/header-menu.scss | 11 +++ src/app/features/header-menu/header-menu.ts | 16 +++- src/app/features/settings/popup.html | 37 ++++++-- src/app/features/settings/popup.scss | 79 ++++++++++++++++++ src/app/features/settings/settings.ts | 29 ++++++- src/app/services/popup.service.ts | 42 ++++++++++ src/styles.scss | 32 ++++--- 15 files changed, 335 insertions(+), 50 deletions(-) create mode 100644 public/guest_profile.png create mode 100644 src/app/services/popup.service.ts diff --git a/public/guest_profile.png b/public/guest_profile.png new file mode 100644 index 0000000000000000000000000000000000000000..e27609de890bbce1d43dc250d9d34cbf2f0128f7 GIT binary patch literal 2751 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 FV1ncK?->99 literal 0 HcmV?d00001 diff --git a/src/app/app.html b/src/app/app.html index a18cc5f..b37b0ee 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -1,2 +1,8 @@ + + diff --git a/src/app/app.ts b/src/app/app.ts index f0769dc..d08911b 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -1,12 +1,15 @@ -import { Component } from '@angular/core'; +import { Component, inject } from '@angular/core'; import { RouterOutlet } from '@angular/router'; import { HeaderMenu } from './features/header-menu/header-menu'; +import { PopupService } from './services/popup.service'; +import { NgComponentOutlet } from '@angular/common'; @Component({ selector: 'app-root', - standalone: true, - imports: [RouterOutlet, HeaderMenu], + imports: [RouterOutlet, HeaderMenu, NgComponentOutlet], templateUrl: './app.html', styleUrl: './app.scss' }) -export class App {} +export class App { + protected popupService = inject(PopupService); +} diff --git a/src/app/components/dropdown/dropdown.scss b/src/app/components/dropdown/dropdown.scss index 576e92b..4eeb180 100644 --- a/src/app/components/dropdown/dropdown.scss +++ b/src/app/components/dropdown/dropdown.scss @@ -36,7 +36,7 @@ $dropdown_animation_duration: 0.2s; .dropdown-box { display: flex; align-items: center; - padding: 0 12px; + padding: 0 10px; height: $dropdown_height; box-sizing: border-box; gap: 8px; diff --git a/src/app/components/popup/popup.html b/src/app/components/popup/popup.html index 37d6140..b91ff1e 100644 --- a/src/app/components/popup/popup.html +++ b/src/app/components/popup/popup.html @@ -1,13 +1,17 @@ - - diff --git a/src/app/components/popup/popup.scss b/src/app/components/popup/popup.scss index e69de29..5f95ca7 100644 --- a/src/app/components/popup/popup.scss +++ b/src/app/components/popup/popup.scss @@ -0,0 +1,78 @@ +:host { + display: block; +} + +.popup-backdrop { + position: fixed; + inset: 0; + background-color: transparent; + display: flex; + justify-content: center; + align-items: center; + z-index: 100; /* .popup-outlet 내부에서의 상대적 순서 */ +} + +.popup-container { + background: white; + border-radius: 12px; + box-shadow: 0 0 40px oklch(0 0 0 / 15%); + min-width: 40px; + max-width: 90vw; + max-height: 90vh; + display: flex; + flex-direction: column; + overflow: visible; + animation: popup-enter 0.2s cubic-bezier(0.16, 1, 0.3, 1); + + border: oklch(0 0 0 / 20%) 2px solid; + gap: 15px; +} + +@keyframes popup-enter { + from { + transform: scale(0.95) translateY(10px); + opacity: 0; + } + to { + transform: scale(1) translateY(0); + opacity: 1; + } +} + +.popup-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 12px; + height: 40px; + border-bottom: 1px solid oklch(0 0 0 / 5%); + + .header-left { + display: flex; + align-items: center; + gap: 12px; + } + + .header-right { + .close-btn { + background: none; + border: none; + padding: 4px; + cursor: pointer; + border-radius: 50%; + color: oklch(0.4 0 0); + transition: background-color 0.2s, color 0.2s; + + &:hover { + background-color: oklch(0 0 0 / 5%); + color: oklch(0 0 0); + } + } + } +} + +.popup-body { + padding: 0 12px; + overflow-y: visible; + flex-grow: 1; +} diff --git a/src/app/components/popup/popup.ts b/src/app/components/popup/popup.ts index dff6d64..026cbb0 100644 --- a/src/app/components/popup/popup.ts +++ b/src/app/components/popup/popup.ts @@ -1,16 +1,18 @@ -import { Component, input, model, output } from '@angular/core'; -import { Dropdown } from '../../components/dropdown/dropdown'; -import { Switch } from '../../components/switch/switch'; +import { Component, output } from '@angular/core'; @Component({ - selector: 'popup', + selector: 'app-popup', + standalone: true, templateUrl: './popup.html', styleUrl: './popup.scss', }) export class PopupComponent { - close = output(); + /** + * 팝업 닫기 요청 (배경 클릭 또는 닫기 버튼) + */ + closeRequest = output(); onClose() { - this.close.emit() + this.closeRequest.emit(); } } diff --git a/src/app/features/header-menu/header-menu.html b/src/app/features/header-menu/header-menu.html index 90b6eff..fc65c19 100644 --- a/src/app/features/header-menu/header-menu.html +++ b/src/app/features/header-menu/header-menu.html @@ -1,6 +1,6 @@ diff --git a/src/app/features/header-menu/header-menu.scss b/src/app/features/header-menu/header-menu.scss index 73518bd..94057b2 100644 --- a/src/app/features/header-menu/header-menu.scss +++ b/src/app/features/header-menu/header-menu.scss @@ -81,5 +81,16 @@ flex-grow: 1; justify-content: flex-end; padding: 12px 20px; + + .settings { + position: relative; + z-index: 10; + cursor: pointer; + transition: transform 0.3s ease; + + &:hover { + transform: rotate(30deg); + } + } } } diff --git a/src/app/features/header-menu/header-menu.ts b/src/app/features/header-menu/header-menu.ts index 13a29fc..7257791 100644 --- a/src/app/features/header-menu/header-menu.ts +++ b/src/app/features/header-menu/header-menu.ts @@ -1,10 +1,20 @@ -import { Component } from '@angular/core'; +import { Component, inject } from '@angular/core'; import { RouterLink } from '@angular/router'; +import { NgOptimizedImage } from '@angular/common'; +import { PopupService } from '../../services/popup.service'; +import { SettingsPopup } from '../settings/settings'; @Component({ selector: 'header-menu', - imports: [RouterLink], + imports: [RouterLink, NgOptimizedImage], templateUrl: './header-menu.html', styleUrl: './header-menu.scss', }) -export class HeaderMenu {} +export class HeaderMenu { + private popupService = inject(PopupService); + + onSettingsClick() { + console.log('HeaderMenu: Settings clicked'); + this.popupService.open(SettingsPopup); + } +} diff --git a/src/app/features/settings/popup.html b/src/app/features/settings/popup.html index cd30928..059a9d3 100644 --- a/src/app/features/settings/popup.html +++ b/src/app/features/settings/popup.html @@ -1,16 +1,37 @@ - + diff --git a/src/app/features/settings/popup.scss b/src/app/features/settings/popup.scss index e69de29..8b61af1 100644 --- a/src/app/features/settings/popup.scss +++ b/src/app/features/settings/popup.scss @@ -0,0 +1,79 @@ +:host { + display: block; +} + +.profile-section { + display: flex; + align-items: center; + gap: 12px; + + font-size: 18px; + font-weight: 500; + + .avatar { + border-radius: 50%; + border: 1px solid oklch(0 0 0 / 20%); + object-fit: cover; + } +} + +.settings-content { + display: flex; + flex-direction: column; + gap: 16px; + + padding-bottom: 8px; + + section { + display: flex; + flex-direction: column; + gap: 6px; + align-items: flex-start; + justify-content: flex-start; + margin: 0; padding: 0; + border: none; + + label { + display: block; + font-weight: 500; + font-size: 14px; + color: #999999; + } + + .settings-container { + width: 100%; + height: 40px; + display: flex; + + flex-direction: row; + align-items: center; + justify-content: space-between; + + .settings-name { + display: flex; + flex-direction: row; + align-items: center; + padding-left: 10px; + gap: 8px; + + span { + display: inline-block; + font-size: 18px; + + &.settings-icon { + display: inline-block; + font-size: 24px; + + width: 24px; + height: 24px; + line-height: 24px; + } + } + } + } + + dropdown { + width: 324px; + } + } +} diff --git a/src/app/features/settings/settings.ts b/src/app/features/settings/settings.ts index 73ccdaa..34f5f5d 100644 --- a/src/app/features/settings/settings.ts +++ b/src/app/features/settings/settings.ts @@ -1,9 +1,32 @@ -import { Component } from '@angular/core'; +import { Component, inject } from '@angular/core'; +import { PopupComponent } from '../../components/popup/popup'; +import { Dropdown, DropdownOption } from '../../components/dropdown/dropdown'; +import { Switch } from '../../components/switch/switch'; +import { NgOptimizedImage } from '@angular/common'; +import { PopupService } from '../../services/popup.service'; @Component({ selector: 'settings-popup', - imports: [], + standalone: true, + imports: [PopupComponent, Dropdown, Switch, NgOptimizedImage], templateUrl: './popup.html', styleUrl: './popup.scss', }) -export class SettingsPopup {} +export class SettingsPopup { + private popupService = inject(PopupService); + + languages: DropdownOption[] = [ + { label: 'English', value: 'EN' }, + { label: '한국어', value: 'KR' }, + { label: '日本語', value: 'JP' }, + ]; + + userName: string = "Guest"; + + /** + * 팝업을 닫을 때, 자기 자신(클래스)을 명확하게 지정하여 닫습니다. + */ + close() { + this.popupService.close(SettingsPopup); + } +} diff --git a/src/app/services/popup.service.ts b/src/app/services/popup.service.ts new file mode 100644 index 0000000..bd717df --- /dev/null +++ b/src/app/services/popup.service.ts @@ -0,0 +1,42 @@ +import { Injectable, signal, Type } from '@angular/core'; + +@Injectable({ + providedIn: 'root', +}) +export class PopupService { + /** + * 현재 활성화된 팝업 컴포넌트들의 리스트 (Signal) + */ + activePopups = signal[]>([]); + + /** + * 팝업을 엽니다. 이미 열려있는 동일한 컴포넌트가 있다면 중복해서 열지 않습니다. + */ + open(component: Type) { + this.activePopups.update((popups) => { + if (popups.includes(component)) return popups; + return [...popups, component]; + }); + } + + /** + * 특정 컴포넌트를 닫습니다. + */ + close(component: Type) { + this.activePopups.update((popups) => popups.filter((p) => p !== component)); + } + + /** + * 가장 최근에 열린(마지막) 팝업을 닫습니다. + */ + closeLast() { + this.activePopups.update((popups) => popups.slice(0, -1)); + } + + /** + * 모든 팝업을 닫습니다. + */ + closeAll() { + this.activePopups.set([]); + } +} diff --git a/src/styles.scss b/src/styles.scss index 209cf4e..7e97095 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -6,29 +6,18 @@ body { font-family: 'NanumSquareNeo', 'Wanted Sans Variable', 'Pretendard JP', sans-serif; font-weight: 500; margin: 0; - overscroll-behavior-x: none; background-color: white; } .icon-24 { - font-variation-settings: - 'FILL' 0, - 'wght' 300, - 'GRAD' 0, - 'opsz' 24; - + font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 24; font-size: 24px; line-height: 24px; } .icon-40 { - font-variation-settings: - 'FILL' 0, - 'wght' 300, - 'GRAD' 0, - 'opsz' 40; - + font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 40; font-size: 40px; line-height: 40px; } @@ -60,6 +49,18 @@ p { margin: 0 auto; } +.popup-outlet { + position: fixed; + inset: 0; + pointer-events: none; + z-index: 10000; /* 최상위 레이어 */ +} + +#global-popup-root > * { + pointer-events: auto; + display: block; +} + section { margin-bottom: 40px; padding: 20px; @@ -67,3 +68,8 @@ section { border-radius: 8px; } +.container { + padding: 40px; + max-width: 600px; + margin: 0 auto; +}