{{ record.created_at|date:"Y년 n월 j일 l" }}
+ {% if record.place_name %} + 📍 {{ record.place_name }} + {% endif %} +등록된 사진이 없어요.
+ {% endif %} +diff --git a/config/settings.py b/config/settings.py index 0c9a74e..9630e4d 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 @@ -94,9 +91,12 @@ def load_local_env(): USE_TZ = True STATIC_URL = "static/" STATICFILES_DIRS = [BASE_DIR / "static"] +MEDIA_URL = "media/" +MEDIA_ROOT = BASE_DIR / "media" + +LOGIN_URL = "/accounts/login/" DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" -<<<<<<< HEAD # 카카오맵 API 키 (locations 앱: 위치 선택/지도 화면에서 사용) # - KAKAO_JS_KEY: 브라우저에서 카카오맵 JS SDK를 로드할 때 쓰는 키. # 공개돼도 큰 문제는 없지만(카카오 콘솔에 등록된 도메인에서만 동작), @@ -107,7 +107,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 +126,4 @@ def load_local_env(): "AUTH_PARAMS": {"access_type": "online"}, } } ->>>>>>> develop diff --git a/config/urls.py b/config/urls.py index 9612a1b..fd3239b 100644 --- a/config/urls.py +++ b/config/urls.py @@ -1,3 +1,5 @@ +from django.conf import settings +from django.conf.urls.static import static from django.contrib import admin from django.urls import include, path @@ -11,3 +13,5 @@ path("diary/", include("diary.urls")), ] +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/records/admin.py b/records/admin.py index 8b13789..9e465e8 100644 --- a/records/admin.py +++ b/records/admin.py @@ -1 +1,10 @@ +from django.contrib import admin +from .models import Record + + +@admin.register(Record) +class RecordAdmin(admin.ModelAdmin): + list_display = ("id", "user", "weather", "place_name", "created_at") + list_filter = ("weather", "created_at") + search_fields = ("user__username", "content", "place_name") diff --git a/records/forms.py b/records/forms.py index 961f416..5381425 100644 --- a/records/forms.py +++ b/records/forms.py @@ -1 +1,57 @@ -# 담당 C: 기록 작성, 수정 및 이미지 업로드 폼 +from django import forms + +from .models import Record + + +class RecordForm(forms.ModelForm): + emotions = forms.MultipleChoiceField( + choices=[(str(number), f"감정 {number}") for number in range(1, 21)], + required=True, + error_messages={"required": "감정을 1개 이상 선택해 주세요."}, + ) + main_emotion = forms.ChoiceField( + choices=[ + ("", "메인 감정 선택"), + *[ + (str(number), f"감정 {number}") + for number in range(1, 21) + ], + ], + required=True, + error_messages={"required": "대표 감정을 선택해 주세요."}, + ) + + class Meta: + model = Record + fields = [ + "weather", "content", "image", "emotions", "main_emotion", + "place_name", "latitude", "longitude", + ] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.instance.pk and not self.is_bound: + self.initial["emotions"] = [ + str(emotion) for emotion in self.instance.emotions + ] + self.initial["main_emotion"] = str(self.instance.main_emotion) + + def clean_emotions(self): + emotions = self.cleaned_data.get("emotions", []) + if len(emotions) > 3: + raise forms.ValidationError("감정은 최대 3개까지 선택할 수 있습니다.") + return [int(emotion) for emotion in emotions] + + def clean_main_emotion(self): + return int(self.cleaned_data["main_emotion"]) + + def clean(self): + cleaned_data = super().clean() + emotions = cleaned_data.get("emotions", []) + main_emotion = cleaned_data.get("main_emotion") + if main_emotion is not None and main_emotion not in emotions: + self.add_error( + "main_emotion", + "선택한 감정 중에서 대표 감정을 골라주세요.", + ) + return cleaned_data diff --git a/records/migrations/0001_initial.py b/records/migrations/0001_initial.py new file mode 100644 index 0000000..92be6ea --- /dev/null +++ b/records/migrations/0001_initial.py @@ -0,0 +1,36 @@ +# Generated by Django 6.0.7 on 2026-07-31 14:14 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Record', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('weather', models.CharField(choices=[('sunny', '해'), ('cloudy', '구름'), ('rainy', '비'), ('thunder', '천둥'), ('dust', '황사'), ('snowy', '눈')], max_length=10)), + ('content', models.TextField(max_length=500)), + ('image', models.ImageField(blank=True, upload_to='records/%Y/%m/%d/')), + ('emotions', models.JSONField(default=list)), + ('place_name', models.CharField(blank=True, max_length=100)), + ('latitude', models.DecimalField(blank=True, decimal_places=7, max_digits=10, null=True)), + ('longitude', models.DecimalField(blank=True, decimal_places=7, max_digits=10, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='heartmark_records', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/records/migrations/0002_alter_record_image.py b/records/migrations/0002_alter_record_image.py new file mode 100644 index 0000000..3f633f8 --- /dev/null +++ b/records/migrations/0002_alter_record_image.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.7 on 2026-08-01 13:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('records', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='record', + name='image', + field=models.ImageField(upload_to='records/%Y/%m/%d/'), + ), + ] diff --git a/records/migrations/0003_record_main_emotion.py b/records/migrations/0003_record_main_emotion.py new file mode 100644 index 0000000..245fc8c --- /dev/null +++ b/records/migrations/0003_record_main_emotion.py @@ -0,0 +1,42 @@ +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import migrations, models + + +def set_existing_main_emotions(apps, schema_editor): + Record = apps.get_model("records", "Record") + for record in Record.objects.all().iterator(): + emotions = record.emotions if isinstance(record.emotions, list) else [] + if not emotions: + emotions = [1] + record.emotions = emotions + record.main_emotion = int(emotions[0]) + record.save(update_fields=["emotions", "main_emotion"]) + + +class Migration(migrations.Migration): + dependencies = [ + ("records", "0002_alter_record_image"), + ] + + operations = [ + migrations.AddField( + model_name="record", + name="main_emotion", + field=models.PositiveSmallIntegerField( + blank=True, + null=True, + validators=[MinValueValidator(1), MaxValueValidator(20)], + ), + ), + migrations.RunPython( + set_existing_main_emotions, + migrations.RunPython.noop, + ), + migrations.AlterField( + model_name="record", + name="main_emotion", + field=models.PositiveSmallIntegerField( + validators=[MinValueValidator(1), MaxValueValidator(20)], + ), + ), + ] diff --git a/records/models.py b/records/models.py index 28a96cb..c64567e 100644 --- a/records/models.py +++ b/records/models.py @@ -1 +1,75 @@ -# 담당 C: Record 생성, 상세, 수정, 삭제 +from django.conf import settings +from django.core.exceptions import ValidationError +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models + + +class Record(models.Model): + class Weather(models.TextChoices): + SUNNY = "sunny", "해" + CLOUDY = "cloudy", "구름" + RAINY = "rainy", "비" + THUNDER = "thunder", "천둥" + DUST = "dust", "황사" + SNOWY = "snowy", "눈" + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="heartmark_records", + ) + weather = models.CharField(max_length=10, choices=Weather.choices) + content = models.TextField(max_length=500) + image = models.ImageField(upload_to="records/%Y/%m/%d/") + emotions = models.JSONField(default=list) + main_emotion = models.PositiveSmallIntegerField( + validators=[MinValueValidator(1), MaxValueValidator(20)], + ) + + # TODO: locations.Place 규격 확정 후 ForeignKey 연결을 검토합니다. + place_name = models.CharField(max_length=100, blank=True) + latitude = models.DecimalField(max_digits=10, decimal_places=7, null=True, blank=True) + longitude = models.DecimalField(max_digits=10, decimal_places=7, null=True, blank=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self): + return f"{self.user}의 기록 ({self.created_at:%Y-%m-%d})" + + @property + def weather_image_name(self): + return f"{self.weather}.png" + + @property + def emotion_image_names(self): + return [f"emotion-{int(number):02d}.png" for number in self.emotions] + + @property + def emotion_items(self): + numbers = [int(number) for number in self.emotions] + numbers.sort(key=lambda number: number != self.main_emotion) + return [ + { + "number": number, + "image_name": f"emotion-{number:02d}.png", + "is_main": number == self.main_emotion, + } + for number in numbers + ] + + def clean(self): + super().clean() + if not isinstance(self.emotions, list): + raise ValidationError({"emotions": "감정은 목록 형태여야 합니다."}) + if not self.emotions: + raise ValidationError({"emotions": "감정을 1개 이상 선택해 주세요."}) + if len(self.emotions) > 3: + raise ValidationError({"emotions": "감정은 최대 3개까지 선택할 수 있습니다."}) + if self.main_emotion not in self.emotions: + raise ValidationError({ + "main_emotion": "선택한 감정 중에서 대표 감정을 골라주세요." + }) diff --git a/records/static/records/css/records.css b/records/static/records/css/records.css index fb4434c..9a95cf4 100644 --- a/records/static/records/css/records.css +++ b/records/static/records/css/records.css @@ -1,2 +1,1405 @@ -/* 담당 C 전용 */ +:root { + --record-paper: #fff9eb; + --record-page: #fffff4; + --record-accent: #ffe7d0; + --record-ink: #46372d; + --record-muted: #8d796a; + --record-line: rgba(98, 76, 61, 0.28); +} +.record-modal, +.record-modal * { + box-sizing: border-box; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.record-preview { + min-height: calc(100vh - 80px); + display: grid; + place-items: center; + background: + radial-gradient(circle at 15% 20%, #ffe7d0 0 4%, transparent 4.2%), + linear-gradient(135deg, #fffff4, #fff9eb); +} + +.record-preview__placeholder { + text-align: center; + color: var(--record-muted); +} + +.record-preview__placeholder button { + padding: 13px 24px; + border: 1px solid var(--record-ink); + border-radius: 14px 11px 16px 10px; + color: var(--record-ink); + background: var(--record-accent); + font: inherit; + font-weight: 700; + cursor: pointer; +} + +.record-overlay[hidden] { + display: none; +} + +.record-overlay { + position: fixed; + inset: 0; + z-index: 1000; + display: grid; + place-items: center; + padding: 18px 140px 18px 210px; + background: rgba(57, 52, 48, 0.58); + backdrop-filter: blur(3px); +} + +.record-modal { + position: relative; + width: min(1150px, calc(100vw - 500px)); + height: min(760px, calc(100vh - 36px)); + max-height: calc(100vh - 36px); + transition: width 180ms ease, max-height 180ms ease; +} + +.record-modal--fullscreen { + position: fixed; + inset: 0; + z-index: 1001; + width: 100vw; + height: 100vh; + max-width: none; + max-height: none; +} + +.record-modal__controls { + position: absolute; + top: 18px; + right: 20px; + z-index: 3; + display: flex; + gap: 8px; +} + +.record-icon-button { + display: grid; + width: 38px; + height: 38px; + padding: 0; + place-items: center; + border: 1.5px solid var(--record-ink); + border-radius: 10px 8px 11px 7px; + color: var(--record-ink); + background: rgba(255, 249, 235, 0.92); + font-size: 1.65rem; + line-height: 1; + cursor: pointer; +} + +.record-paper { + position: relative; + display: grid; + grid-template-rows: + auto + minmax(245px, 1fr) + minmax(120px, 0.52fr) + auto + auto; + gap: 10px; + width: 100%; + height: 100%; + max-height: calc(100vh - 36px); + padding: 38px 64px 26px; + overflow: auto; + border: 2px solid var(--record-ink); + border-radius: 35px 29px 39px 31px; + color: var(--record-ink); + background-color: var(--record-paper); + background-image: + radial-gradient(rgba(126, 98, 75, 0.06) 0.7px, transparent 0.7px); + background-size: 7px 7px; + box-shadow: 0 24px 70px rgba(38, 30, 24, 0.25); +} + +.record-modal--fullscreen .record-paper { + display: grid; + grid-template-rows: + auto + minmax(260px, 34vh) + minmax(150px, 1fr) + auto + auto; + gap: 12px; + width: 100%; + height: 100%; + min-height: 100%; + max-height: none; + padding: + clamp(64px, 7vh, 88px) + clamp(72px, 8vw, 164px) + clamp(52px, 6vh, 76px); + border-radius: 0; + box-shadow: none; +} + +.record-imagination-character { + position: absolute; + bottom: 12px; + left: -285px; + z-index: 2; + width: 235px; + pointer-events: none; + filter: drop-shadow(3px 6px 3px rgba(50, 40, 32, 0.16)); + transition: opacity 120ms ease; +} + +.record-imagination-character img { + display: block; + width: 205px; + height: auto; +} + +.thought-dot { + position: absolute; + display: block; + border: 2px solid var(--record-ink); + border-radius: 50%; + background: var(--record-paper); +} + +.thought-dot--small { + top: -6px; + right: 45px; + width: 12px; + height: 12px; +} + +.thought-dot--medium { + top: -35px; + right: 15px; + width: 19px; + height: 19px; +} + +.thought-dot--large { + top: -72px; + right: -17px; + width: 29px; + height: 29px; +} + +.record-modal--fullscreen .record-imagination-character { + display: none; +} + +.record-modal--fullscreen .record-modal__controls { + position: fixed; + top: 18px; + right: 22px; +} + +.record-modal--fullscreen .record-paper__header { + grid-template-columns: minmax(280px, 1fr) minmax(520px, 1fr); + gap: 64px; + padding-right: 0; + margin-bottom: 0; +} + +.record-modal--fullscreen .record-date { + align-self: center; + justify-self: start; +} + +.record-modal--fullscreen .record-date strong { + font-size: 1.18rem; +} + +.record-modal--fullscreen .weather-picker { + justify-self: end; + justify-content: flex-end; + gap: 16px; +} + +.record-modal--fullscreen .weather-picker__options { + gap: 7px; +} + +.record-modal--fullscreen .weather-icon { + width: 66px; + height: 66px; +} + +.record-modal--fullscreen .record-paper__header, +.record-modal--fullscreen .photo-box, +.record-modal--fullscreen .diary-field, +.record-modal--fullscreen .emotion-picker, +.record-modal--fullscreen .record-paper__footer { + width: min(100%, 1600px); + margin-inline: auto; +} + +.record-modal--fullscreen .photo-box { + height: 100%; + min-height: 0; +} + +.record-modal--fullscreen #record-photo-preview { + height: 100%; + min-height: 0; +} + +.record-modal--fullscreen .diary-field { + min-height: 0; + margin-top: 0; +} + +.record-modal--fullscreen .diary-field textarea { + height: 100%; + min-height: 0; +} + +.record-modal--fullscreen .emotion-picker { + margin-top: 0; +} + +.record-modal--fullscreen .record-paper__footer { + min-height: 48px; +} + +.record-paper__header { + display: grid; + grid-template-columns: minmax(260px, 1fr) minmax(520px, 1fr); + align-items: center; + gap: 52px; + padding-right: 58px; + margin-bottom: 0; +} + +.record-date { + display: flex; + flex-direction: column; + gap: 3px; +} + +.record-date strong { + font-size: 1.08rem; +} + +.weather-picker { + display: flex; + align-items: center; + justify-self: end; + justify-content: flex-end; + gap: 14px; + min-width: 0; + padding: 0; + margin: 0; + border: 0; +} + +.weather-picker__options { + display: flex; + align-items: center; + gap: 4px; +} + +.weather-option, +.emotion-option { + position: relative; + display: grid; + place-items: center; + cursor: pointer; +} + +.weather-option input, +.emotion-option input, +.photo-action input { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; + pointer-events: none; +} + +.weather-icon { + display: block; + width: 62px; + height: 62px; + border: 3px solid transparent; + border-radius: 44% 56% 49% 51%; + background-repeat: no-repeat; + background-position: center; + background-size: contain; + transition: transform 140ms ease, background-color 140ms ease; +} + +.weather-icon--sunny { + background-image: url("../images/weather/sunny.png"); +} + +.weather-icon--cloudy { + background-image: url("../images/weather/cloudy.png"); +} + +.weather-icon--rainy { + background-image: url("../images/weather/rainy.png"); +} + +.weather-icon--thunder { + background-image: url("../images/weather/thunder.png"); +} + +.weather-icon--dust { + background-image: url("../images/weather/dust.png"); +} + +.weather-icon--snowy { + background-image: url("../images/weather/snowy.png"); +} + +.weather-option:hover .weather-icon { + transform: scale(1.05); +} + +.weather-option input:checked + .weather-icon { + border-color: #e8b78f; + background-color: var(--record-accent); + box-shadow: 0 0 0 3px rgba(255, 231, 208, 0.72); + transform: scale(1.05); +} + +.photo-box { + position: relative; + display: grid; + min-height: 235px; + overflow: hidden; + place-items: center; + border: 2px solid var(--record-ink); + border-radius: 5px 10px 6px 8px; + background: rgba(255, 255, 244, 0.55); +} + +.photo-box__empty { + width: 100%; + text-align: center; +} + +.photo-box__empty > p { + margin: 0 0 20px; + color: var(--record-muted); + font-size: 0.88rem; +} + +.photo-actions { + display: grid; + grid-template-columns: 1fr; + align-items: center; + justify-items: center; +} + +.photo-action { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: 10px 36px; + font-weight: 700; + cursor: pointer; +} + +.photo-action:hover { + color: #b56f46; +} + +.photo-action__icon { + display: block; + width: 34px; + height: 34px; + object-fit: contain; + image-rendering: auto; +} + +#record-photo-preview { + width: 100%; + height: 300px; + object-fit: contain; + background: #f3eadb; +} + +.photo-box__remove { + position: absolute; + right: 12px; + bottom: 12px; + padding: 7px 11px; + border: 1px solid var(--record-ink); + border-radius: 9px; + color: var(--record-ink); + background: rgba(255, 249, 235, 0.9); + cursor: pointer; +} + +.diary-field { + display: block; + height: 100%; + min-height: 0; + margin-top: 0; +} + +.diary-field textarea { + width: 100%; + height: 100%; + min-height: 122px; + padding: 3px 10px 0; + resize: vertical; + border: 0; + outline: 0; + color: var(--record-ink); + background-color: transparent; + background-image: repeating-linear-gradient( + to bottom, + transparent 0, + transparent 30px, + var(--record-line) 31px, + transparent 32px + ); + font: inherit; + font-size: 1rem; + line-height: 32px; +} + +.diary-field textarea::placeholder { + color: #aa9687; +} + +.emotion-picker { + margin-top: 10px; +} + +.emotion-picker__heading { + display: flex; + align-items: baseline; + gap: 8px; +} + +.emotion-picker__heading h2 { + margin: 0; + font-size: 1rem; +} + +.emotion-picker__heading span, +.emotion-picker__heading output { + color: var(--record-muted); + font-size: 0.78rem; +} + +.emotion-picker__heading output { + margin-left: auto; + font-weight: 700; +} + +.emotion-picker__options { + display: grid; + grid-template-columns: repeat(10, minmax(48px, 1fr)); + gap: 4px; + margin-top: 4px; +} + +.emotion-icon { + display: block; + width: 72px; + height: 56px; + border: 3px solid transparent; + border-radius: 45% 55% 52% 48%; + background-repeat: no-repeat; + background-position: center; + background-size: contain; + transition: transform 140ms ease, background-color 140ms ease; +} + +.emotion-option:hover .emotion-icon { + transform: translateY(-3px); +} + +.emotion-option input:checked + .emotion-icon { + border-color: #e8b78f; + background-color: var(--record-accent); + box-shadow: 0 0 0 2px rgba(255, 231, 208, 0.75); + transform: scale(1.04); +} + +.emotion-option input:disabled + .emotion-icon { + opacity: 0.42; + cursor: not-allowed; +} + +.emotion-icon--1 { + background-image: url("../images/emotions/emotion-01.png"); +} + +.emotion-icon--2 { + background-image: url("../images/emotions/emotion-02.png"); +} + +.emotion-icon--3 { + background-image: url("../images/emotions/emotion-03.png"); +} + +.emotion-icon--4 { + background-image: url("../images/emotions/emotion-04.png"); +} + +.emotion-icon--5 { + background-image: url("../images/emotions/emotion-05.png"); +} + +.emotion-icon--6 { + background-image: url("../images/emotions/emotion-06.png"); +} + +.emotion-icon--7 { + background-image: url("../images/emotions/emotion-07.png"); +} + +.emotion-icon--8 { + background-image: url("../images/emotions/emotion-08.png"); +} + +.emotion-icon--9 { + background-image: url("../images/emotions/emotion-09.png"); +} + +.emotion-icon--10 { + background-image: url("../images/emotions/emotion-10.png"); +} + +.emotion-icon--11 { + background-image: url("../images/emotions/emotion-11.png"); +} + +.emotion-icon--12 { + background-image: url("../images/emotions/emotion-12.png"); +} + +.emotion-icon--13 { + background-image: url("../images/emotions/emotion-13.png"); +} + +.emotion-icon--14 { + background-image: url("../images/emotions/emotion-14.png"); +} + +.emotion-icon--15 { + background-image: url("../images/emotions/emotion-15.png"); +} + +.emotion-icon--16 { + background-image: url("../images/emotions/emotion-16.png"); +} + +.emotion-icon--17 { + background-image: url("../images/emotions/emotion-17.png"); +} + +.emotion-icon--18 { + background-image: url("../images/emotions/emotion-18.png"); +} + +.emotion-icon--19 { + background-image: url("../images/emotions/emotion-19.png"); +} + +.emotion-icon--20 { + background-image: url("../images/emotions/emotion-20.png"); +} + +.record-paper__footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 16px; + min-height: 60px; + padding-left: 0; +} + +.record-form-message { + margin: 0; + color: #b95a52; + font-size: 0.85rem; +} + +.emotion-main-badge { + position: absolute; + top: -7px; + right: -5px; + display: none; + padding: 3px 6px; + border: 1px solid #d9925e; + border-radius: 999px; + color: #7d4b2d; + background: #fff0c9; + font-size: 0.62rem; + font-weight: 800; + line-height: 1; + box-shadow: 1px 2px 0 rgba(70, 55, 45, 0.13); + transform: none; +} + +.emotion-option--main .emotion-main-badge { + display: inline-block; +} + +.emotion-option--main .emotion-icon { + box-shadow: 0 0 0 3px #e5a873, 0 0 0 6px rgba(255, 231, 208, 0.8) !important; +} + +.record-submit { + min-width: 142px; + min-height: 48px; + border: 1.5px solid var(--record-ink); + border-radius: 15px 12px 17px 11px; + color: var(--record-ink); + background: var(--record-accent); + font: inherit; + font-weight: 800; + cursor: pointer; + box-shadow: 3px 4px 0 rgba(70, 55, 45, 0.16); +} + +.record-submit:hover { + transform: translateY(-1px) rotate(-1deg); +} + +/* 기록 작성: 세로 사진 + 감정 / 날씨 + 일기 2열 구성 */ +.record-paper, +.record-modal--fullscreen .record-paper { + display: block; +} + +.record-create-layout { + position: relative; + display: grid; + grid-template-columns: minmax(260px, 0.78fr) minmax(430px, 1.55fr); + grid-template-rows: minmax(0, 1fr) auto auto; + gap: clamp(34px, 4vw, 68px); + row-gap: 12px; + width: 100%; + height: 100%; + min-height: 0; +} + +.record-create-layout__left, +.record-create-layout__right { + display: flex; + min-width: 0; + min-height: 0; + flex-direction: column; +} + +.record-create-layout__left { + gap: 22px; + padding: 20px 0 2px; +} + +.record-create-layout__right { + gap: 22px; + padding: 20px 0 2px; +} + +.record-create-layout .record-date { + min-height: clamp(52px, 4.2vw, 66px); + flex: 0 0 clamp(52px, 4.2vw, 66px); + justify-content: center; +} + +.record-create-layout .photo-box { + width: min(100%, 350px); + min-height: 0; + aspect-ratio: 3 / 4; + align-self: center; +} + +.record-create-layout #record-photo-preview { + width: 100%; + height: 100%; + object-fit: cover; +} + +.record-create-layout .emotion-picker { + grid-column: 1 / -1; + width: 100%; + margin-top: 0; +} + +.record-create-layout .emotion-picker__options { + grid-template-columns: repeat(10, minmax(38px, 1fr)); + gap: 2px 6px; +} + +.record-create-layout .emotion-icon { + width: clamp(44px, 3.4vw, 60px); + height: clamp(39px, 3vw, 52px); +} + +.record-create-layout .weather-picker { + width: 100%; + align-self: stretch; + justify-self: auto; + justify-content: flex-end; + padding-right: 0; +} + +.record-create-layout .weather-picker__options { + justify-content: flex-end; + flex-wrap: wrap; +} + +.record-create-layout .weather-icon { + width: clamp(52px, 4.2vw, 66px); + height: clamp(52px, 4.2vw, 66px); +} + +.record-create-layout .diary-field { + flex: 1; + min-height: 300px; +} + +.record-create-layout .diary-field textarea { + min-height: 100%; + resize: none; +} + +.record-create-layout .record-paper__footer { + grid-column: 1 / -1; + min-height: 50px; + margin-top: auto; +} + +.record-modal--fullscreen .record-create-layout { + width: min(1500px, 100%); + margin: 0 auto; + grid-template-columns: minmax(300px, 0.75fr) minmax(520px, 1.6fr); +} + +.record-modal--fullscreen .record-create-layout__left { + width: min(100%, 390px); + justify-self: center; +} + +.record-modal--fullscreen .record-create-layout .photo-box { + width: 100%; + max-height: calc(100vh - 260px); +} + +body.record-modal-open { + overflow: hidden; +} + +.record-page-shell { + min-height: calc(100vh - 90px); + padding: 56px 24px; + background: var(--record-page); +} + +.record-page-card { + width: min(720px, 100%); + padding: 38px; + margin: 0 auto; + border: 1.5px solid var(--record-ink); + border-radius: 24px; + background: var(--record-paper); +} + +.record-page-form, +.record-page-field { + display: grid; + gap: 8px; +} + +.record-page-form { + gap: 20px; +} + +.record-page-field input, +.record-page-field select, +.record-page-field textarea { + width: 100%; + padding: 11px 12px; + border: 1px solid var(--record-line); + border-radius: 10px; + background: #fffff4; + font: inherit; +} + +.record-page-error, +.record-page-errors { + color: #b95a52; +} + +.record-page-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 16px; +} + +.record-detail-shell { + min-height: calc(100vh - 90px); + padding: 48px 24px; + background: var(--record-page); +} + +.record-detail-card { + width: min(1180px, 100%); + padding: clamp(28px, 4vw, 56px); + margin: 0 auto; + border: 1.5px solid var(--record-ink); + border-radius: 30px; + color: var(--record-ink); + background: var(--record-paper); +} + +.record-detail-header, +.record-detail-actions, +.record-delete-card form { + display: flex; + align-items: center; +} + +.record-delete-overlay { + position: fixed; + inset: 0; + z-index: 1200; + display: grid; + padding: 24px; + place-items: center; + background: rgba(67, 62, 57, 0.58); + backdrop-filter: blur(2px); +} + +.record-delete-overlay[hidden] { + display: none; +} + +.record-delete-modal { + width: min(720px, calc(100vw - 48px)); + padding: clamp(46px, 6vw, 72px); + border: 2px solid var(--record-ink); + border-radius: 32px; + color: var(--record-ink); + text-align: center; + background: var(--record-paper); + box-shadow: 0 22px 65px rgba(38, 30, 24, 0.28); +} + +.record-delete-modal h2 { + margin: 0; + font-size: clamp(1.8rem, 3vw, 2.55rem); +} + +.record-delete-modal p { + margin: 28px 0 0; + font-size: clamp(1rem, 1.5vw, 1.25rem); +} + +.record-delete-modal form { + display: flex; + justify-content: center; + gap: 20px; + margin-top: 36px; +} + +.record-delete-modal button { + min-width: 112px; + font: inherit; +} + +body.record-delete-modal-open { + overflow: hidden; +} + +.record-detail-header { + justify-content: space-between; + padding-bottom: 18px; + margin-bottom: 28px; + border-bottom: 1px dashed rgba(141, 121, 106, 0.38); +} + +.record-detail-header p { + margin: 0 0 6px; + font-size: 1.25rem; + font-weight: 800; +} + +.record-detail-heading { + display: grid; + gap: 8px; +} + +.record-detail-place { + width: fit-content; + padding: 6px 11px; + border-radius: 999px; + color: #795d48; + background: #ffe9d4; + font-size: 0.82rem; +} + +.record-detail-weather-wrap { + display: flex; + align-items: center; + gap: 10px; + color: var(--record-muted); + font-size: 0.76rem; + font-weight: 700; +} + +.record-detail-weather { + width: 76px; + height: 76px; + object-fit: contain; +} + +.record-detail-layout { + display: grid; + grid-template-columns: minmax(260px, 0.8fr) minmax(360px, 1.4fr); + gap: clamp(38px, 5vw, 70px); + align-items: stretch; +} + +.record-detail-photo { + position: relative; + display: grid; + aspect-ratio: 3 / 4; + overflow: hidden; + place-items: center; + border: 1.5px solid var(--record-ink); + border-radius: 12px; + color: var(--record-muted); +} + +.record-detail-photo img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.record-detail-content { + min-height: 100%; + padding: 4px 12px; + background-image: repeating-linear-gradient( + to bottom, + transparent 0, + transparent 34px, + var(--record-line) 35px, + transparent 36px + ); + font-size: 1rem; + line-height: 36px; +} + +.record-detail-emotions { + display: flex; + min-width: 0; + align-items: center; + gap: 24px; +} + +.record-detail-emotions-heading h2 { + margin: 0 0 4px; + font-size: 1rem; +} + +.record-detail-emotions-heading span { + color: var(--record-muted); + font-size: 0.72rem; +} + +.record-detail-emotion-list { + display: flex; + align-items: flex-end; + gap: 12px; +} + +.record-detail-emotion { + position: relative; + display: grid; + width: 68px; + height: 68px; + margin: 0; + place-items: center; + border-radius: 50%; + background: #fff4e5; + box-shadow: 0 4px 12px rgba(91, 67, 49, 0.11); +} + +.record-detail-emotion img { + width: 58px; + height: 52px; + object-fit: contain; +} + +.record-detail-emotion--main { + width: 84px; + height: 84px; + border: 3px solid #e7a976; + background: #ffe8ce; + box-shadow: 0 0 0 4px rgba(255, 231, 208, 0.78), 0 5px 14px rgba(91, 67, 49, 0.14); +} + +.record-detail-emotion--main img { + width: 70px; + height: 62px; +} + +.record-detail-emotion--main > span { + position: absolute; + top: -13px; + right: -8px; + padding: 3px 7px; + border: 1px solid #d9925e; + border-radius: 999px; + color: #7d4b2d; + background: #fff0c9; + font-size: 0.64rem; + font-weight: 800; +} + +.record-detail-footer { + display: flex; + min-height: 108px; + align-items: center; + justify-content: space-between; + gap: 28px; + padding-top: 24px; + margin-top: 26px; + border-top: 1px dashed rgba(141, 121, 106, 0.38); +} + +.record-detail-actions { + justify-content: flex-end; + gap: 12px; + margin-top: 0; +} + +.record-secondary-button, +.record-danger-button { + padding: 10px 18px; + border: 1px solid var(--record-ink); + border-radius: 11px; + color: var(--record-ink); + background: var(--record-accent); + font: inherit; + text-decoration: none; + cursor: pointer; +} + +.record-danger-button { + color: #8f3932; + background: #ffe1dc; +} + +.record-cancel-button { + padding: 10px 18px; + border: 1px solid var(--record-ink); + border-radius: 11px; + color: var(--record-ink); + background: var(--record-accent); + font-weight: 400; + text-decoration: none; +} + +.record-delete-card { + width: min(520px, 100%); + padding: 42px; + margin: 10vh auto 0; + border: 1.5px solid var(--record-ink); + border-radius: 24px; + text-align: center; + background: var(--record-paper); +} + +.record-delete-card form { + justify-content: center; + gap: 18px; + margin-top: 28px; +} + +@media (max-width: 760px) { + .record-detail-layout { + grid-template-columns: 1fr; + } + + .record-detail-photo { + width: min(100%, 360px); + margin: 0 auto; + } + + .record-detail-content { + min-height: 280px; + } + + .record-detail-footer, + .record-detail-emotions { + align-items: flex-start; + flex-direction: column; + } + + .record-detail-actions { + align-self: flex-end; + } +} + +.site-messages { + position: fixed; + top: 84px; + left: 50%; + z-index: 1100; + transform: translateX(-50%); +} + +.site-messages p { + padding: 10px 18px; + border-radius: 999px; + color: var(--record-ink); + background: var(--record-accent); + box-shadow: 0 6px 20px rgba(70, 55, 45, 0.16); +} + +/* 기록 수정 페이지: 상세 페이지와 동일한 그림일기 배치 */ +.record-edit-shell, +.record-edit-shell * { + box-sizing: border-box; +} + +.record-edit-shell { + width: 100%; + min-height: 100vh; + padding: + clamp(64px, 7vh, 88px) + clamp(72px, 8vw, 164px) + clamp(52px, 6vh, 76px); + color: var(--record-ink); + background: var(--record-paper); +} + +.record-edit-paper { + display: grid; + grid-template-rows: auto auto auto; + row-gap: 12px; + width: min(1500px, 100%); + min-height: calc(100vh - clamp(116px, 13vh, 164px)); + margin: 0 auto; +} + +.record-edit-main { + display: grid; + grid-template-columns: minmax(290px, 0.72fr) minmax(520px, 1.65fr); + gap: clamp(44px, 5vw, 90px); + align-items: stretch; + min-height: 0; +} + +.record-edit-left, +.record-edit-right { + display: flex; + min-width: 0; + flex-direction: column; + gap: 22px; +} + +.record-edit-left { + width: min(100%, 390px); + justify-self: center; +} + +.record-edit-left > time { + display: flex; + min-height: 66px; + align-items: center; + font-size: 1.15rem; + font-weight: 800; +} + +.record-edit-photo { + position: relative; + display: grid; + width: 100%; + max-height: calc(100vh - 390px); + aspect-ratio: 3 / 4; + overflow: hidden; + place-items: center; + align-self: center; + border: 2px solid var(--record-ink); + border-radius: 9px; + background: rgba(255, 255, 244, 0.55); +} + +.record-edit-photo > img:first-child { + width: 100%; + height: 100%; + object-fit: cover; +} + +.record-edit-photo__empty { + display: grid; + justify-items: center; + gap: 22px; + color: var(--record-muted); + text-align: center; +} + +.record-edit-photo__empty[hidden] { + display: none; +} + +.record-edit-photo__empty img { + width: 42px; + height: 42px; + object-fit: contain; +} + +.record-edit-photo__button { + position: absolute; + right: 12px; + bottom: 12px; + padding: 9px 13px; + border: 1px solid var(--record-ink); + border-radius: 10px; + background: rgba(255, 249, 235, 0.94); + font-weight: 700; + cursor: pointer; +} + +.record-edit-photo__button input { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; +} + +.record-edit-clear-photo { + align-self: center; + color: var(--record-muted); + font-size: 0.82rem; +} + +.record-edit-weather { + display: flex; + min-height: 66px; + align-items: center; + justify-content: flex-end; + gap: 4px; + padding: 0; + border: 0; +} + +.record-edit-diary { + display: block; + flex: 1; + min-height: 0; +} + +.record-edit-diary textarea { + width: 100%; + height: 100%; + min-height: 100%; + padding: 3px 12px 0; + resize: none; + border: 0; + outline: 0; + color: var(--record-ink); + background-color: transparent; + background-image: repeating-linear-gradient( + to bottom, + transparent 0, + transparent 34px, + var(--record-line) 35px, + transparent 36px + ); + font: inherit; + font-size: 1rem; + line-height: 36px; +} + +.record-edit-emotions { + margin-top: 0; +} + +.record-edit-emotion-grid { + display: grid; + grid-template-columns: repeat(10, minmax(48px, 1fr)); + gap: 4px 8px; + margin-top: 8px; +} + +.record-edit-emotion-grid .emotion-icon { + width: clamp(52px, 4vw, 68px); + height: clamp(46px, 3.4vw, 58px); +} + +.record-edit-actions { + display: flex; + min-height: 62px; + align-items: center; + justify-content: flex-end; + gap: 18px; + margin-top: 0; +} + +body.record-edit-open .site-header, +body.record-edit-open .bottom-nav { + display: none; +} + +body.record-edit-open .site-main { + padding-top: 0; +} + +.record-edit-actions a { + color: var(--record-muted); + font-weight: 700; + text-decoration: none; +} + +@media (max-width: 900px) { + .record-edit-shell { + padding: 32px 22px 40px; + } + + .record-edit-paper { + display: block; + height: auto; + min-height: 0; + } + + .record-edit-main { + grid-template-columns: 1fr; + } + + .record-edit-photo { + width: min(100%, 360px); + } + + .record-edit-weather { + justify-content: center; + flex-wrap: wrap; + } + + .record-edit-diary { + min-height: 300px; + } + + .record-edit-emotion-grid { + grid-template-columns: repeat(5, 1fr); + } +} diff --git a/records/static/records/images/emotions/emotion-01.png b/records/static/records/images/emotions/emotion-01.png new file mode 100644 index 0000000..8dbb16d Binary files /dev/null and b/records/static/records/images/emotions/emotion-01.png differ diff --git a/records/static/records/images/emotions/emotion-02.png b/records/static/records/images/emotions/emotion-02.png new file mode 100644 index 0000000..4c4171e Binary files /dev/null and b/records/static/records/images/emotions/emotion-02.png differ diff --git a/records/static/records/images/emotions/emotion-03.png b/records/static/records/images/emotions/emotion-03.png new file mode 100644 index 0000000..b714ea8 Binary files /dev/null and b/records/static/records/images/emotions/emotion-03.png differ diff --git a/records/static/records/images/emotions/emotion-04.png b/records/static/records/images/emotions/emotion-04.png new file mode 100644 index 0000000..ddbcf0b Binary files /dev/null and b/records/static/records/images/emotions/emotion-04.png differ diff --git a/records/static/records/images/emotions/emotion-05.png b/records/static/records/images/emotions/emotion-05.png new file mode 100644 index 0000000..f01dad3 Binary files /dev/null and b/records/static/records/images/emotions/emotion-05.png differ diff --git a/records/static/records/images/emotions/emotion-06.png b/records/static/records/images/emotions/emotion-06.png new file mode 100644 index 0000000..cf88433 Binary files /dev/null and b/records/static/records/images/emotions/emotion-06.png differ diff --git a/records/static/records/images/emotions/emotion-07.png b/records/static/records/images/emotions/emotion-07.png new file mode 100644 index 0000000..8b4165f Binary files /dev/null and b/records/static/records/images/emotions/emotion-07.png differ diff --git a/records/static/records/images/emotions/emotion-08.png b/records/static/records/images/emotions/emotion-08.png new file mode 100644 index 0000000..7c92cdf Binary files /dev/null and b/records/static/records/images/emotions/emotion-08.png differ diff --git a/records/static/records/images/emotions/emotion-09.png b/records/static/records/images/emotions/emotion-09.png new file mode 100644 index 0000000..518d1d0 Binary files /dev/null and b/records/static/records/images/emotions/emotion-09.png differ diff --git a/records/static/records/images/emotions/emotion-10.png b/records/static/records/images/emotions/emotion-10.png new file mode 100644 index 0000000..4650c20 Binary files /dev/null and b/records/static/records/images/emotions/emotion-10.png differ diff --git a/records/static/records/images/emotions/emotion-11.png b/records/static/records/images/emotions/emotion-11.png new file mode 100644 index 0000000..c719f22 Binary files /dev/null and b/records/static/records/images/emotions/emotion-11.png differ diff --git a/records/static/records/images/emotions/emotion-12.png b/records/static/records/images/emotions/emotion-12.png new file mode 100644 index 0000000..c8f7961 Binary files /dev/null and b/records/static/records/images/emotions/emotion-12.png differ diff --git a/records/static/records/images/emotions/emotion-13.png b/records/static/records/images/emotions/emotion-13.png new file mode 100644 index 0000000..21f2f91 Binary files /dev/null and b/records/static/records/images/emotions/emotion-13.png differ diff --git a/records/static/records/images/emotions/emotion-14.png b/records/static/records/images/emotions/emotion-14.png new file mode 100644 index 0000000..b8aaa8d Binary files /dev/null and b/records/static/records/images/emotions/emotion-14.png differ diff --git a/records/static/records/images/emotions/emotion-15.png b/records/static/records/images/emotions/emotion-15.png new file mode 100644 index 0000000..925e477 Binary files /dev/null and b/records/static/records/images/emotions/emotion-15.png differ diff --git a/records/static/records/images/emotions/emotion-16.png b/records/static/records/images/emotions/emotion-16.png new file mode 100644 index 0000000..7c597cb Binary files /dev/null and b/records/static/records/images/emotions/emotion-16.png differ diff --git a/records/static/records/images/emotions/emotion-17.png b/records/static/records/images/emotions/emotion-17.png new file mode 100644 index 0000000..ce1b43c Binary files /dev/null and b/records/static/records/images/emotions/emotion-17.png differ diff --git a/records/static/records/images/emotions/emotion-18.png b/records/static/records/images/emotions/emotion-18.png new file mode 100644 index 0000000..2480d45 Binary files /dev/null and b/records/static/records/images/emotions/emotion-18.png differ diff --git a/records/static/records/images/emotions/emotion-19.png b/records/static/records/images/emotions/emotion-19.png new file mode 100644 index 0000000..0bf1cc8 Binary files /dev/null and b/records/static/records/images/emotions/emotion-19.png differ diff --git a/records/static/records/images/emotions/emotion-20.png b/records/static/records/images/emotions/emotion-20.png new file mode 100644 index 0000000..99d3501 Binary files /dev/null and b/records/static/records/images/emotions/emotion-20.png differ diff --git a/records/static/records/images/main-character-writing.png b/records/static/records/images/main-character-writing.png new file mode 100644 index 0000000..3fadf79 Binary files /dev/null and b/records/static/records/images/main-character-writing.png differ diff --git a/records/static/records/images/photo-actions/gallery.png b/records/static/records/images/photo-actions/gallery.png new file mode 100644 index 0000000..b0fc2c2 Binary files /dev/null and b/records/static/records/images/photo-actions/gallery.png differ diff --git a/records/static/records/images/weather/cloudy.png b/records/static/records/images/weather/cloudy.png new file mode 100644 index 0000000..28ad7a5 Binary files /dev/null and b/records/static/records/images/weather/cloudy.png differ diff --git a/records/static/records/images/weather/dust.png b/records/static/records/images/weather/dust.png new file mode 100644 index 0000000..56d2988 Binary files /dev/null and b/records/static/records/images/weather/dust.png differ diff --git a/records/static/records/images/weather/rainy.png b/records/static/records/images/weather/rainy.png new file mode 100644 index 0000000..02cee83 Binary files /dev/null and b/records/static/records/images/weather/rainy.png differ diff --git a/records/static/records/images/weather/snowy.png b/records/static/records/images/weather/snowy.png new file mode 100644 index 0000000..f18c413 Binary files /dev/null and b/records/static/records/images/weather/snowy.png differ diff --git a/records/static/records/images/weather/sunny.png b/records/static/records/images/weather/sunny.png new file mode 100644 index 0000000..aca3999 Binary files /dev/null and b/records/static/records/images/weather/sunny.png differ diff --git a/records/static/records/images/weather/thunder.png b/records/static/records/images/weather/thunder.png new file mode 100644 index 0000000..37e1d65 Binary files /dev/null and b/records/static/records/images/weather/thunder.png differ diff --git a/records/static/records/js/record_detail.js b/records/static/records/js/record_detail.js new file mode 100644 index 0000000..01ee9c5 --- /dev/null +++ b/records/static/records/js/record_detail.js @@ -0,0 +1,27 @@ +(() => { + const overlay = document.querySelector("#record-delete-overlay"); + const openButton = document.querySelector("#open-record-delete-modal"); + const closeButton = document.querySelector("#close-record-delete-modal"); + if (!overlay || !openButton || !closeButton) return; + + const openModal = () => { + overlay.hidden = false; + document.body.classList.add("record-delete-modal-open"); + closeButton.focus(); + }; + + const closeModal = () => { + overlay.hidden = true; + document.body.classList.remove("record-delete-modal-open"); + openButton.focus(); + }; + + openButton.addEventListener("click", openModal); + closeButton.addEventListener("click", closeModal); + overlay.addEventListener("click", (event) => { + if (event.target === overlay) closeModal(); + }); + document.addEventListener("keydown", (event) => { + if (event.key === "Escape" && !overlay.hidden) closeModal(); + }); +})(); diff --git a/records/static/records/js/record_edit.js b/records/static/records/js/record_edit.js new file mode 100644 index 0000000..79c9520 --- /dev/null +++ b/records/static/records/js/record_edit.js @@ -0,0 +1,97 @@ +(() => { + const form = document.querySelector(".record-edit-paper"); + if (!form) return; + + document.body.classList.add("record-edit-open"); + + const emotionInputs = [...form.querySelectorAll('input[name="emotions"]')]; + const emotionCount = form.querySelector("#record-edit-emotion-count"); + const mainEmotionInput = form.querySelector("#record-edit-main-emotion"); + const message = form.querySelector("#record-edit-message"); + const imageInput = form.querySelector("#record-edit-image-input"); + const preview = form.querySelector("#record-edit-preview"); + const emptyState = form.querySelector("#record-edit-photo-empty"); + let previewUrl = null; + let showRequiredMessage = false; + + const getMissingLabels = () => { + const weather = form.querySelector('input[name="weather"]:checked'); + const content = form.querySelector('textarea[name="content"]'); + const emotions = emotionInputs.filter((input) => input.checked); + const hasImage = Boolean( + emptyState?.hidden || imageInput?.files.length + ); + const missing = []; + if (!hasImage) missing.push("사진"); + if (!weather) missing.push("날씨"); + if (!content?.value.trim()) missing.push("오늘의 마음"); + if (!emotions.length) missing.push("감정"); + return missing; + }; + + const updateRequiredMessage = () => { + const missing = getMissingLabels(); + if (showRequiredMessage) { + message.textContent = missing.length + ? `${missing.join(" · ")} 입력이 필요해요.` + : ""; + } + return missing.length === 0; + }; + + const updateEmotions = () => { + const selected = emotionInputs.filter((input) => input.checked); + if (mainEmotionInput && !mainEmotionInput.value && selected.length) { + mainEmotionInput.value = selected[0].value; + } + emotionCount.textContent = `${selected.length} / 3`; + emotionInputs.forEach((input) => { + input.disabled = selected.length >= 3 && !input.checked; + }); + message.textContent = selected.length >= 3 + ? "감정은 최대 3개까지 선택할 수 있어요." + : ""; + emotionInputs.forEach((input) => { + input.closest(".emotion-option")?.classList.toggle( + "emotion-option--main", + input.checked && input.value === mainEmotionInput?.value, + ); + }); + }; + + emotionInputs.forEach((input) => input.addEventListener("change", () => { + if (mainEmotionInput) { + if (input.checked && !mainEmotionInput.value) { + mainEmotionInput.value = input.value; + } else if (!input.checked && mainEmotionInput.value === input.value) { + mainEmotionInput.value = emotionInputs.find( + (candidate) => candidate.checked + )?.value || ""; + } + } + updateEmotions(); + updateRequiredMessage(); + })); + updateEmotions(); + + imageInput?.addEventListener("change", () => { + const file = imageInput.files[0]; + if (!file?.type.startsWith("image/")) return; + if (previewUrl) URL.revokeObjectURL(previewUrl); + previewUrl = URL.createObjectURL(file); + preview.src = previewUrl; + preview.hidden = false; + emptyState.hidden = true; + updateRequiredMessage(); + }); + + form.addEventListener("submit", (event) => { + showRequiredMessage = true; + if (!updateRequiredMessage()) { + event.preventDefault(); + } + }); + + form.querySelectorAll('input[name="weather"], textarea[name="content"]') + .forEach((field) => field.addEventListener("input", updateRequiredMessage)); +})(); diff --git a/records/static/records/js/records.js b/records/static/records/js/records.js index d7bfcad..62b7ceb 100644 --- a/records/static/records/js/records.js +++ b/records/static/records/js/records.js @@ -1,2 +1,189 @@ -// 담당 C 전용 +(() => { + const overlay = document.querySelector("[data-record-modal]"); + if (!overlay) return; + const modal = overlay.querySelector(".record-modal"); + const openButtons = document.querySelectorAll( + "#open-record-modal, [data-open-record-modal]" + ); + const expandButton = document.querySelector("#record-modal-expand"); + const form = document.querySelector("#record-create-form"); + const today = document.querySelector("#record-today"); + const emotionInputs = [ + ...document.querySelectorAll('input[name="emotions"]'), + ]; + const emotionCount = document.querySelector("#emotion-count"); + const mainEmotionInput = document.querySelector("#main-emotion"); + const fileInputs = [ + ...document.querySelectorAll( + 'input[name="image"]' + ), + ]; + const photoPreview = document.querySelector("#record-photo-preview"); + const photoEmpty = document.querySelector("#record-photo-empty"); + const photoRemove = document.querySelector("#record-photo-remove"); + const message = document.querySelector("#record-form-message"); + let lastFocusedElement = null; + let previewUrl = null; + let showRequiredMessage = false; + + const setToday = () => { + if (!today) return; + today.textContent = new Intl.DateTimeFormat("ko-KR", { + year: "numeric", + month: "long", + day: "numeric", + weekday: "long", + }).format(new Date()); + }; + + const openModal = (event) => { + event?.preventDefault(); + lastFocusedElement = document.activeElement; + overlay.hidden = false; + document.body.classList.add("record-modal-open"); + expandButton?.focus(); + }; + + const closeModal = () => { + overlay.hidden = true; + modal?.classList.remove("record-modal--fullscreen"); + expandButton?.setAttribute("aria-pressed", "false"); + document.body.classList.remove("record-modal-open"); + showRequiredMessage = false; + message.textContent = ""; + lastFocusedElement?.focus(); + }; + + const toggleFullscreen = () => { + const isFullscreen = modal.classList.toggle( + "record-modal--fullscreen" + ); + expandButton.setAttribute("aria-pressed", String(isFullscreen)); + expandButton.title = isFullscreen ? "창 크기로 보기" : "전체 화면"; + }; + + const updateEmotionState = () => { + const selected = emotionInputs.filter((input) => input.checked); + if (mainEmotionInput && !mainEmotionInput.value && selected.length) { + mainEmotionInput.value = selected[0].value; + } + emotionCount.textContent = `${selected.length} / 3`; + emotionInputs.forEach((input) => { + input.disabled = selected.length >= 3 && !input.checked; + }); + message.textContent = + selected.length >= 3 ? "감정은 최대 3개까지 선택할 수 있어요." : ""; + emotionInputs.forEach((input) => { + input.closest(".emotion-option")?.classList.toggle( + "emotion-option--main", + input.checked && input.value === mainEmotionInput?.value, + ); + }); + }; + + const showPhoto = (file) => { + if (!file?.type.startsWith("image/")) return; + if (previewUrl) URL.revokeObjectURL(previewUrl); + previewUrl = URL.createObjectURL(file); + photoPreview.src = previewUrl; + photoPreview.hidden = false; + photoEmpty.hidden = true; + photoRemove.hidden = false; + }; + + const clearPhoto = () => { + if (previewUrl) URL.revokeObjectURL(previewUrl); + previewUrl = null; + photoPreview.removeAttribute("src"); + photoPreview.hidden = true; + photoEmpty.hidden = false; + photoRemove.hidden = true; + fileInputs.forEach((input) => { + input.value = ""; + }); + }; + + const getCompletionState = () => ({ + image: fileInputs.some((input) => input.files.length > 0), + weather: Boolean(form?.querySelector('input[name="weather"]:checked')), + content: Boolean(form?.querySelector('textarea[name="content"]')?.value.trim()), + emotions: emotionInputs.some((input) => input.checked), + }); + + const updateRequiredMessage = () => { + const state = getCompletionState(); + const labels = { + image: "사진", + weather: "날씨", + content: "오늘의 마음", + emotions: "감정", + }; + const missing = Object.entries(state) + .filter(([, isComplete]) => !isComplete) + .map(([name]) => labels[name]); + if (showRequiredMessage) { + message.textContent = missing.length + ? `${missing.join(" · ")} 입력이 필요해요.` + : ""; + } + return missing.length === 0; + }; + + openButtons.forEach((button) => + button.addEventListener("click", openModal) + ); + expandButton?.addEventListener("click", toggleFullscreen); + emotionInputs.forEach((input) => + input.addEventListener("change", () => { + if (mainEmotionInput) { + if (input.checked && !mainEmotionInput.value) { + mainEmotionInput.value = input.value; + } else if (!input.checked && mainEmotionInput.value === input.value) { + mainEmotionInput.value = emotionInputs.find( + (candidate) => candidate.checked + )?.value || ""; + } + } + updateEmotionState(); + updateRequiredMessage(); + }) + ); + fileInputs.forEach((input) => + input.addEventListener("change", () => { + showPhoto(input.files[0]); + updateRequiredMessage(); + }) + ); + photoRemove?.addEventListener("click", () => { + clearPhoto(); + updateRequiredMessage(); + }); + + overlay.addEventListener("click", (event) => { + if (event.target === overlay) closeModal(); + }); + + document.addEventListener("keydown", (event) => { + if (event.key === "Escape" && !overlay.hidden) closeModal(); + }); + + form?.addEventListener("submit", (event) => { + showRequiredMessage = true; + if (!updateRequiredMessage()) { + event.preventDefault(); + } + }); + + form?.querySelectorAll('input[name="weather"], textarea[name="content"]') + .forEach((field) => field.addEventListener("input", () => { + updateRequiredMessage(); + })); + + setToday(); + updateEmotionState(); + + if (document.querySelector("[data-record-preview]")) { + openModal(); + } +})(); diff --git a/records/templates/records/partials/record_modal.html b/records/templates/records/partials/record_modal.html index d3d076e..e9293b0 100644 --- a/records/templates/records/partials/record_modal.html +++ b/records/templates/records/partials/record_modal.html @@ -1,2 +1,181 @@ - +{% load static %} +
diff --git a/records/templates/records/record_confirm_delete.html b/records/templates/records/record_confirm_delete.html index 72c82a4..da4933b 100644 --- a/records/templates/records/record_confirm_delete.html +++ b/records/templates/records/record_confirm_delete.html @@ -1,5 +1,22 @@ {% extends "base.html" %} -{% block content %} - +{% load static %} + +{% block title %}기록 삭제 | 마음자국{% endblock %} + +{% block extra_css %} + {% endblock %} +{% block content %} +{{ record.created_at|date:"Y년 n월 j일" }}의 기록은 삭제 후 복구할 수 없어요.
+ +{{ record.created_at|date:"Y년 n월 j일 l" }}
+ {% if record.place_name %} + 📍 {{ record.place_name }} + {% endif %} +등록된 사진이 없어요.
+ {% endif %} +메인페이지가 완성되면 이 영역이 실제 메인 화면으로 교체됩니다.
+ +