diff --git a/common/static/common/css/base.css b/common/static/common/css/base.css index 4c6262a..9e83404 100644 --- a/common/static/common/css/base.css +++ b/common/static/common/css/base.css @@ -1,4 +1,24 @@ /* 공통 담당자만 수정: 디자인 토큰 및 전체 레이아웃 */ +@font-face { + font-family: "HeartMarkFont"; + src: url("../font/온글잎 박다현체.ttf") format("truetype"); + font-weight: normal; + font-style: normal; + font-display: swap; +} + +html, +body { + font-family: "HeartMarkFont", sans-serif; +} + +button, +input, +textarea, +select { + font: inherit; +} + .site-main { padding-top: 100px; } @@ -7,7 +27,7 @@ body { margin: 0; background: #f3f3f3; color: #171717; - font-family: Arial, "Noto Sans KR", sans-serif; + font-family: "HeartMarkFont", Arial, "Noto Sans KR", sans-serif; } a { diff --git "a/common/static/common/font/\354\230\250\352\270\200\354\236\216 \353\260\225\353\213\244\355\230\204\354\262\264.ttf" "b/common/static/common/font/\354\230\250\352\270\200\354\236\216 \353\260\225\353\213\244\355\230\204\354\262\264.ttf" new file mode 100644 index 0000000..6d95c10 Binary files /dev/null and "b/common/static/common/font/\354\230\250\352\270\200\354\236\216 \353\260\225\353\213\244\355\230\204\354\262\264.ttf" differ diff --git a/config/settings.py b/config/settings.py index 0c9a74e..46e8e78 100644 --- a/config/settings.py +++ b/config/settings.py @@ -5,12 +5,10 @@ BASE_DIR = Path(__file__).resolve().parent.parent -<<<<<<< HEAD # 프로젝트 루트의 .env 파일을 읽어서 os.environ에 등록한다. # .env는 .gitignore에 포함되어 있어 git에 올라가지 않으므로, # API 키 같은 민감한 값은 코드에 직접 쓰지 않고 이 방식으로 불러온다. load_dotenv(BASE_DIR / ".env") -======= def load_local_env(): env_path = BASE_DIR / ".env" @@ -26,7 +24,6 @@ def load_local_env(): load_local_env() ->>>>>>> develop SECRET_KEY = "development-only" DEBUG = True @@ -48,6 +45,7 @@ def load_local_env(): "allauth.socialaccount.providers.naver", "common", "accounts", + "mypage", "locations", "records", "diary", @@ -96,7 +94,6 @@ def load_local_env(): STATICFILES_DIRS = [BASE_DIR / "static"] DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" -<<<<<<< HEAD # 카카오맵 API 키 (locations 앱: 위치 선택/지도 화면에서 사용) # - KAKAO_JS_KEY: 브라우저에서 카카오맵 JS SDK를 로드할 때 쓰는 키. # 공개돼도 큰 문제는 없지만(카카오 콘솔에 등록된 도메인에서만 동작), @@ -107,7 +104,6 @@ def load_local_env(): # 두 값 모두 .env 파일에 정의되어 있어야 하며, .env는 git에 커밋되지 않는다. KAKAO_JS_KEY = os.environ.get("KAKAO_JS_KEY") KAKAO_REST_KEY = os.environ.get("KAKAO_REST_KEY") -======= SITE_ID = 1 AUTHENTICATION_BACKENDS = [ @@ -127,5 +123,4 @@ def load_local_env(): "AUTH_PARAMS": {"access_type": "online"}, } } ->>>>>>> develop diff --git a/config/urls.py b/config/urls.py index 9612a1b..a0f7db5 100644 --- a/config/urls.py +++ b/config/urls.py @@ -5,6 +5,7 @@ path("admin/", admin.site.urls), path("", include("common.urls")), path("accounts/", include("accounts.urls")), + path("mypage/", include("mypage.urls")), path("accounts/", include("allauth.urls")), path("locations/", include("locations.urls")), path("records/", include("records.urls")), diff --git a/mypage/__init__.py b/mypage/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/mypage/__init__.py @@ -0,0 +1 @@ + diff --git a/mypage/apps.py b/mypage/apps.py new file mode 100644 index 0000000..8a5f240 --- /dev/null +++ b/mypage/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class MypageConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "mypage" diff --git a/mypage/static/mypage/css/mypage.css b/mypage/static/mypage/css/mypage.css new file mode 100644 index 0000000..c29a1d8 --- /dev/null +++ b/mypage/static/mypage/css/mypage.css @@ -0,0 +1,873 @@ +:root { + --mypage-paper: #fff9eb; + --mypage-panel: #fffff4; + --mypage-accent: #ffe7d0; + --mypage-ink: #46372d; + --mypage-muted: #8d796a; +} + +.mypage-screen, +.mypage-screen * { + box-sizing: border-box; +} + +.mypage-screen { + position: relative; + min-height: calc(100vh - 100px); + overflow: hidden; + padding: 125px max(24px, 7vw) 78px; + color: var(--mypage-ink); + background: var(--mypage-paper); +} + +.mypage-profile { + position: relative; + z-index: 1; + display: grid; + justify-items: center; + width: min(960px, 100%); + margin: 0 auto 64px; +} + +.mypage-character-wrap { + position: relative; + display: grid; + width: 142px; + height: 142px; + place-items: center; + border: 1.5px solid var(--mypage-ink); + border-radius: 50%; + background: #fff; +} + +.mypage-character { + width: 112px; + height: 112px; + object-fit: contain; +} + +.mypage-character-fallback { + display: grid; + width: 104px; + height: 104px; + place-items: center; + border-radius: 48% 52% 46% 54%; + color: var(--mypage-ink); + background: #dce8d0; + font-size: 2rem; + font-weight: 800; +} + +.mypage-edit-button { + position: absolute; + right: 2px; + bottom: 4px; + display: grid; + width: 34px; + height: 34px; + padding: 0; + place-items: center; + border: 1.5px solid var(--mypage-ink); + border-radius: 50%; + color: var(--mypage-ink); + background: var(--mypage-accent); + font-size: 1.1rem; + cursor: pointer; +} + +.mypage-profile h1 { + margin: 14px 0 0; + font-size: 1rem; +} + +.attendance-card { + position: relative; + z-index: 1; + width: min(980px, 86vw); + min-height: 0; + padding: 72px 68px 62px; + margin: 0 auto; + border: 2px solid #ffe0c5; + border-radius: 44px; + background: rgba(255, 255, 244, 0.78); +} + +.attendance-heading { + position: absolute; + top: 17px; + right: 48px; + left: 48px; + display: flex; + align-items: center; + justify-content: space-between; + margin: 0; +} + +.attendance-heading p, +.attendance-heading h2, +.attendance-heading strong { + margin: 0; +} + +.attendance-heading h2 { + position: absolute; + top: -35px; + left: -24px; + padding: 12px 26px; + border-radius: 7px 14px 8px 12px; + background: #ffe7d0; + box-shadow: 0 3px 8px rgba(91, 67, 49, 0.1); + font-size: 1.12rem; + line-height: 1; + transform: rotate(-1deg); + white-space: nowrap; +} + +.attendance-heading p, +.attendance-heading strong, +.attendance-guide { + color: var(--mypage-muted); + font-size: 0.8rem; +} + +.attendance-road { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 24px 20px; + padding: 0; + margin: 0; + list-style: none; +} + +.attendance-slider { + position: relative; + display: block; + align-items: center; +} + +.attendance-viewport { + overflow: hidden; +} + +.attendance-track { + display: flex; + transition: transform 280ms ease; +} + +.attendance-page { + min-width: 100%; +} + +.attendance-period { + margin: 0 0 8px; + color: var(--mypage-muted); + font-size: 0.72rem; + text-align: center; +} + +.attendance-arrow { + position: absolute; + top: 56%; + z-index: 3; + display: grid; + width: 38px; + height: 38px; + padding: 0; + place-items: center; + border: 1.5px solid #fae7d9; + border-radius: 50%; + color: #615953; + background: #fff0df; + font-size: 0; + line-height: 1; + cursor: pointer; + transform: translateY(-50%); + box-shadow: 0 3px 8px rgba(91, 67, 49, 0.13); +} + +.attendance-arrow::before { + width: 10px; + height: 10px; + border-right: 3px solid currentColor; + border-bottom: 3px solid currentColor; + content: ""; +} + +.attendance-arrow--prev::before { + transform: translate(2px, 5px) rotate(135deg); +} + +.attendance-arrow--next::before { + transform: translate(-2px, 5px) rotate(-45deg); +} + +.attendance-arrow--prev { + left: -90px; +} + +.attendance-arrow--next { + right: -90px; +} + +.attendance-arrow:disabled { + border-color: #fae7d9; + color: #615953; + background: #fff0df; + opacity: 1; + cursor: default; + box-shadow: 0 3px 8px rgba(91, 67, 49, 0.13); +} + +.attendance-day { + position: relative; + display: grid; + justify-items: center; + gap: 7px; +} + +.attendance-day__stamp { + position: relative; + display: block; + width: 68px; + height: 63px; + border: 3px solid transparent; + border-radius: 46%; +} + +.attendance-day__footprint { + display: block; + width: 100%; + height: 100%; + background: #76503a; + -webkit-mask: url("../images/footprint-stamp.png") center / contain no-repeat; + mask: url("../images/footprint-stamp.png") center / contain no-repeat; + opacity: 0.38; +} + +.attendance-day--checked .attendance-day__footprint { + opacity: 0; +} + +.attendance-day__emotion { + position: absolute; + top: 51%; + left: 50%; + width: 45px; + height: 45px; + object-fit: contain; + transform: translate(-50%, -50%); + filter: drop-shadow(0 2px 2px rgba(70, 55, 45, 0.15)); +} + +.attendance-day--future .attendance-day__footprint { + opacity: 0.38; +} + +.attendance-day time { + position: static; + width: auto; + height: auto; + overflow: visible; + color: var(--mypage-muted); + font-size: 0.7rem; + line-height: 1; + white-space: nowrap; +} + +.attendance-check { + font-size: 1.3rem; + font-weight: 800; +} + +.attendance-guide { + position: absolute; + right: 24px; + bottom: 12px; + left: 24px; + display: block; + margin: 0; + text-align: center; +} + +.attendance-pagination { + display: flex; + justify-content: center; + gap: 8px; + margin-top: 22px; +} + +.attendance-dot { + width: 10px; + height: 10px; + padding: 0; + border: 0; + border-radius: 50%; + background: #cdb097; + cursor: pointer; +} + +.attendance-dot--active { + width: 28px; + border-radius: 999px; + background: #b87549; +} + +.mypage-insights { + position: relative; + z-index: 1; + width: min(980px, 86vw); + margin: 46px auto 0; +} + +.insight-overview, +.insight-card { + position: relative; + border: 1.5px solid #f2d7bf; + background: rgba(255, 255, 244, 0.9); + box-shadow: 0 8px 20px rgba(92, 69, 51, 0.07); +} + +.insight-overview { + min-height: 142px; + padding: 44px 34px 22px; + border-radius: 30px; +} + +.insight-label { + position: absolute; + top: -16px; + left: 24px; + z-index: 2; + padding: 12px 26px; + margin: 0; + border-radius: 7px 14px 8px 12px; + color: var(--mypage-ink); + background: #ffe7d0; + box-shadow: 0 3px 8px rgba(91, 67, 49, 0.1); + font-size: 1.12rem; + transform: rotate(-1deg); +} + +.insight-label--blue { + background: #ffe7d0; +} + +.overview-items { + display: grid; + grid-template-columns: 1.2fr 1fr 1fr; +} + +.overview-item { + display: flex; + min-height: 70px; + align-items: center; + justify-content: center; + gap: 18px; + padding: 4px 24px; + border-left: 1px dashed #e8ccb4; +} + +.overview-item:first-child { + border-left: 0; +} + +.overview-item div:last-child { + display: grid; + gap: 5px; +} + +.overview-item span { + color: var(--mypage-muted); + font-size: 0.76rem; +} + +.overview-item strong { + color: var(--mypage-ink); + font-size: 1.32rem; +} + +.overview-icon, +.overview-symbol { + display: grid; + flex: 0 0 62px; + width: 62px; + height: 62px; + place-items: center; + border-radius: 46% 54% 48% 52%; + background: #e4ddf3; +} + +.overview-icon img { + width: 52px; + height: 52px; + object-fit: contain; +} + +.overview-empty { + color: #8b76a8 !important; + font-size: 1.5rem !important; + font-weight: 800; +} + +.overview-symbol { + color: #fff !important; +} + +.overview-symbol img { + width: 56px; + height: 56px; + object-fit: contain; +} + +.overview-symbol--streak { + background: transparent; +} + +.overview-symbol--total { + background: transparent; +} + +.insight-detail-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 26px; + margin-top: 42px; +} + +.insight-card { + min-height: 260px; + padding: 48px 34px 30px; + border-radius: 26px; +} + +.weekly-mood-content { + display: grid; + grid-template-columns: 145px 1fr; + align-items: center; + gap: 24px; + min-height: 170px; +} + +.weekly-mood-illustration { + display: grid; + width: 140px; + height: 140px; + place-items: center; + border-radius: 45% 55% 48% 52%; + background: #e6ddf4; + box-shadow: inset 0 0 0 9px rgba(255, 255, 255, 0.32); +} + +.weekly-mood-illustration img { + width: 92px; + height: 92px; + object-fit: contain; +} + +.weekly-mood-illustration span { + color: #8c73aa; + font-size: 3.2rem; +} + +.weekly-mood-content p, +.place-content p { + margin: 0; + color: var(--mypage-muted); + font-size: 0.84rem; + line-height: 1.8; +} + +.weekly-mood-copy { + display: grid; + gap: 10px; +} + +.weekly-mood-copy strong { + color: var(--mypage-ink); + font-size: 1.15rem; +} + +.place-content { + position: relative; + z-index: 1; + display: flex; + align-items: center; + gap: 18px; + width: min(360px, 100%); + padding: 18px 22px; + margin: 34px auto 0; + border-radius: 15px; + background: #ffe3df; +} + +.place-content div { + display: grid; + gap: 7px; +} + +.place-content strong { + color: var(--mypage-ink); + font-size: 1rem; +} + +.place-pin { + display: grid; + flex: 0 0 52px; + width: 52px; + height: 52px; + place-items: center; + border: 2px solid #e78978; + border-radius: 50% 50% 50% 12%; + color: #d96f60; + background: #fff5ed; + font-size: 1.7rem; + transform: rotate(-45deg); +} + +.place-pin::first-letter { + transform: rotate(45deg); +} + +.place-route { + position: absolute; + right: 24px; + bottom: 22px; + width: 74px; + height: 38px; + border-bottom: 2px dashed #add3a0; + border-radius: 50%; + transform: rotate(-8deg); +} + +.place-route::after { + position: absolute; + right: -2px; + bottom: -7px; + color: #80b779; + content: "⌂"; + font-size: 1.8rem; +} + +/* Frequent-place states: ticket before the first place record, map afterwards. */ +.place-ticket { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: 58px 1fr; + align-items: center; + gap: 16px; + width: min(390px, 100%); + min-height: 142px; + padding: 22px 28px 22px 24px; + margin: 30px auto 0; + overflow: hidden; + border: 1.5px solid #e8a58f; + border-radius: 18px; + background: #fff0e7; + box-shadow: 0 7px 16px rgba(126, 84, 61, 0.08); +} + +.place-ticket::after { + content: none; +} + +.place-ticket__copy { display: grid; gap: 5px; } +.place-ticket__copy > span { color: #c4725e; font-size: 0.7rem; font-weight: 700; letter-spacing: 0.04em; } +.place-ticket strong { color: var(--mypage-ink); font-size: 1rem; } +.place-ticket p { margin: 0; color: var(--mypage-muted); font-size: 0.78rem; line-height: 1.6; } + +.place-ticket__icon { + display: grid; + width: 54px; + height: 54px; + place-items: center; + border: 0; + border-radius: 0; + color: #d96f60; + background: transparent; + font-size: 1.7rem; + transform: none; +} + +.place-ticket__icon svg { + width: 48px; + height: 48px; + transform: none; +} + +.place-ticket__hole { + position: absolute; + right: -9px; + width: 18px; + height: 18px; + border: 1.5px solid #e8a58f; + border-radius: 50%; + background: var(--mypage-panel); +} + +.place-ticket__hole--top { top: -9px; } +.place-ticket__hole--bottom { bottom: -9px; } +.place-map { + position: relative; + width: min(390px, 100%); + min-height: 170px; + margin: 24px auto 0; + overflow: hidden; + border: 1.5px solid #b9cfae; + border-radius: 20px; + background: #edf2df; + box-shadow: 0 7px 16px rgba(87, 111, 73, 0.09); +} + +.place-map::before, +.place-map::after { position: absolute; border-radius: 50%; background: rgba(172, 205, 151, 0.5); content: ""; } +.place-map::before { top: -24px; right: 38px; width: 100px; height: 82px; } +.place-map::after { bottom: -30px; left: 20px; width: 122px; height: 88px; } + +.place-map__road { + position: absolute; + display: block; + width: 120%; + height: 20px; + border-top: 2px dashed #d6c39e; + border-bottom: 2px dashed #d6c39e; + background: #fffaf0; +} + +.place-map__road--one { top: 68px; left: -32px; transform: rotate(-12deg); } +.place-map__road--two { top: 90px; left: -44px; transform: rotate(24deg); } +.place-map__park { position: absolute; right: 22px; bottom: 18px; width: 72px; height: 56px; border: 2px solid rgba(111, 160, 101, 0.45); border-radius: 48% 52% 44% 56%; background: rgba(191, 222, 169, 0.72); } + +.place-map__pin { + position: absolute; + top: 32px; + left: 38px; + z-index: 2; + width: 38px; + height: 38px; + border: 3px solid #d96f60; + border-radius: 50% 50% 50% 12%; + background: #ffdfd5; + transform: rotate(-45deg); + box-shadow: 0 4px 7px rgba(104, 67, 50, 0.16); +} + +.place-map__pin::after { position: absolute; top: 11px; left: 11px; width: 10px; height: 10px; border-radius: 50%; background: #fff9eb; content: ""; } + +.place-map__label { + position: absolute; + right: 20px; + bottom: 20px; + z-index: 3; + display: grid; + width: min(235px, 66%); + gap: 4px; + padding: 13px 16px; + border: 1px solid rgba(99, 119, 84, 0.2); + border-radius: 13px; + background: rgba(255, 255, 244, 0.94); + box-shadow: 0 4px 10px rgba(76, 91, 64, 0.1); +} + +.place-map__label span, +.place-map__label small { color: var(--mypage-muted); font-size: 0.7rem; } +.place-map__label strong { overflow: hidden; color: var(--mypage-ink); font-size: 1rem; text-overflow: ellipsis; white-space: nowrap; } + +.mypage-doodle { + position: absolute; + border-radius: 48% 52% 44% 56%; + transform: rotate(-18deg); +} + +.mypage-doodle--coral { + top: 36px; + left: -30px; + width: 74px; + height: 74px; + background: #ef6868; +} + +.mypage-doodle--blue { + top: 62px; + right: 18%; + width: 34px; + height: 50px; + background: #7fc9e6; +} + +.mypage-doodle--green { + top: 280px; + left: 6%; + width: 24px; + height: 32px; + background: #02b77c; +} + +.mypage-doodle--yellow { + top: 320px; + right: 7%; + width: 42px; + height: 42px; + background: #e7ed68; +} + +.profile-editor[hidden] { + display: none; +} + +.profile-editor { + position: fixed; + inset: 0; + z-index: 1000; + display: grid; + padding: 24px; + place-items: center; + background: rgba(57, 52, 48, 0.58); +} + +.profile-editor__panel { + position: relative; + width: min(430px, 100%); + padding: 34px; + border: 1.5px solid var(--mypage-ink); + border-radius: 22px; + background: var(--mypage-paper); +} + +.profile-editor__panel h2 { + margin-top: 0; +} + +.profile-editor__panel form { + display: grid; + gap: 12px; +} + +.profile-editor__panel input { + padding: 12px; + border: 1px solid rgba(70, 55, 45, 0.3); + border-radius: 10px; + font: inherit; +} + +.mypage-save-button { + padding: 11px 18px; + border: 1px solid var(--mypage-ink); + border-radius: 11px; + color: var(--mypage-ink); + background: var(--mypage-accent); + font-weight: 700; + cursor: pointer; +} + +.profile-editor__close { + position: absolute; + top: 12px; + right: 14px; + border: 0; + background: transparent; + font-size: 1.6rem; + cursor: pointer; +} + +.profile-character-pending { + width: 100%; + padding: 11px; + margin-top: 12px; + border: 1px dashed rgba(70, 55, 45, 0.3); + border-radius: 10px; + color: var(--mypage-muted); + background: transparent; +} + +@media (max-width: 720px) { + .mypage-screen { + padding: 96px 14px 60px; + } + + .attendance-card { + width: 100%; + min-height: 0; + padding: 70px 34px 58px; + border-radius: 30px; + } + + .attendance-heading { + right: 22px; + left: 22px; + } + + .attendance-heading p, + .attendance-heading strong { + font-size: 0.66rem; + } + + .attendance-slider { + display: block; + } + + .attendance-arrow { + top: 55%; + width: 28px; + height: 28px; + } + + .attendance-arrow::before { + width: 8px; + height: 8px; + border-width: 0 2px 2px 0; + } + + .attendance-arrow--prev { + left: -48px; + } + + .attendance-arrow--next { + right: -48px; + } + + .attendance-road { + gap: 22px 2px; + } + + .attendance-day__stamp { + width: 46px; + height: 43px; + } + + .attendance-day__emotion { + width: 31px; + height: 31px; + } + + .mypage-insights { + width: 100%; + margin-top: 38px; + } + + .overview-items, + .insight-detail-grid, + .weekly-mood-content { + grid-template-columns: 1fr; + } + + .overview-item { + justify-content: flex-start; + border-top: 1px dashed #e8ccb4; + border-left: 0; + } + + .overview-item:first-child { + border-top: 0; + } + + .insight-detail-grid { + gap: 38px; + } + + .weekly-mood-content { + justify-items: center; + text-align: center; + } + +} diff --git a/mypage/static/mypage/images/footprint-stamp.png b/mypage/static/mypage/images/footprint-stamp.png new file mode 100644 index 0000000..dc34467 Binary files /dev/null and b/mypage/static/mypage/images/footprint-stamp.png differ diff --git a/mypage/static/mypage/images/streak-sprout.png b/mypage/static/mypage/images/streak-sprout.png new file mode 100644 index 0000000..5d189a4 Binary files /dev/null and b/mypage/static/mypage/images/streak-sprout.png differ diff --git a/mypage/static/mypage/images/total-diary.png b/mypage/static/mypage/images/total-diary.png new file mode 100644 index 0000000..e551784 Binary files /dev/null and b/mypage/static/mypage/images/total-diary.png differ diff --git a/mypage/static/mypage/js/mypage.js b/mypage/static/mypage/js/mypage.js new file mode 100644 index 0000000..f334a85 --- /dev/null +++ b/mypage/static/mypage/js/mypage.js @@ -0,0 +1,68 @@ +(() => { + const editor = document.querySelector("#profile-editor"); + const openButton = document.querySelector("#open-profile-editor"); + const closeButton = editor?.querySelector(".profile-editor__close"); + if (editor && openButton) { + const openEditor = () => { + editor.hidden = false; + editor.querySelector("input")?.focus(); + }; + const closeEditor = () => { + editor.hidden = true; + openButton.focus(); + }; + + openButton.addEventListener("click", openEditor); + closeButton?.addEventListener("click", closeEditor); + editor.addEventListener("click", (event) => { + if (event.target === editor) closeEditor(); + }); + document.addEventListener("keydown", (event) => { + if (event.key === "Escape" && !editor.hidden) closeEditor(); + }); + } + + const slider = document.querySelector("#attendance-slider"); + const track = slider?.querySelector(".attendance-track"); + const pages = [...(slider?.querySelectorAll(".attendance-page") || [])]; + const previousButton = slider?.querySelector(".attendance-arrow--prev"); + const nextButton = slider?.querySelector(".attendance-arrow--next"); + const dots = [...document.querySelectorAll(".attendance-dot")]; + if (!slider || !track || !pages.length) return; + + let currentPage = Number.parseInt(slider.dataset.currentPage || "0", 10); + let touchStartX = null; + + const showPage = (pageIndex) => { + currentPage = Math.max(0, Math.min(pageIndex, pages.length - 1)); + track.style.transform = `translateX(-${currentPage * 100}%)`; + pages.forEach((page, index) => { + page.setAttribute("aria-hidden", String(index !== currentPage)); + }); + dots.forEach((dot, index) => { + const isActive = index === currentPage; + dot.classList.toggle("attendance-dot--active", isActive); + dot.setAttribute("aria-selected", String(isActive)); + }); + previousButton.disabled = currentPage === 0; + nextButton.disabled = currentPage === pages.length - 1; + }; + + previousButton?.addEventListener("click", () => showPage(currentPage - 1)); + nextButton?.addEventListener("click", () => showPage(currentPage + 1)); + dots.forEach((dot, index) => dot.addEventListener("click", () => showPage(index))); + + slider.addEventListener("touchstart", (event) => { + touchStartX = event.changedTouches[0].clientX; + }, { passive: true }); + slider.addEventListener("touchend", (event) => { + if (touchStartX === null) return; + const distance = event.changedTouches[0].clientX - touchStartX; + if (Math.abs(distance) > 45) { + showPage(currentPage + (distance < 0 ? 1 : -1)); + } + touchStartX = null; + }, { passive: true }); + + showPage(currentPage); +})(); diff --git a/mypage/templates/mypage/mypage.html b/mypage/templates/mypage/mypage.html new file mode 100644 index 0000000..2a44052 --- /dev/null +++ b/mypage/templates/mypage/mypage.html @@ -0,0 +1,203 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}마이페이지 | 마음자국{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+ + + + + +
+
+ {% if character_url %} + 선택한 메인 캐릭터 + {% else %} +
+ {{ request.user.username|first|upper }} +
+ {% endif %} + +
+

+ {{ request.user.first_name|default:request.user.username }} +

+
+ +
+
+
+

출석체크

+
+ {{ attendance_count }}일 기록 +
+ +
+ +
+
+ {% for page in attendance_pages %} + + {% endfor %} +
+
+ +
+
+ {% for page in attendance_pages %} + + {% endfor %} +
+

기록을 작성하면 대표 감정으로 자동 출석돼요.

+
+ +
+
+

나의 마음 한눈에

+
+
+
+ {% if weekly_emotion %} + 이번 주 대표 감정 + {% else %} + + {% endif %} +
+
이번 주 대표 감정{% if weekly_emotion %}{{ weekly_emotion_label }}{% else %}아직 없어요{% endif %}
+
+
+ +
연속 기록{{ record_streak }}일
+
+
+ +
총 기록{{ total_record_count }}개
+
+
+
+ +
+
+

이번 주 마음

+
+
+ {% if weekly_emotion %} + 이번 주에 가장 많이 기록한 감정 + {% else %} + + {% endif %} +
+
+ {% if weekly_emotion %}{{ weekly_emotion_label }}{% endif %} +

{{ weekly_message }}

+
+
+
+ +
+

자주 남긴 장소

+ {% if frequent_place %} +
+ + + + +
+ 나의 마음 장소 + {{ frequent_place }} + 총 {{ frequent_place_count }}번 기록했어요 +
+
+ {% else %} +
+ + + +
+ 마음자국 장소 티켓 + 아직 장소가 없어요 +

장소를 인증하고 첫 마음자국을 남겨 보세요.

+
+
+ {% endif %} +
+
+
+ + +
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/mypage/tests.py b/mypage/tests.py new file mode 100644 index 0000000..e75b5f0 --- /dev/null +++ b/mypage/tests.py @@ -0,0 +1,47 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone +from datetime import timedelta + + +class MypageViewTests(TestCase): + def setUp(self): + self.user = get_user_model().objects.create_user( + username="mypage-user", + password="test-password", + ) + + def test_login_is_required(self): + response = self.client.get(reverse("mypage:home")) + self.assertEqual(response.status_code, 302) + + def test_attendance_pages_begin_on_join_date(self): + self.user.date_joined = timezone.now() - timedelta(days=20) + self.user.save(update_fields=["date_joined"]) + self.client.force_login(self.user) + response = self.client.get(reverse("mypage:home")) + self.assertEqual(response.status_code, 200) + pages = response.context["attendance_pages"] + self.assertEqual(len(pages), 2) + self.assertEqual(len(pages[0]["days"]), 14) + self.assertEqual(len(pages[1]["days"]), 14) + self.assertEqual( + pages[0]["days"][0]["date"], + timezone.localdate() - timedelta(days=20), + ) + self.assertEqual( + pages[-1]["days"][-1]["date"], + timezone.localdate() + timedelta(days=7), + ) + self.assertTrue(pages[-1]["days"][-1]["is_future"]) + + def test_user_can_change_display_name(self): + self.client.force_login(self.user) + response = self.client.post( + reverse("mypage:home"), + {"display_name": "마음이"}, + ) + self.assertRedirects(response, reverse("mypage:home")) + self.user.refresh_from_db() + self.assertEqual(self.user.first_name, "마음이") diff --git a/mypage/urls.py b/mypage/urls.py new file mode 100644 index 0000000..796aa0b --- /dev/null +++ b/mypage/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from . import views + +app_name = "mypage" + +urlpatterns = [ + path("", views.mypage, name="home"), +] diff --git a/mypage/views.py b/mypage/views.py new file mode 100644 index 0000000..5d2c3b9 --- /dev/null +++ b/mypage/views.py @@ -0,0 +1,218 @@ +from collections import Counter +from datetime import timedelta + +from django.apps import apps +from django.contrib.auth.decorators import login_required +from django.shortcuts import redirect, render +from django.utils import timezone + + +EMOTION_LABELS = { + "01": "슬픔", "02": "애정", "03": "우울", "04": "분노", + "05": "뿌듯함", "06": "불안", "07": "수줍음", "08": "못마땅함", + "09": "놀람", "10": "눈물", "11": "화남", "12": "긴장", + "13": "아픔", "14": "당황", "15": "편안함", "16": "행복", + "17": "지루함", "18": "신남", "19": "추움", "20": "졸림", +} + + +def _get_record_model(): + """기록 브랜치가 합쳐진 뒤 Record 모델을 자동으로 연결합니다.""" + try: + return apps.get_model("records", "Record") + except LookupError: + return None + + +def _get_character_url(user): + """accounts의 캐릭터 규격이 확정되면 이 함수만 맞춰 수정합니다.""" + for relation_name in ("character", "selected_character"): + try: + character = getattr(user, relation_name, None) + except Exception: + character = None + if not character: + continue + for image_name in ("image", "image_url"): + image = getattr(character, image_name, None) + if not image: + continue + return getattr(image, "url", image) + return None + + +def _record_day(record): + created_at = getattr(record, "created_at", None) + if created_at is None: + return None + if hasattr(created_at, "date"): + if timezone.is_aware(created_at): + created_at = timezone.localtime(created_at) + return created_at.date() + return created_at + + +def _record_emotion(record): + main_emotion = getattr(record, "main_emotion", None) + emotions = getattr(record, "emotions", []) or [] + value = main_emotion or (emotions[0] if emotions else None) + if not value: + return None + try: + return f"{int(value):02d}" + except (TypeError, ValueError): + return None + + +def _record_place_name(record): + for field_name in ("place_name", "location_name"): + value = getattr(record, field_name, None) + if value: + return str(value) + + for relation_name in ("place", "location"): + try: + place = getattr(record, relation_name, None) + except Exception: + place = None + if not place: + continue + for field_name in ("name", "place_name", "address_name", "address"): + value = getattr(place, field_name, None) + if value: + return str(value) + label = str(place).strip() + if label and label != "None": + return label + return None + + +def _get_user_records(user): + record_model = _get_record_model() + if record_model is None: + return [] + try: + return list(record_model.objects.filter(user=user).order_by("-created_at")) + except Exception: + return [] + + +def _build_insights(records): + today = timezone.localdate() + week_start = today - timedelta(days=today.weekday()) + record_dates = {day for day in map(_record_day, records) if day} + + streak = 0 + cursor = today if today in record_dates else today - timedelta(days=1) + while cursor in record_dates: + streak += 1 + cursor -= timedelta(days=1) + + weekly_emotions = [ + emotion + for record in records + if (record_day := _record_day(record)) + and week_start <= record_day <= today + and (emotion := _record_emotion(record)) + ] + weekly_emotion = Counter(weekly_emotions).most_common(1)[0][0] if weekly_emotions else None + + place_names = [name for record in records if (name := _record_place_name(record))] + frequent_place_data = Counter(place_names).most_common(1)[0] if place_names else None + frequent_place = frequent_place_data[0] if frequent_place_data else None + frequent_place_count = frequent_place_data[1] if frequent_place_data else 0 + + if weekly_emotion: + weekly_message = "이번 주에는 이 마음을 가장 자주 남겼어요. 내 마음을 천천히 돌아봐 주세요." + else: + weekly_message = "아직 이번 주에 남긴 마음이 없어요. 오늘의 마음자국을 남겨 볼까요?" + + return { + "weekly_emotion": weekly_emotion, + "weekly_emotion_label": EMOTION_LABELS.get(weekly_emotion, "대표 감정"), + "weekly_message": weekly_message, + "record_streak": streak, + "total_record_count": len(records), + "frequent_place": frequent_place, + "frequent_place_count": frequent_place_count, + } + + +def _build_attendance(user): + today = timezone.localdate() + joined_at = user.date_joined + if timezone.is_aware(joined_at): + joined_at = timezone.localtime(joined_at) + first_day = min(joined_at.date(), today) + record_by_date = {} + record_model = _get_record_model() + + if record_model is not None: + try: + records = record_model.objects.filter( + user=user, + created_at__date__gte=first_day, + created_at__date__lte=today, + ).order_by("-created_at") + for record in records: + record_day = _record_day(record) + if record_day is None: + continue + record_by_date.setdefault(record_day, record) + except Exception: + # Record 규격이 합쳐지는 과정에서도 마이페이지 자체는 렌더링합니다. + record_by_date = {} + + attendance_days = [] + elapsed_day_count = (today - first_day).days + 1 + # 오늘이 포함된 기간 다음의 미래 14일까지 탐색할 수 있게 한 페이지를 더 둡니다. + slot_count = (((elapsed_day_count + 13) // 14) + 1) * 14 + for offset in range(slot_count): + day = first_day + timedelta(days=offset) + is_future = day > today + record = None if is_future else record_by_date.get(day) + emotion_number = None + if record: + emotion_number = _record_emotion(record) + attendance_days.append({ + "date": day, + "record": record, + "emotion_number": emotion_number, + "is_future": is_future, + "is_today": day == today, + }) + return attendance_days, len(record_by_date) + + +@login_required +def mypage(request): + if request.method == "POST": + display_name = request.POST.get("display_name", "").strip() + if display_name and len(display_name) <= 30: + request.user.first_name = display_name + request.user.save(update_fields=["first_name"]) + return redirect("mypage:home") + + user_records = _get_user_records(request.user) + insights = _build_insights(user_records) + attendance_days, attendance_count = _build_attendance(request.user) + attendance_pages = [] + current_page_index = 0 + for start in range(0, len(attendance_days), 14): + page_days = attendance_days[start:start + 14] + if any(day["is_today"] for day in page_days): + current_page_index = len(attendance_pages) + attendance_pages.append({ + "days": page_days, + "label": ( + f"{page_days[0]['date']:%m/%d} - {page_days[-1]['date']:%m/%d}" + ), + }) + context = { + "attendance_pages": attendance_pages, + "attendance_count": attendance_count, + "current_page_index": current_page_index, + "character_url": _get_character_url(request.user), + **insights, + } + return render(request, "mypage/mypage.html", context)