프로젝트

[ bible cash ] 각 계층별 코드

do_it_zero 2025. 1. 17. 18:00

config

package study.demobible.config;

import jakarta.servlet.Filter;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import study.demobible.config.filter.SessionFilter;

@Configuration
public class FilterConfig {
    @Bean
    public FilterRegistrationBean<SessionFilter> sessionFilter(){
        FilterRegistrationBean<SessionFilter> registrationBean = new FilterRegistrationBean<>();
        registrationBean.setFilter(new SessionFilter());
        registrationBean.addUrlPatterns("/home","/history","/contents");
        return registrationBean;
    }
}
package study.demobible.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;

@Configuration
@EnableScheduling
public class SchedulingConfig {
}
package study.demobible.config.filter;

import jakarta.servlet.*;
import jakarta.servlet.annotation.WebFilter;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import study.demobible.util.MemberContext;

import java.io.IOException;

public class SessionFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        HttpServletResponse httpResponse = (HttpServletResponse) response;

        // 만약 만료되었으면 session은 null
        HttpSession session = httpRequest.getSession(false);

        if(session == null){
            httpResponse.sendRedirect(httpRequest.getContextPath() + "/");
            return;
        }

        long lastAccessedTime = session.getLastAccessedTime();
        long currentTime = System.currentTimeMillis();

        // 세션 생성시간과 현재 시간을 비교해서 timeout보다 클 경우 세션을 만료시킨다.
        long timeout = 10000;
        if(currentTime - lastAccessedTime > timeout){
            session.invalidate();
            httpResponse.sendRedirect(httpRequest.getContextPath() + "/");
            return;
        }
        
        chain.doFilter(request,response);
    }
}

 

controller

package study.demobible.controller;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import study.demobible.dto.ContentDto;
import study.demobible.entity.Content;
import study.demobible.service.ContentService;

import java.time.LocalDate;
import java.util.List;

@Slf4j
@Controller
@RequiredArgsConstructor
public class ContentController {

    private final ContentService contentService;

    @GetMapping("/contents")
    public String getContents(@RequestParam("year") int year,
                              @RequestParam("month") int month,
                              @RequestParam("date") int date,
                              Model model){
        log.info("Date: {}-{}-{}", year, month, date);
        /**
         * 1. 해당 날짜에 맞는 content를 가져온다.
         * 2. ReadingHistory에서 readingStatus를 가져온다.
         * 3. model에 담아서 전송한다.
         * */
        LocalDate newDate = LocalDate.of(year, month, date);
        List<ContentDto> contentDtoList = contentService.getContent(newDate);
        //log.info("contentList: {}",contentDtoList);
        model.addAttribute("contentDtoList",contentDtoList);
        model.addAttribute("newDate",newDate);
        //contentService.getReadingStatus();
        return "content";
    }
}
package study.demobible.controller;

import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import study.demobible.entity.History;
import study.demobible.entity.Member;
import study.demobible.entity.ReadingStatus;
import study.demobible.repository.HistoryRepository;
import study.demobible.service.HistoryService;
import study.demobible.util.MemberContext;

import java.time.LocalDate;
import java.time.LocalDateTime;

import static study.demobible.entity.ReadingStatus.*;

@Controller
@Slf4j
@RequiredArgsConstructor
public class HistoryController {

    private final HistoryService historyService;

    @GetMapping("/history")
    public String saveHistory(HttpSession session){
        Member member = MemberContext.getMember(session);
        historyService.saveHistory(member);
        return "home";
    }
}
package study.demobible.controller;

import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import study.demobible.entity.History;
import study.demobible.entity.Member;
import study.demobible.repository.HistoryRepository;
import study.demobible.repository.TalentRepository;

import java.util.List;

@Controller
@RequiredArgsConstructor
public class HomeController {

    private final HistoryRepository historyRepository;
    private final TalentRepository talentRepository;

    @GetMapping("/home")
    public String home(HttpSession session, Model model){
        // 읽음 이력 가져오기
        Member member =(Member) session.getAttribute("member");
        List<History> historyList = historyRepository.findByMemberIdx(member.getIdx());
        // member 총합 talent 가져오기

        return "home";
    }
}
package study.demobible.controller;

import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.Banner;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import study.demobible.dto.LoginForm;
import study.demobible.dto.SignUpForm;
import study.demobible.entity.Member;
import study.demobible.repository.MemberRepository;
import study.demobible.util.MemberContext;

@Slf4j
@Controller
@RequiredArgsConstructor
public class MemberController {

    private final MemberRepository memberRepository;

    @GetMapping("/signup")
    public String getSignupPage(){
        return "signup";
    }

    @PostMapping("/signup")
    public String signup(@ModelAttribute SignUpForm signUpForm, Model model,HttpSession session){
        Member member = new Member(signUpForm);
        Member savedMember = memberRepository.save(member);

        // 세션에 멤버 저장 서버에서만 확인 가능 세션은 클라이언트마다 각각임.
        session.setAttribute("member",savedMember);
        model.addAttribute("id",signUpForm.getName());
        return "home";
    }

    @PostMapping("/login")
    public String login(@ModelAttribute LoginForm loginForm, Model model, HttpSession session){
        log.info("loginForm : {}",loginForm);
        Member findMember = memberRepository.findByUserId(loginForm.getUserId());
        session.setAttribute("member",findMember);
        model.addAttribute("id",findMember.getUserId());
        return "home";
    }
}

 

dto

package study.demobible.dto;

import lombok.Data;
import study.demobible.entity.Content;

@Data
public class ContentDto {
    private Long idx;
    private String longLabel;
    private Integer chapter;
    private Integer paragraph;
    private String sentence;

    public ContentDto(Content content){
        this.idx = content.getIdx();
        this.longLabel = content.getLongLabel();
        this.chapter = content.getChapter();
        this.paragraph = content.getParagraph();
        this.sentence = content.getSentence();
    }
}
package study.demobible.dto;

import lombok.Data;

@Data
public class LoginForm {
    private String userId;
}
package study.demobible.dto;

import lombok.Data;
import lombok.ToString;
import study.demobible.entity.Group;

@Data
@ToString
public class SignUpForm {
    private String userId;
    private String name;
    private Group group;

}

 

entity

package study.demobible.entity;


import jakarta.persistence.*;
import lombok.Getter;

@Entity
@Getter
public class Content {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long idx;

    @Column(nullable = false)
    private Integer cate;

    @Column(nullable = false)
    private Integer book;

    @Column(nullable = false)
    private Integer chapter;

    @Column(nullable = false)
    private Integer paragraph;

    @Column(nullable = false, columnDefinition = "TEXT")
    private String sentence;

    @Column(nullable = false, length = 10)
    private String testament;

    @Column(nullable = false, length = 30)
    private String longLabel;

    @Column(nullable = false, length = 10)
    private String shortLabel;
}
package study.demobible.entity;

public enum Group {
    A,B,C
}
package study.demobible.entity;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.random.RandomGenerator;

@Entity
@NoArgsConstructor
@Getter
public class History {
    @Id
    @GeneratedValue
    private Long idx;

    @Column(columnDefinition = "DATE")
    private LocalDate readTime;

    private ReadingStatus readingStatus;

    @ManyToOne
    private Member member;

    @ManyToOne
    private Range range;

    public History(LocalDate readTime, ReadingStatus readingStatus, Member member,Range range) {
        this.readTime = readTime;
        this.readingStatus = readingStatus;
        this.member = member;
        this.range = range;
    }
}
package study.demobible.entity;

import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;
import study.demobible.dto.SignUpForm;
import study.demobible.repository.HistoryRepository;

import java.util.ArrayList;
import java.util.List;

@Entity
@Getter
@ToString
@NoArgsConstructor
public class Member {
    @Id
    @GeneratedValue
    private Long idx;

    @Column(unique = true)
    private String userId;

    private String name;

    @Enumerated(EnumType.STRING)
    @Column(name = "member_group")
    private Group group;

    @OneToMany(mappedBy = "member")
    private List<History> historyList = new ArrayList<>();

    public Member(String userId, String name, Group group) {
        this.userId = userId;
        this.name = name;
        this.group = group;
    }

    public Member(SignUpForm signUpForm){
        this.userId = signUpForm.getUserId();
        this.name = signUpForm.getName();
        this.group = signUpForm.getGroup();
    }

}
package study.demobible.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

import java.time.LocalDate;
import java.time.LocalDateTime;

@Entity
@Getter
@ToString
@NoArgsConstructor
public class Range {
    @Id
    @GeneratedValue
    private Long idx;

    private int startPoint;

    private int endPoint;

    @Column(columnDefinition = "DATE")
    private LocalDate updatedAt;

    public Range(int startPoint, int endPoint, LocalDate updatedAt) {
        this.startPoint = startPoint;
        this.endPoint = endPoint;
        this.updatedAt = updatedAt;
    }
}
package study.demobible.entity;

public enum ReadingStatus {
    READ,NOT_READ
}
package study.demobible.entity;

import jakarta.persistence.*;
import lombok.NoArgsConstructor;

import java.time.LocalDate;

@Entity
@NoArgsConstructor
public class Talent {

    @Id
    @GeneratedValue
    private Long idx;

    @ManyToOne
    private Member member;

    @Column(columnDefinition = "int default 0")
    private int talent;

    @Column(columnDefinition = "DATE")
    private LocalDate updatedAt;

    public Talent(Member member,LocalDate rangeUpdatedAt){
        this.member = member;
        this.updatedAt = updatedAt;
        if(rangeUpdatedAt.isEqual(LocalDate.now())){
            this.talent = 10;
        } else {
            this.talent = 2;
        }

    }
}
package study.demobible.entity;

import java.time.LocalDateTime;

public class TalentHistory {
    private Long idx;
    private Talent talent;
    private LocalDateTime updatedAt;
    private int updatedTalent;
}

exception

package study.demobible.exception;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;

@Slf4j
@Aspect
@Component
public class ClassExceptionHandlerAspect {
    @Pointcut("within(@HandleRuntimeException *)")
    public void classAnnotatedWithHandleException(){}
    
    @Around("classAnnotatedWithHandleException()")
    public Object handleRuntimeException(ProceedingJoinPoint joinPoint){
        try {
            return joinPoint.proceed();
        } catch (Throwable throwable){
            if(throwable instanceof RuntimeException){
                HandleRuntimeException handleRuntimeException = joinPoint.getTarget().getClass().getAnnotation(HandleRuntimeException.class);

                for(Class<? extends RuntimeException> exceptionClass : handleRuntimeException.value()){
                    if(exceptionClass.isInstance(throwable)){
                        log.info("Handle exception: {}",throwable.getClass().getSimpleName());
                        log.info("Message: {}",throwable.getMessage());
                        return null; // 예외 처리 후 기본 반환 값
                    }
                }
            }
            throw new RuntimeException(throwable);
        }
    }
}
package study.demobible.exception;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface HandleRuntimeException {
    Class<? extends RuntimeException>[] value() default {RuntimeException.class};
}

repository

package study.demobible.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import study.demobible.entity.Content;

import java.util.List;

@Repository
public interface ContentRepository extends JpaRepository<Content,Long> {
    List<Content> findByIdxBetween(int startIdx, int endIdx);
}
package study.demobible.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import study.demobible.entity.History;

import java.util.List;

public interface HistoryRepository extends JpaRepository<History,Long> {
    List<History> findByMemberIdx(Long memberIdx);
}
package study.demobible.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import study.demobible.entity.Member;

public interface MemberRepository extends JpaRepository<Member,Long> {
    Member findByUserId(String userId);
}
package study.demobible.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import study.demobible.entity.Range;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Optional;

public interface RangeRepository extends JpaRepository<Range,Long> {

    Range findTopByOrderByUpdatedAtDesc();

    //Optional<Range> findByUpdatedAtBetween(LocalDateTime startOfDay, LocalDateTime endOfDay);

    Optional<Range> findByUpdatedAt(LocalDate dateTime);
}
package study.demobible.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import study.demobible.entity.Talent;

public interface TalentRepository extends JpaRepository<Talent,Long> {

}

 

service

package study.demobible.service.schedul;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import study.demobible.entity.Range;
import study.demobible.repository.RangeRepository;

import java.time.LocalDate;
import java.time.LocalDateTime;

@Slf4j
@Service
@RequiredArgsConstructor
public class RangeScheduler {

    private final RangeRepository rangeRepository;

    @Scheduled(cron = "0 0 0 * * *") // 매일 자정 실행됨
    //@Scheduled(fixedRate = 30000) // 30초마다 실행
    public void updateRange(){
        Range currentRange = rangeRepository.findTopByOrderByUpdatedAtDesc();

        if (currentRange != null) {
            int newStart;
            int newEnd;

            // end 값이 31100인 경우
            if (currentRange.getEndPoint() == 31100) {
                newStart = currentRange.getEndPoint() + 1;
                newEnd = currentRange.getEndPoint() + 38;
            } else {
                // 그 외의 경우
                newStart = currentRange.getEndPoint() + 1;
                newEnd = newStart + 99;
            }

            // 계산된 값으로 Range 객체 생성 및 저장
            Range range = new Range(newStart, newEnd, LocalDate.now());
            rangeRepository.save(range);
            log.info("업데이트 된 range: start = {} end = {}", newStart, newEnd);
        } else {
            // 초기 데이터 생성
            Range initRange = new Range(1, 100, LocalDate.now());
            rangeRepository.save(initRange);
            log.info("초기 데이터 생성: start = 1, end = 100");
        }

    }
}
package study.demobible.service;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import study.demobible.dto.ContentDto;
import study.demobible.entity.Content;
import study.demobible.entity.Range;
import study.demobible.exception.HandleRuntimeException;
import study.demobible.repository.ContentRepository;
import study.demobible.repository.RangeRepository;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;

@Slf4j
@Service
@HandleRuntimeException
@RequiredArgsConstructor
public class ContentService {

    private final ContentRepository contentRepository;
    private final RangeRepository rangeRepository;

    public List<ContentDto> getContent(LocalDate localDate) {
        //  외부 날짜를 LocalDateTime로 변환함.

        //LocalDateTime localDateTime = LocalDateTime.of(year,month,date,0,0);

        //LocalDateTime startOfDay = localDate.atStartOfDay();
        //LocalDateTime endOfDay = localDate.atTime(23, 59, 59, 999999);

        // 해당 날짜에 해당하는 Range를 가져온다.
        //Optional<Range> range = rangeRepository.findByUpdatedAtBetween(startOfDay,endOfDay);
        Optional<Range> range = rangeRepository.findByUpdatedAt(localDate);
        log.info("rage: {}",range);


        // Range에 해당하는 Content들을 가져온 후 ContentDto로 변환
        List<Content> contentList = contentRepository.findByIdxBetween(range.get().getStartPoint(), range.get().getEndPoint());

        List<ContentDto> contentDtoList = contentList.stream()
                .map(ContentDto::new)
                .sorted(Comparator.comparing(ContentDto::getIdx))
                .collect(Collectors.toList());
        return contentDtoList;
    }

}
package study.demobible.service;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import study.demobible.entity.*;
import study.demobible.exception.HandleRuntimeException;
import study.demobible.repository.HistoryRepository;
import study.demobible.repository.RangeRepository;
import study.demobible.repository.TalentRepository;
import study.demobible.util.MemberContext;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Optional;

import static study.demobible.entity.ReadingStatus.*;

@Slf4j
@Service
@Transactional
@HandleRuntimeException
@RequiredArgsConstructor
public class HistoryService {
    private final HistoryRepository historyRepository;
    private final RangeRepository rangeRepository;
    private final TalentRepository talentRepository;

    public void saveHistory(Member member){
        LocalDate readTime = LocalDate.now();
        Optional<Range> optionalRange = rangeRepository.findByUpdatedAt(readTime);
        Range range = optionalRange.orElseThrow(() -> new IllegalStateException("해당 Range는 존재하지 않습니다."));
        
        History history = new History(readTime, READ, member, range);
        History savedHistory = historyRepository.save(history);
        log.info("저장된 기록 : {}",savedHistory);
        
        Talent talent = new Talent(member, readTime);
        Talent savedTalent = talentRepository.save(talent);
        log.info("저장된 달란트 : {}",savedTalent);
    }
}
package study.demobible.service;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import study.demobible.dto.SignUpForm;
import study.demobible.entity.Member;
import study.demobible.repository.MemberRepository;

@Service
@RequiredArgsConstructor
public class MemberService {
    private final MemberRepository memberRepository;

    public void save(SignUpForm signUpForm){
        Member member = new Member(signUpForm);
        memberRepository.save(member);
    }

}

css

/* content style */
body {
    margin: 0;
    font-family: Arial, sans-serif;
    display: flex;
    flex-direction: column;
    align-items: flex-start; /* 왼쪽 정렬로 변경 */
    justify-content: flex-start; /* 왼쪽 상단에 정렬 */
    background-color: #f4f4f4;
}


.calendar-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    width: 100%;
    margin-bottom: 10px;
}

.current-month {
    font-size: 16px;
    font-weight: bold;
    text-align: center;
    flex-grow: 1;
}

.nav-button {
    padding: 5px 15px;
    font-size: 14px;
    color: #fff;
    background-color: #007bff;
    border: none;
    border-radius: 5px;
    cursor: pointer;
    margin: 0 5px;
    transition: background-color 0.3s ease;
}

.nav-button:hover {
    background-color: #0056b3;
}

/* 하단 버튼 스타일 */
.footer {
    text-align: center;
    margin-top: 20px; /* 버튼과 이미지, 텍스트 간의 간격 */
}

/* 기본 스타일 */
body {
    margin: 0;
    font-family: Arial, sans-serif;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    background-color: #f4f4f4;
}

.container {
    max-width: 100%; /* 반응형 크기 */
    padding: 10px;
    box-sizing: border-box;
}

.calendar-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    width: 100%;
    margin-bottom: 10px;
}

.current-month {
    font-size: 16px;
    font-weight: bold;
    text-align: center;
    flex-grow: 1;
}

.nav-button {
    padding: 5px 15px;
    font-size: 14px;
    color: #fff;
    background-color: #007bff;
    border: none;
    border-radius: 5px;
    cursor: pointer;
    margin: 0 5px;
    transition: background-color 0.3s ease;
}

.nav-button:hover {
    background-color: #0056b3;
}

#daysOfWeek {
    display: grid;
    grid-template-columns: repeat(7, 1fr);
    text-align: center;
    font-size: 14px;
    font-weight: bold;
    margin-bottom: 5px;
}

.day-name {
    padding: 5px;
    color: #555;
}

#calendar {
    display: grid;
    grid-template-columns: repeat(7, 1fr);
    gap: 5px;
    padding: 10px;
    background: #ffffff;
    border-radius: 10px;
    box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}

.calendar-day {
    text-align: center;
    padding: 10px;
    border-radius: 5px;
    font-size: 14px;
    position: relative;
    background-color: #fafafa;
    cursor: pointer;
    transition: background-color 0.3s ease;
}

.calendar-day:hover {
    background-color: #e6f7ff;
}

.calendar-day.disabled {
    color: #ccc;
    pointer-events: none;
}

.calendar-day.today {
    background-color: #ffe6e6;
}

.calendar-day.selected {
    background-color: #b3d9ff;
}

.calendar-day.today .dot {
    width: 5px;
    height: 5px;
    background-color: red;
    border-radius: 50%;
    position: absolute;
    bottom: 5px;
    left: 50%;
    transform: translateX(-50%);
}

/* 하단 버튼 스타일 */
.footer {
    text-align: center;
    margin-top: 20px; /* 버튼과 이미지, 텍스트 간의 간격 */
}


.verse-button {
    padding: 10px 20px;
    font-size: 16px;
    color: #fff;
    background-color: #28a745;
    border: none;
    border-radius: 5px;
    cursor: pointer;
    transition: background-color 0.3s ease;
}

.verse-button:hover {
    background-color: #218838;
}

/* 동전 이미지 및 달란트 스타일 */
.coin-info {
    display: flex;
    justify-content: center;
    align-items: center;
    margin-top: 10px; /* '오늘의 말씀' 버튼과 동전 이미지 사이 간격 */
}

.coin-info img {
    width: 1em; /* 동전 이미지 크기를 텍스트 크기에 맞게 설정 */
    height: 1em;
    margin-right: 5px; /* 텍스트와 이미지 사이 간격 */
}

.coin-text, .coin-amount {
    font-size: 1em;
}

.left-aligned {
    text-align: left;
    margin-left: 0;
    margin-bottom: 10px;
}


/* 반응형 디자인 */
@media (max-width: 600px) {
    #calendar {
        gap: 3px;
    }

    .calendar-day {
        padding: 8px;
        font-size: 12px;
    }

    .nav-button {
        padding: 4px 10px;
        font-size: 12px;
    }

    .current-month {
        font-size: 14px;
    }

    .verse-button {
        padding: 8px 15px;
        font-size: 14px;
    }

    .dropdown {
        padding: 5px 10px;
        font-size: 14px;
        border-radius: 5px;
        border: 1px solid #ccc;
        margin-right: 10px;
    }

    .year-text {
        font-size: 16px;
        font-weight: bold;
        margin-right: 10px;
    }
}

js

document.addEventListener('DOMContentLoaded', function () {
    const calendarEl = document.getElementById('calendar');
    const daysOfWeekEl = document.getElementById('daysOfWeek');
    const monthSelect = document.getElementById('monthSelect');
    const todayButton = document.getElementById('todayButton');
    const yearText = document.getElementById('yearText');
    const verseButton = document.getElementById('verseButton');
    const today = new Date();

    let currentYear = 2025; // 고정된 연도 2025
    let currentMonth = today.getMonth();
    let selectedDate = today; // 기본적으로 오늘 날짜 선택

    const daysOfWeek = ['일', '월', '화', '수', '목', '금', '토'];

    // 초기화
    populateMonthSelect();
    updateSelectValues();
    renderCalendar(currentYear, currentMonth);

    // 이벤트 리스너
    monthSelect.addEventListener('change', function () {
        currentMonth = parseInt(monthSelect.value, 10);
        renderCalendar(currentYear, currentMonth);
    });

    todayButton.addEventListener('click', function () {
        currentMonth = today.getMonth();
        selectedDate = today; // 오늘 날짜로 설정
        renderCalendar(currentYear, currentMonth);
        updateVerseButton('오늘의 말씀');
    });

    // "오늘의 말씀" 버튼 클릭 시 선택된 날짜 전송
    verseButton.addEventListener('click', function () {
        if (selectedDate) {
            const year = selectedDate.getFullYear();
            const month = selectedDate.getMonth() + 1; // 월은 0부터 시작하므로 +1
            const date = selectedDate.getDate();

            // 서버에 선택된 날짜 전송 (서버로 이동)
            const url = `/contents?year=${year}&month=${month}&date=${date}`;

            // 페이지 이동 (서버로 요청을 보내는 방식)
            window.location.href = url;
        } else {
            alert("먼저 날짜를 선택해주세요.");
        }
    });

    function populateMonthSelect() {
        for (let month = 0; month < 12; month++) {
            const option = document.createElement('option');
            option.value = month;
            option.textContent = `${month + 1}월`;
            monthSelect.appendChild(option);
        }
    }

    function updateSelectValues() {
        monthSelect.value = currentMonth;
    }

    function renderCalendar(year, month) {
        calendarEl.innerHTML = ''; // 기존 달력 초기화
        daysOfWeekEl.innerHTML = ''; // 요일 초기화

        // 요일 렌더링
        daysOfWeek.forEach((day) => {
            const dayEl = document.createElement('div');
            dayEl.className = 'day-name';
            dayEl.textContent = day;
            daysOfWeekEl.appendChild(dayEl);
        });

        // 날짜 계산
        const daysInMonth = new Date(year, month + 1, 0).getDate();
        const firstDay = new Date(year, month, 1).getDay();

        // 빈 셀 추가
        for (let i = 0; i < firstDay; i++) {
            const emptyDay = document.createElement('div');
            emptyDay.className = 'calendar-day empty';
            calendarEl.appendChild(emptyDay);
        }

        // 날짜 생성
        for (let day = 1; day <= daysInMonth; day++) {
            const dayEl = document.createElement('div');
            dayEl.className = 'calendar-day';

            const date = new Date(year, month, day);

            // 오늘 날짜는 빨간 점 표시
            if (
                date.getFullYear() === today.getFullYear() &&
                date.getMonth() === today.getMonth() &&
                date.getDate() === today.getDate()
            ) {
                dayEl.classList.add('today');
                const dot = document.createElement('div');
                dot.className = 'dot';
                dayEl.appendChild(dot);
            }

            // 선택한 날짜는 다른 스타일 적용
            if (
                selectedDate &&
                date.getFullYear() === selectedDate.getFullYear() &&
                date.getMonth() === selectedDate.getMonth() &&
                date.getDate() === selectedDate.getDate()
            ) {
                dayEl.classList.add('selected');
            }

            // 오늘 이후 날짜는 선택 불가
            if (date > today) {
                dayEl.classList.add('disabled');
                dayEl.style.pointerEvents = 'none';
            }

            // 날짜 클릭 시 오늘의 말씀으로 변경
            dayEl.textContent = day;
            dayEl.addEventListener('click', function () {
                if (date <= today) { // 오늘 이전 날짜는 클릭 시 말씀이 표시
                    selectedDate = date;
                    renderCalendar(currentYear, currentMonth);
                    if (date.getFullYear() === today.getFullYear() &&
                        date.getMonth() === today.getMonth() &&
                        date.getDate() === today.getDate()) {
                        updateVerseButton('오늘의 말씀');
                    } else {
                        updateVerseButton(`${date.getFullYear()}년 ${date.getMonth() + 1}월 ${date.getDate()}일의 말씀`);
                    }
                }
            });

            calendarEl.appendChild(dayEl);
        }
    }

    function updateVerseButton(text) {
        verseButton.textContent = text;
    }

    window.showVerse = function () {
        alert('오늘의 말씀: "나는 길이요 진리요 생명이다" (요한복음 14:6)');
    };
});

 

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>성경 구절</title>
    <link rel="stylesheet" th:href="@{/css/contentStyle.css}">

    <!-- 부트스트랩 CSS CDN -->
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">

    <!-- 부트스트랩 아이콘 (선택 사항) -->
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.1/css/all.min.css">

</head>
<body>

<div th:each="contentDto, iterStat : ${contentDtoList}">
    <div th:if="${iterStat.index == 0 or contentDto.getParagraph() == 1}">
        <h2 th:text="${contentDto.getLongLabel()} + ' ' + ${contentDto.getChapter()} + '장'"></h2>
    </div>

    <div th:text="${contentDto.getParagraph()} + '. ' + ${contentDto.getSentence()}" style="margin-bottom: 10px;"></div>
</div>

<div class="d-flex justify-content-between mt-3">
    <form action="/history" method="POST">
        <input type="hidden" name="newDate" value="${newDate}">
        <button type="submit" class="btn-secondary btn-block text-center">읽기 완료</button>
    </form>
</div>

</body>
</html>

 

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>반응형 달력</title>
    <link rel="stylesheet" th:href="@{/css/style.css}">
</head>
<body>
<div class="container">
    <div class="calendar-header">
        <div class="current-month" id="yearText">2025년</div>
        <select id="monthSelect" class="dropdown"></select>
        <button class="nav-button" id="todayButton">오늘</button>
    </div>

    <div id="daysOfWeek"></div>
    <div id="calendar"></div>

    <div class="footer">
        <!-- '오늘의 말씀' 버튼을 link로 감싸지 않고, 클릭 이벤트로 처리하도록 수정 -->
        <button class="verse-button" id="verseButton">오늘의 말씀</button>
        <div class="coin-info">
            <img th:src="@{/images/coin.png}" alt="동전 이미지"> <!-- 동전 이미지 파일 경로 -->
            <span class="coin-text">달란트</span>
            <span class="coin-amount">: 100</span>
        </div>
    </div>

</div>

<!-- JavaScript -->
<script th:src="@{/js/calendar.js}"></script>
</body>
</html>

 

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>로그인 페이지</title>

    <!-- 부트스트랩 CSS CDN -->
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">

    <!-- 부트스트랩 아이콘 (선택 사항) -->
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.1/css/all.min.css">
</head>
<body class="d-flex justify-content-center align-items-center" style="height: 100vh; background-color: #f4f6f9;">

<div class="container">
    <div class="row justify-content-center">
        <div class="col-md-6 col-lg-4">
            <!-- 중앙 박스 -->
            <div class="card shadow">
                <div class="card-body p-4">
                    <!-- 제목 -->
                    <h3 class="text-center mb-4">테스트 중</h3>

                    <form th:action="@{/login}" method="post">
                        <!-- 아이디 입력란 -->
                        <div class="form-group">
                            <label for="userId">아이디</label>
                            <input type="text" id="userId" name="userId" class="form-control" placeholder="아이디를 입력하세요" required>
                        </div>
                        <button type="submit" class="btn btn-primary btn-block">로그인</button>
                    </form>

                    <div class="d-flex justify-content-between mt-3">
                        <!-- 회원가입 버튼 -->
                        <a href="/signup" class="btn-secondary btn-block text-center">회원가입</a>
                    </div>
                </div>
            </div>
        </div>
    </div>
</div>

<!-- 부트스트랩 JS 및 Popper.js CDN -->
<script src="https://code.jquery.com/jquery-3.5.1.slim.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.5.2/dist/umd/popper.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js"></script>
</body>
</html>

 

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>회원가입</title>

    <!-- 부트스트랩 CSS CDN -->
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
</head>
<body>
<div class="container mt-5">
    <h2 class="text-center">여정 합류</h2>

    <!-- 회원가입 폼 -->
    <form action="/signup" method="POST">
        <div class="form-group">
            <label for="group">아이디 그룹</label>
            <!-- 드롭다운 선택 -->
            <select id="group" name="group" class="form-control">
                <option value="A">A</option>
                <option value="B">B</option>
                <option value="C">C</option>
            </select>
        </div>

        <div class="form-group">
            <label for="userId">유저id</label>
            <input type="text" id="userId" class="form-control" name="userId" placeholder="아이디를 입력하세요">
        </div>

        <div class="form-group">
            <label for="name">이름</label>
            <input type="text" id="name" class="form-control" name="name" placeholder="아이디를 입력하세요">
        </div>

        <button type="submit" class="btn btn-primary btn-block">회원가입</button>
    </form>
</div>
</body>
</html>

application.yml

spring:
  datasource:
    url: jdbc:h2:tcp://localhost/~/bible
    username: sa
    password: sa
    driver-class-name: org.h2.Driver

  jpa:
    hibernate:
      ddl-auto: update
    properties:
      hibernate:
        format_sql: true

logging:
  level:
    org.hibernate.sql: debug