diff --git a/backend-core/staticfiles/missions/css/mission_list.css b/backend-core/staticfiles/missions/css/mission_list.css index 0d10041..a65d1e9 100644 --- a/backend-core/staticfiles/missions/css/mission_list.css +++ b/backend-core/staticfiles/missions/css/mission_list.css @@ -134,10 +134,10 @@ animation: pulse-pin 2s infinite; } -/* 미션 마커 (노란색) */ +/* 미션 마커 - 색상은 JS(CATEGORY_COLORS)에서 inline style로 주입됨 */ .mission-marker { - background: #FFC107; z-index: 50; + /* background 는 map_manager.js 의 CATEGORY_COLORS 에서 결정 */ } .mission-marker:hover { @@ -145,12 +145,48 @@ z-index: 101; } +/* 매칭 완료된 미션 마커는 약간 흐리게 처리 */ +.mission-marker.status-matched, +.mission-marker.status-completed { + opacity: 0.5; +} + @keyframes pulse-pin { 0% { transform: scale(1); opacity: 0.8; } 100% { transform: scale(2.5); opacity: 0; } } -/* ==================== 4. 헤더 & 필터 UI ==================== */ +/* ==================== 4. 지도 범례 ==================== */ +.map-legend { + position: absolute; + left: 12px; + bottom: 12px; + z-index: 10; + background: white; + border-radius: 10px; + padding: 8px 12px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + font-size: 11px; + color: #374151; + pointer-events: none; /* 지도 클릭 방해 안 하도록 */ +} + +.map-legend-item { + display: flex; + align-items: center; + gap: 6px; + margin: 3px 0; + line-height: 1; +} + +.map-legend-dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; +} + +/* ==================== 5. 헤더 & 필터 UI ==================== */ .mission-header { height: 70px; max-width: 450px; @@ -205,10 +241,6 @@ } - - - - /* 필터 버튼 활성화 상태 */ .filter-btn { width: 40px; @@ -240,7 +272,7 @@ stroke: white; } -/* ==================== 5. 기타 UI 요소 (툴바, 패널 등) ==================== */ +/* ==================== 6. 기타 UI 요소 (툴바, 패널 등) ==================== */ .mission-toolbar { display: flex; flex-direction: column; @@ -430,9 +462,6 @@ } - - - /* 전체화면 지도 컨테이너 */ #fullscreen-map-container { position: fixed; diff --git a/backend-core/staticfiles/missions/js/map_manager.js b/backend-core/staticfiles/missions/js/map_manager.js index 3dae3f6..bc36f23 100644 --- a/backend-core/staticfiles/missions/js/map_manager.js +++ b/backend-core/staticfiles/missions/js/map_manager.js @@ -14,9 +14,32 @@ * * 수정 사항: * - getUserLocation(): 3단계 폴백 전략 - * - 커스텀 마커: CSS 스타일 사용 (파란색 핀/노란색 핀) + * - 커스텀 마커: CSS 스타일 사용 (파란색 핀/카테고리별 색상 핀) + * - CATEGORY_COLORS: 카테고리별 색상 단일 소스 관리 */ +// ==================== 카테고리 색상 단일 소스 ==================== + +const CATEGORY_COLORS = { + ERRAND: '#3679E3', // 심부름 - 파랑 + STUDY: '#27C27B', // 학업 - 초록 + RENTAL: '#FFB800', // 대여 - 노랑 + RECRUIT: '#8B5CF6', // 구인 - 보라 + LIFE: '#FF6B9A', // 생활 - 핑크 + OTHER: '#94A3B8', // 기타 - 회색 +}; + +const CATEGORY_LABELS = { + ERRAND: '심부름', + STUDY: '학업', + RENTAL: '대여', + RECRUIT: '구인', + LIFE: '생활', + OTHER: '기타', +}; + +const CATEGORY_COLOR_DEFAULT = '#94A3B8'; // fallback + class KakaoMapManager { constructor(containerId, options = {}) { this.containerId = containerId; @@ -177,10 +200,10 @@ class KakaoMapManager { } /** - * 커스텀 마커 추가 (CSS 스타일 사용 - 노란색 핀) + * 커스텀 마커 추가 (카테고리별 색상 적용) * @param {number} lat * @param {number} lng - * @param {object} options { status: 'WAITING'|'MATCHED'|'COMPLETED', onClick: fn } + * @param {object} options { category: 'ERRAND'|'STUDY'|..., status: 'WAITING'|..., onClick: fn } * @returns {kakao.maps.CustomOverlay} */ addCustomMarker(lat, lng, options = {}) { @@ -191,13 +214,17 @@ class KakaoMapManager { const position = new kakao.maps.LatLng(lat, lng); - // CSS 마커 DOM 생성 + // 카테고리 색상 결정 (단일 소스: CATEGORY_COLORS) + const color = CATEGORY_COLORS[options.category] ?? CATEGORY_COLOR_DEFAULT; + + // CSS 마커 DOM 생성 (색상은 inline style로 주입) const markerEl = document.createElement('div'); markerEl.className = 'mission-marker'; - - // 상태별 클래스 추가 + markerEl.style.background = color; + + // 상태별 클래스 추가 (흐리게 처리 등 상태 UI에 활용) if (options.status) { - markerEl.classList.add(options.status.toLowerCase()); + markerEl.classList.add(`status-${options.status.toLowerCase()}`); } // CustomOverlay 생성 @@ -404,6 +431,8 @@ function waitForKakaoMaps() { // ==================== 전역 노출 ==================== window.KakaoMapManager = KakaoMapManager; +window.CATEGORY_COLORS = CATEGORY_COLORS; +window.CATEGORY_LABELS = CATEGORY_LABELS; window.MapUtils = { displayUserLocation, waitForKakaoMaps diff --git a/backend-core/staticfiles/users/css/homepage_guest.css b/backend-core/staticfiles/users/css/homepage_guest.css index 484297b..cea03f6 100644 --- a/backend-core/staticfiles/users/css/homepage_guest.css +++ b/backend-core/staticfiles/users/css/homepage_guest.css @@ -15,24 +15,30 @@ body { font-family: 'Noto Sans KR', sans-serif; } -/* 2. 헤더 섹션 (WelcomeScreen 상단 블루 영역) */ +/* 2. 헤더 섹션 (상단 연한 블루 영역 수정) */ .header { - background: linear-gradient(180deg, var(--primary-blue) 0%, #6BB6FF 100%); - padding: 60px 20px 60px; + background: #E6F1FF; /* 배경색 수정 */ + padding: 60px 20px 80px; text-align: center; - color: var(--white); } -.logo-circle { - width: 60px; height: 60px; - background: var(--white); color: var(--primary-blue); - border-radius: 18px; display: flex; align-items: center; justify-content: center; - font-size: 32px; font-weight: 800; margin: 0 auto 16px; - box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); +/* 실제 로고 이미지 스타일 */ +.login-logo { + width: 350px; /* 로고 크기 */ + height: auto; + display: block; + margin: 0 auto 12px; /* 가로 중앙 정렬 및 하단 여백 */ } -.brand-name { font-size: 30px; font-weight: 700; margin-bottom: 5px; } -.brand-subtitle { font-size: 14px; opacity: 0.9; } +.brand-subtitle { + width: 350px; /* 로고 너비와 똑같이 맞춰서 기준점을 잡습니다 */ + margin: 0 auto; /* 로고와 동일하게 가로 중앙에 배치 */ + text-align: center; /* 350px라는 영역 안에서 글자를 중앙으로 정렬 */ + font-size: 15px; + font-weight: 500; + color: #4DA6FF; + line-height: 2; /* 글자가 너무 붙어 보이지 않게 약간의 높이 추가 */ +} /* 3. 미션 리스트 섹션 (문구 위치 및 굵기 설정) */ .mission-list-section { @@ -52,7 +58,7 @@ body { color: var(--text-black); margin-bottom: 8px; } -/* 설명 문구: 도움까지, 뒤에 줄바꿈(\A) 및 글자 크기 16px 적용 */ +/* 설명 문구 */ .mission-list-section::after { content: "작은 부탁부터 과제 도움까지,\A학생들이 서로 돕는 미션 플랫폼"; order: -1; @@ -61,7 +67,7 @@ body { color: var(--text-gray); line-height: 1.6; margin-bottom: 30px; - white-space: pre-wrap; /* \A 줄바꿈 인식을 위해 필수 */ + white-space: pre-wrap; } .section-title { @@ -121,9 +127,11 @@ body { .price { font-size: 18px; font-weight: 700; color: var(--primary-blue); } /* 5. 하단 버튼 영역 (로그인/회원가입) */ -.guest-footer-area { padding: 30px 20px 100px; background: var(--white); } +.guest-footer-area { + padding: 30px 25px 100px; /* 양옆 여백을 20px에서 25px로 변경 */ + background: var(--white); +} -/* 안내 문구와 미션 카드 사이 간격 확보 */ .guest-footer-text { text-align: center; font-size: 14px; @@ -131,25 +139,33 @@ body { margin: 60px 0 20px; } -/* [수정] 로그인/회원가입 버튼 규격: 로그인 페이지의 .btn-main과 일치시킴 */ .guest-cta { - display: flex; align-items: center; justify-content: center; gap: 10px; - width: calc(100% - 40px); - height: 56px; /* 높이 56px 적용 */ - margin: 0 20px 12px; - border-radius: 12px; /* 테두리 반경 12px 적용 */ - font-size: 18px; /* 글자 크기 18px 적용 */ - font-weight: 700; /* 글자 굵기 700 적용 */ + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + + /* 핵심 수정 부분: 가로 크기를 100%로 하고 양옆 마진을 0으로 합니다 */ + width: 100%; + margin: 0 0 12px 0; /* 위 0, 오른쪽 0, 아래 12px, 왼쪽 0 */ + + height: 56px; + border-radius: 12px; + font-size: 18px; + font-weight: 700; text-decoration: none; box-sizing: border-box; } -/* 로그인 버튼 색상 */ -.guest-cta:nth-of-type(1) { background: var(--primary-blue); color: var(--white); } +/* 첫 번째 버튼 (로그인): 배경색 적용 */ +.guest-cta:nth-of-type(1) { + background: #4DA6FF; + color: #ffffff; +} -/* 회원가입 버튼 색상 */ +/* 두 번째 버튼 (회원가입): 테두리와 글자색 적용 */ .guest-cta:nth-of-type(2) { - background: var(--white); - color: var(--primary-blue); - border: 1px solid var(--primary-blue); + background: #ffffff; + color: #4DA6FF; + border: 1px solid #4DA6FF; } \ No newline at end of file diff --git a/backend-core/staticfiles/users/css/login.css b/backend-core/staticfiles/users/css/login.css index 694b644..2f60519 100644 --- a/backend-core/staticfiles/users/css/login.css +++ b/backend-core/staticfiles/users/css/login.css @@ -41,10 +41,14 @@ body { padding-top: 15px; } -.header h2 { - grid-row: 1; /* 첫 번째 줄 */ +/* 텍스트 정렬 및 로고 배치를 위한 영역 */ +.header-text { grid-column: 2; - font-size: 24px; /* 시안처럼 조금 더 크게 조절 가능 */ + text-align: left; /* 상단 텍스트는 화살표 옆 왼쪽 정렬 */ +} + +.header h2 { + font-size: 24px; font-weight: 700; margin: 0; color: #101828; @@ -52,49 +56,37 @@ body { } .header p { - grid-row: 2; /* 두 번째 줄 */ - grid-column: 2; font-size: 14px; color: #667085; margin: 4px 0 0 0; } -/* 4. 요소 순서 강제 조정 (로고 -> 명칭 -> 입력창) */ -.input-section { - display: flex; - flex-direction: column; +/* 4. 로고 이미지 스타일 */ +.login-logo { + width: 370px; + height: auto; + display: block; + margin: 50px 0 5px -25px; /* 왼쪽으로 25px 당김 */ +} + +/* 로고 밑에 '대학생 미션 중개 플랫폼' 문구 */ +.header-text::after { + content: "대학생 미션 중개 플랫폼"; + display: block; + width: 370px; + margin-left: -25px; text-align: center; + font-size: 15px; + font-weight: 500; + color: #667085; + margin-top: 20px; + margin-bottom: 20px; } -/* [순서 1] 로고 박스 */ -.input-section::before { - content: 'U'; - order: -2; +.input-section { display: flex; - justify-content: center; - align-items: center; - width: 60px; - height: 60px; - background: #FFFFFF; - border-radius: 15px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); - margin: 10px auto 15px; - font-size: 30px; - font-weight: 800; - color: #57A6FF; -} - -/* [순서 2] Uniquest 서비스 명칭 및 설명 */ -.input-section::after { - content: "Uniquest\A 대학생 미션 중개 플랫폼"; - order: -1; - display: block; - white-space: pre; - font-size: 22px; - font-weight: 700; - color: #344054; - margin-bottom: 35px; - line-height: 1.3; + flex-direction: column; + text-align: center; } /* [순서 3] 입력창 영역 */ @@ -137,7 +129,7 @@ input { .btn-main { width: 100%; height: 56px; - background-color: #57A6FF; + background-color: #4DA6FF; color: #ffffff; border: none; border-radius: 12px; @@ -176,35 +168,35 @@ input { /* 6. 하단 푸터 및 이용약관 */ .footer { margin-top: 25px; - display: flex; /* 나란히 배치하기 위해 flex 적용 */ - justify-content: center; /* 가로 중앙 정렬 */ - align-items: center; /* 세로 중앙 정렬 */ - gap: 15px; /* 두 링크 사이의 간격 */ + display: flex; + justify-content: center; + align-items: center; + gap: 15px; font-size: 14px; } /* 비밀번호 찾기 (회색 링크) */ #search_key { - display: inline-block; /* display: none 제거 */ - color: #98A2B3; /* 약간 회색 */ + display: inline-block; + color: #98A2B3; text-decoration: none; transition: color 0.2s; } #search_key:hover { - color: #667085; /* 호버 시 조금 더 진한 회색 */ + color: #667085; } /* 회원가입 하기 (파란색 링크) */ #signup { - color: #57A6FF; /* 파란색 */ + color: #4DA6FF; text-decoration: none; font-weight: 600; } -/* 두 메뉴 사이에 구분선(c)을 넣고 싶을 때 추가하는 스타일 (선택 사항) */ +/* 두 메뉴 사이의 구분선 */ #search_key::after { - content: "|"; /* 구분선 텍스트 제거 */ + content: "|"; margin-left: 15px; } diff --git a/backend-core/staticfiles/users/css/my_missions.css b/backend-core/staticfiles/users/css/my_missions.css index a22395f..3f88112 100644 --- a/backend-core/staticfiles/users/css/my_missions.css +++ b/backend-core/staticfiles/users/css/my_missions.css @@ -136,8 +136,7 @@ text-align: center; padding: 40px 20px; color: #98A2B3; - font-size: 13px; - font-weight:500; + font-size: 14px; } diff --git a/backend-core/staticfiles/users/images/logo.png b/backend-core/staticfiles/users/images/logo.png new file mode 100644 index 0000000..0e792e3 Binary files /dev/null and b/backend-core/staticfiles/users/images/logo.png differ diff --git a/backend-core/users/serializers.py b/backend-core/users/serializers.py index 8c298e5..6d86788 100644 --- a/backend-core/users/serializers.py +++ b/backend-core/users/serializers.py @@ -56,11 +56,92 @@ def create(self, validated_data): class UserProfileSerializer(serializers.ModelSerializer): """ - 프로필 조회용 시리얼라이저 + 프로필 조회용 시리얼라이저 (profile/, 공개 프로필 공통) """ - # 보여주기용: 대학 객체의 이름을 문자열로 반환 university = serializers.CharField(source='university.name', read_only=True) + userphoto = serializers.SerializerMethodField() class Meta: model = User - fields = ['id', 'username', 'university', 'is_student_verified', 'manner_score'] + fields = ['id', 'username', 'university', 'is_student_verified', 'manner_score', 'userphoto'] + + def get_userphoto(self, obj): + return obj.userphoto.url if obj.userphoto else None + + def to_representation(self, instance): + data = super().to_representation(instance) + if instance.manner_score is not None: + data['manner_score'] = round(float(instance.manner_score), 1) + return data + + +class UserMypageSerializer(serializers.ModelSerializer): + """마이페이지 전체 정보 (GET /api/profile/)""" + university = serializers.CharField(source='university.name', read_only=True) + userphoto = serializers.SerializerMethodField() + missions = serializers.SerializerMethodField() + blocked_people = serializers.SerializerMethodField() + accepted_missions = serializers.SerializerMethodField() + review_data = serializers.SerializerMethodField() + + class Meta: + model = User + fields = [ + 'id', 'username', 'university', 'univ_email', 'is_student_verified', + 'manner_score', 'missions', 'blocked_people', 'accepted_missions', + 'userphoto', 'review_data', + ] + + def get_userphoto(self, obj): + return obj.userphoto.url if obj.userphoto else None + + def get_missions(self, obj): + return list(obj.missions.all().values( + 'id', 'title', 'reward', 'status', 'descriptions', 'category', 'location_name' + )) + + def get_blocked_people(self, obj): + return list(obj.blocked_people.all().values('id', 'username')) + + def get_accepted_missions(self, obj): + return list(obj.accepted_missions.all().values( + 'id', 'title', 'reward', 'status', 'descriptions' + )) + + def get_review_data(self, obj): + return obj.review_datas + + def to_representation(self, instance): + data = super().to_representation(instance) + if instance.manner_score is not None: + data['manner_score'] = round(float(instance.manner_score), 1) + return data + + +class UserProfileModifySerializer(serializers.ModelSerializer): + """프로필 수정 페이지 GET/PATCH (GET: 조회, PATCH: username, user_photo 수정)""" + university = serializers.CharField(source='university.name', read_only=True) + userphoto = serializers.SerializerMethodField(read_only=True) + user_photo = serializers.ImageField(write_only=True, required=False) + + class Meta: + model = User + fields = ['username', 'univ_email', 'university', 'userphoto', 'user_photo'] + read_only_fields = ['univ_email', 'university'] + + def get_userphoto(self, obj): + return obj.userphoto.url if obj.userphoto else None + + def update(self, instance, validated_data): + user_photo = validated_data.pop('user_photo', None) + if 'username' in validated_data: + instance.username = validated_data['username'] + update_fields = [] + if 'username' in validated_data: + update_fields.append('username') + if user_photo is not None: + instance.userphoto = user_photo + update_fields.append('userphoto') + if update_fields: + instance.save(update_fields=update_fields) + return instance diff --git a/backend-core/users/views.py b/backend-core/users/views.py index 0118228..ceb6297 100644 --- a/backend-core/users/views.py +++ b/backend-core/users/views.py @@ -1,7 +1,12 @@ from rest_framework import generics, status, views from rest_framework.response import Response from rest_framework.permissions import AllowAny, IsAuthenticated -from .serializers import UserRegisterSerializer, UserProfileSerializer +from .serializers import ( + UserRegisterSerializer, + UserProfileSerializer, + UserMypageSerializer, + UserProfileModifySerializer, +) from django.contrib.auth import get_user_model from django.shortcuts import render,redirect import json @@ -234,23 +239,8 @@ def logout(request): @api_view(['GET']) @permission_classes([IsAuthenticated]) # 🛡️ 토큰 해독 보안 요원 def get_my_info(request): - user = request.user - missions = list(user.missions.all().values('id','title','reward','status','descriptions','category','location_name')) - accepted_missions = list(user.accepted_missions.all().values('id','title','reward','status','descriptions')) - blocked_Queryset = user.blocked_people.all() - return Response({ - "id": user.id, - "username": user.username, - "university": user.university.name if user.university else None, - "univ_email": user.univ_email, - "is_student_verified": user.is_student_verified, - "manner_score": round(user.manner_score, 1), - "missions": missions, - "blocked_people": list(blocked_Queryset.values('id', 'username')), - "accepted_missions": accepted_missions, - "userphoto": user.userphoto.url if user.userphoto else None, - "review_data" : user.review_datas - }) + serializer = UserMypageSerializer(request.user) + return Response(serializer.data) def mypage_view(request): return render(request, 'users/mypage.html') @@ -261,33 +251,18 @@ def mypage_view(request): @permission_classes([IsAuthenticated]) def get_my_info_patch(request): user = request.user - + if request.method == 'GET': - # 기존 조회 로직 - return Response({ - "username": user.username, - "univ_email": user.univ_email, - "university": user.university.name if user.university else None, - "userphoto" : user.userphoto.url if user.userphoto else None - }) + serializer = UserProfileModifySerializer(user) + return Response(serializer.data) elif request.method == 'PATCH': - # 1. 프론트에서 보낸 데이터(updatedData) 받기 - username = request.data.get('username') - userphoto = request.data.get('user_photo') - - # 2. 데이터 업데이트 (값이 있을 때만) - if username: - user.username = username - if userphoto: - user.userphoto = userphoto - - # 3. DB 저장 - user.save() - + serializer = UserProfileModifySerializer(user, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() return Response({ "message": "수정 완료", - "username": user.username, + "username": serializer.instance.username, }, status=status.HTTP_200_OK) def mypage_modify_view(request): @@ -553,15 +528,8 @@ def get_public_profile(request, user_id): if user.blocked_people.filter(id=request.user.id).exists(): return Response({"error": "접근할 수 없습니다."}, status=status.HTTP_404_NOT_FOUND) - return Response({ - "id": user.id, - "username": user.username, - "university": user.university.name if user.university else None, - "is_student_verified": user.is_student_verified, - "manner_score": round(user.manner_score, 1), - "userphoto": request.build_absolute_uri(user.userphoto.url) if user.userphoto else None, - "review_datas": user.review_datas or [], # ✅ 추가 - }) + serializer = UserProfileSerializer(user) + return Response(serializer.data) from django.shortcuts import render, get_object_or_404