비빕밥 팀원 모집 페이지 작업
This commit is contained in:
parent
8f6a49cb54
commit
2cca6c72a6
|
|
@ -0,0 +1,75 @@
|
|||
-- Indie game team recruitment posts.
|
||||
-- PostgreSQL DDL aligned with the existing bibimbap table style.
|
||||
|
||||
CREATE SEQUENCE IF NOT EXISTS "recruit_posts_id_seq";
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "recruit_posts" (
|
||||
"id" bigint DEFAULT nextval('recruit_posts_id_seq'::regclass) NOT NULL,
|
||||
"user_id" bigint NOT NULL,
|
||||
"project_name" character varying(200) NOT NULL,
|
||||
"genre" character varying(80),
|
||||
"summary" character varying(200) NOT NULL,
|
||||
"role" character varying(30) NOT NULL,
|
||||
"project_status" character varying(50) NOT NULL,
|
||||
"participation_type" character varying(30) NOT NULL,
|
||||
"expected_period" character varying(80),
|
||||
"team_members" character varying(200),
|
||||
"contact" character varying(200) NOT NULL,
|
||||
"description" text,
|
||||
"reference_url" character varying(500),
|
||||
"deadline_at" timestamp with time zone,
|
||||
"is_visible" boolean DEFAULT true NOT NULL,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"deleted_at" timestamp with time zone,
|
||||
"is_delete" boolean DEFAULT false NOT NULL,
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
ALTER SEQUENCE "recruit_posts_id_seq" OWNED BY "recruit_posts"."id";
|
||||
|
||||
ALTER TABLE "recruit_posts"
|
||||
ADD CONSTRAINT "recruit_posts_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id");
|
||||
|
||||
ALTER TABLE "recruit_posts"
|
||||
ADD CONSTRAINT "recruit_posts_role_check"
|
||||
CHECK ("role" IN ('기획', '아트', '프로그래머'));
|
||||
|
||||
ALTER TABLE "recruit_posts"
|
||||
ADD CONSTRAINT "recruit_posts_participation_type_check"
|
||||
CHECK ("participation_type" IN ('취미', '수익쉐어', '유급', '게임잼'));
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "idx_recruit_posts_visible_order"
|
||||
ON "recruit_posts" ("is_visible", "is_delete", "sort_order", "created_at" DESC, "id" DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "idx_recruit_posts_role"
|
||||
ON "recruit_posts" ("role")
|
||||
WHERE "is_delete" = false AND "is_visible" = true;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "idx_recruit_posts_participation_type"
|
||||
ON "recruit_posts" ("participation_type")
|
||||
WHERE "is_delete" = false AND "is_visible" = true;
|
||||
|
||||
COMMENT ON TABLE "recruit_posts" IS '인디게임 팀원 모집글';
|
||||
COMMENT ON COLUMN "recruit_posts"."id" IS '모집글 고유 ID';
|
||||
COMMENT ON COLUMN "recruit_posts"."user_id" IS '모집글 작성자 users.id';
|
||||
COMMENT ON COLUMN "recruit_posts"."project_name" IS '프로젝트 이름';
|
||||
COMMENT ON COLUMN "recruit_posts"."genre" IS '게임 장르';
|
||||
COMMENT ON COLUMN "recruit_posts"."summary" IS '한 줄 소개';
|
||||
COMMENT ON COLUMN "recruit_posts"."role" IS '모집 역할. 기획, 아트, 프로그래머';
|
||||
COMMENT ON COLUMN "recruit_posts"."project_status" IS '프로젝트 진행 상태';
|
||||
COMMENT ON COLUMN "recruit_posts"."participation_type" IS '참여 방식. 취미, 수익쉐어, 유급, 게임잼';
|
||||
COMMENT ON COLUMN "recruit_posts"."expected_period" IS '예상 작업 기간';
|
||||
COMMENT ON COLUMN "recruit_posts"."team_members" IS '현재 팀 구성';
|
||||
COMMENT ON COLUMN "recruit_posts"."contact" IS '연락 방법';
|
||||
COMMENT ON COLUMN "recruit_posts"."description" IS '상세 설명';
|
||||
COMMENT ON COLUMN "recruit_posts"."reference_url" IS '참고 링크';
|
||||
COMMENT ON COLUMN "recruit_posts"."deadline_at" IS '모집 마감 시각';
|
||||
COMMENT ON COLUMN "recruit_posts"."is_visible" IS '목록 공개 여부';
|
||||
COMMENT ON COLUMN "recruit_posts"."sort_order" IS '정렬 우선순위';
|
||||
COMMENT ON COLUMN "recruit_posts"."created_at" IS '모집글 생성 시각';
|
||||
COMMENT ON COLUMN "recruit_posts"."updated_at" IS '모집글 마지막 수정 시각';
|
||||
COMMENT ON COLUMN "recruit_posts"."deleted_at" IS '모집글 삭제 시각';
|
||||
COMMENT ON COLUMN "recruit_posts"."is_delete" IS '소프트 삭제 여부';
|
||||
|
|
@ -0,0 +1,186 @@
|
|||
package com.pandoli365.bibimbap.controller;
|
||||
|
||||
import com.pandoli365.bibimbap.data.RecruitPostData;
|
||||
import com.pandoli365.bibimbap.mapper.RecruitPostsMapper;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@Controller
|
||||
public class RecruitController {
|
||||
|
||||
private static final Set<String> ROLES = Set.of("기획", "아트", "프로그래머");
|
||||
private static final Set<String> PARTICIPATION_TYPES = Set.of("취미", "수익쉐어", "유급", "게임잼");
|
||||
private static final Set<String> PROJECT_STATUSES = Set.of("아이디어 단계", "프로토타입 개발 중", "데모 있음", "출시 준비 중");
|
||||
|
||||
private final RecruitPostsMapper recruitPostsMapper;
|
||||
|
||||
public RecruitController(RecruitPostsMapper recruitPostsMapper) {
|
||||
this.recruitPostsMapper = recruitPostsMapper;
|
||||
}
|
||||
|
||||
@GetMapping("/recruit")
|
||||
public String recruitList(Model model) {
|
||||
model.addAttribute("recruitPosts", recruitPostsMapper.getVisibleRecruitPosts());
|
||||
return "recruit-list";
|
||||
}
|
||||
|
||||
@GetMapping("/recruit/new")
|
||||
public String recruitForm(HttpSession session) {
|
||||
if (sessionUserId(session) == null) {
|
||||
return "redirect:/login";
|
||||
}
|
||||
return "recruit-form";
|
||||
}
|
||||
|
||||
@PostMapping("/recruit/new")
|
||||
@Transactional
|
||||
public ResponseEntity<Map<String, Object>> createRecruitPost(
|
||||
@RequestParam(name = "projectName", required = false) String projectName,
|
||||
@RequestParam(name = "genre", required = false) String genre,
|
||||
@RequestParam(name = "summary", required = false) String summary,
|
||||
@RequestParam(name = "role", required = false) String role,
|
||||
@RequestParam(name = "status", required = false) String projectStatus,
|
||||
@RequestParam(name = "type", required = false) String participationType,
|
||||
@RequestParam(name = "period", required = false) String expectedPeriod,
|
||||
@RequestParam(name = "team", required = false) String teamMembers,
|
||||
@RequestParam(name = "contact", required = false) String contact,
|
||||
@RequestParam(name = "description", required = false) String description,
|
||||
HttpSession session
|
||||
) {
|
||||
Long userId = sessionUserId(session);
|
||||
if (userId == null) {
|
||||
return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다.");
|
||||
}
|
||||
|
||||
String normalizedProjectName = trimToNull(projectName);
|
||||
String normalizedSummary = trimToNull(summary);
|
||||
String normalizedContact = trimToNull(contact);
|
||||
String normalizedRole = trimToNull(role);
|
||||
String normalizedProjectStatus = trimToNull(projectStatus);
|
||||
String normalizedParticipationType = trimToNull(participationType);
|
||||
|
||||
if (normalizedProjectName == null || normalizedProjectName.length() > 80) {
|
||||
return response(HttpStatus.BAD_REQUEST, "프로젝트명을 80자 이내로 입력해 주세요.");
|
||||
}
|
||||
if (normalizedSummary == null || normalizedSummary.length() > 120) {
|
||||
return response(HttpStatus.BAD_REQUEST, "한 줄 소개를 120자 이내로 입력해 주세요.");
|
||||
}
|
||||
if (normalizedContact == null || normalizedContact.length() > 100) {
|
||||
return response(HttpStatus.BAD_REQUEST, "연락 방법을 100자 이내로 입력해 주세요.");
|
||||
}
|
||||
if (!ROLES.contains(normalizedRole)) {
|
||||
return response(HttpStatus.BAD_REQUEST, "모집 역할을 확인해 주세요.");
|
||||
}
|
||||
if (!PROJECT_STATUSES.contains(normalizedProjectStatus)) {
|
||||
return response(HttpStatus.BAD_REQUEST, "진행 상태를 확인해 주세요.");
|
||||
}
|
||||
if (!PARTICIPATION_TYPES.contains(normalizedParticipationType)) {
|
||||
return response(HttpStatus.BAD_REQUEST, "참여 방식을 확인해 주세요.");
|
||||
}
|
||||
|
||||
String normalizedGenre = trimToEmpty(genre);
|
||||
String normalizedExpectedPeriod = trimToEmpty(expectedPeriod);
|
||||
String normalizedTeamMembers = trimToEmpty(teamMembers);
|
||||
String normalizedDescription = trimToEmpty(description);
|
||||
if (normalizedGenre.length() > 60) {
|
||||
return response(HttpStatus.BAD_REQUEST, "게임 장르는 60자 이내로 입력해 주세요.");
|
||||
}
|
||||
if (normalizedExpectedPeriod.length() > 40) {
|
||||
return response(HttpStatus.BAD_REQUEST, "예상 기간은 40자 이내로 입력해 주세요.");
|
||||
}
|
||||
if (normalizedTeamMembers.length() > 80) {
|
||||
return response(HttpStatus.BAD_REQUEST, "현재 팀 구성은 80자 이내로 입력해 주세요.");
|
||||
}
|
||||
if (normalizedDescription.length() > 1200) {
|
||||
return response(HttpStatus.BAD_REQUEST, "상세 설명은 1,200자 이내로 입력해 주세요.");
|
||||
}
|
||||
|
||||
RecruitPostData post = new RecruitPostData();
|
||||
post.setUserId(userId);
|
||||
post.setProjectName(normalizedProjectName);
|
||||
post.setGenre(normalizedGenre);
|
||||
post.setSummary(normalizedSummary);
|
||||
post.setRole(normalizedRole);
|
||||
post.setProjectStatus(normalizedProjectStatus);
|
||||
post.setParticipationType(normalizedParticipationType);
|
||||
post.setExpectedPeriod(normalizedExpectedPeriod);
|
||||
post.setTeamMembers(normalizedTeamMembers);
|
||||
post.setContact(normalizedContact);
|
||||
post.setDescription(normalizedDescription);
|
||||
post.setReferenceUrl("");
|
||||
post.setVisible(true);
|
||||
post.setSortOrder(recruitPostsMapper.nextSortOrder());
|
||||
|
||||
recruitPostsMapper.addRecruitPost(post);
|
||||
if (post.getId() == null) {
|
||||
return response(HttpStatus.INTERNAL_SERVER_ERROR, "모집글 등록 결과를 확인하지 못했습니다.");
|
||||
}
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", 200);
|
||||
body.put("message", "모집글 등록이 완료되었습니다.");
|
||||
body.put("recruitPostId", post.getId());
|
||||
body.put("location", "/recruit/" + post.getId());
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
@GetMapping("/recruit/{id}")
|
||||
public String recruitDetail(@PathVariable("id") long id, Model model) {
|
||||
RecruitPostData post = recruitPostsMapper.getRecruitPost(id);
|
||||
if (post == null || Boolean.FALSE.equals(post.getVisible())) {
|
||||
return "redirect:/recruit";
|
||||
}
|
||||
model.addAttribute("post", post);
|
||||
return "recruit-detail";
|
||||
}
|
||||
|
||||
private Long sessionUserId(HttpSession session) {
|
||||
if (session == null) {
|
||||
return null;
|
||||
}
|
||||
Object userId = session.getAttribute("userId");
|
||||
if (userId instanceof Number number) {
|
||||
return number.longValue();
|
||||
}
|
||||
if (userId instanceof String text) {
|
||||
try {
|
||||
return Long.parseLong(text);
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String trimToNull(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String text = value.trim();
|
||||
return text.isBlank() ? null : text;
|
||||
}
|
||||
|
||||
private String trimToEmpty(String value) {
|
||||
String text = trimToNull(value);
|
||||
return text == null ? "" : text;
|
||||
}
|
||||
|
||||
private ResponseEntity<Map<String, Object>> response(HttpStatus status, String message) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", status.value());
|
||||
body.put("message", message);
|
||||
return ResponseEntity.status(status).body(body);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
package com.pandoli365.bibimbap.data;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
public class RecruitPostData {
|
||||
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private String creator;
|
||||
private String projectName;
|
||||
private String genre;
|
||||
private String summary;
|
||||
private String role;
|
||||
private String projectStatus;
|
||||
private String participationType;
|
||||
private String expectedPeriod;
|
||||
private String teamMembers;
|
||||
private String contact;
|
||||
private String description;
|
||||
private String referenceUrl;
|
||||
private OffsetDateTime deadlineAt;
|
||||
private Boolean visible;
|
||||
private Integer sortOrder;
|
||||
private OffsetDateTime createdAt;
|
||||
private OffsetDateTime updatedAt;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public Long getUserId() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
public void setUserId(Long userId) {
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
public String getCreator() {
|
||||
return creator;
|
||||
}
|
||||
|
||||
public void setCreator(String creator) {
|
||||
this.creator = creator;
|
||||
}
|
||||
|
||||
public String getProjectName() {
|
||||
return projectName;
|
||||
}
|
||||
|
||||
public void setProjectName(String projectName) {
|
||||
this.projectName = projectName;
|
||||
}
|
||||
|
||||
public String getGenre() {
|
||||
return genre;
|
||||
}
|
||||
|
||||
public void setGenre(String genre) {
|
||||
this.genre = genre;
|
||||
}
|
||||
|
||||
public String getSummary() {
|
||||
return summary;
|
||||
}
|
||||
|
||||
public void setSummary(String summary) {
|
||||
this.summary = summary;
|
||||
}
|
||||
|
||||
public String getRole() {
|
||||
return role;
|
||||
}
|
||||
|
||||
public void setRole(String role) {
|
||||
this.role = role;
|
||||
}
|
||||
|
||||
public String getProjectStatus() {
|
||||
return projectStatus;
|
||||
}
|
||||
|
||||
public void setProjectStatus(String projectStatus) {
|
||||
this.projectStatus = projectStatus;
|
||||
}
|
||||
|
||||
public String getParticipationType() {
|
||||
return participationType;
|
||||
}
|
||||
|
||||
public void setParticipationType(String participationType) {
|
||||
this.participationType = participationType;
|
||||
}
|
||||
|
||||
public String getExpectedPeriod() {
|
||||
return expectedPeriod;
|
||||
}
|
||||
|
||||
public void setExpectedPeriod(String expectedPeriod) {
|
||||
this.expectedPeriod = expectedPeriod;
|
||||
}
|
||||
|
||||
public String getTeamMembers() {
|
||||
return teamMembers;
|
||||
}
|
||||
|
||||
public void setTeamMembers(String teamMembers) {
|
||||
this.teamMembers = teamMembers;
|
||||
}
|
||||
|
||||
public String getContact() {
|
||||
return contact;
|
||||
}
|
||||
|
||||
public void setContact(String contact) {
|
||||
this.contact = contact;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public void setDescription(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public String getReferenceUrl() {
|
||||
return referenceUrl;
|
||||
}
|
||||
|
||||
public void setReferenceUrl(String referenceUrl) {
|
||||
this.referenceUrl = referenceUrl;
|
||||
}
|
||||
|
||||
public OffsetDateTime getDeadlineAt() {
|
||||
return deadlineAt;
|
||||
}
|
||||
|
||||
public void setDeadlineAt(OffsetDateTime deadlineAt) {
|
||||
this.deadlineAt = deadlineAt;
|
||||
}
|
||||
|
||||
public Boolean getVisible() {
|
||||
return visible;
|
||||
}
|
||||
|
||||
public void setVisible(Boolean visible) {
|
||||
this.visible = visible;
|
||||
}
|
||||
|
||||
public Integer getSortOrder() {
|
||||
return sortOrder;
|
||||
}
|
||||
|
||||
public void setSortOrder(Integer sortOrder) {
|
||||
this.sortOrder = sortOrder;
|
||||
}
|
||||
|
||||
public OffsetDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(OffsetDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public OffsetDateTime getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
public void setUpdatedAt(OffsetDateTime updatedAt) {
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
package com.pandoli365.bibimbap.mapper;
|
||||
|
||||
import com.pandoli365.bibimbap.data.RecruitPostData;
|
||||
import org.apache.ibatis.annotations.Insert;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Options;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface RecruitPostsMapper {
|
||||
|
||||
@Select("""
|
||||
SELECT
|
||||
r.id,
|
||||
r.user_id AS userId,
|
||||
u.display_name AS creator,
|
||||
r.project_name AS projectName,
|
||||
r.genre,
|
||||
r.summary,
|
||||
r.role,
|
||||
r.project_status AS projectStatus,
|
||||
r.participation_type AS participationType,
|
||||
r.expected_period AS expectedPeriod,
|
||||
r.team_members AS teamMembers,
|
||||
r.contact,
|
||||
r.description,
|
||||
r.reference_url AS referenceUrl,
|
||||
r.deadline_at AS deadlineAt,
|
||||
r.is_visible AS visible,
|
||||
r.sort_order AS sortOrder,
|
||||
r.created_at AS createdAt,
|
||||
r.updated_at AS updatedAt
|
||||
FROM recruit_posts r
|
||||
JOIN users u ON u.id = r.user_id
|
||||
WHERE r.id = #{id}
|
||||
AND r.is_delete IS NOT TRUE
|
||||
AND u.is_delete IS NOT TRUE
|
||||
""")
|
||||
RecruitPostData getRecruitPost(@Param("id") long id);
|
||||
|
||||
@Select("""
|
||||
SELECT
|
||||
r.id,
|
||||
r.user_id AS userId,
|
||||
u.display_name AS creator,
|
||||
r.project_name AS projectName,
|
||||
r.genre,
|
||||
r.summary,
|
||||
r.role,
|
||||
r.project_status AS projectStatus,
|
||||
r.participation_type AS participationType,
|
||||
r.expected_period AS expectedPeriod,
|
||||
r.team_members AS teamMembers,
|
||||
r.contact,
|
||||
r.description,
|
||||
r.reference_url AS referenceUrl,
|
||||
r.deadline_at AS deadlineAt,
|
||||
r.is_visible AS visible,
|
||||
r.sort_order AS sortOrder,
|
||||
r.created_at AS createdAt,
|
||||
r.updated_at AS updatedAt
|
||||
FROM recruit_posts r
|
||||
JOIN users u ON u.id = r.user_id
|
||||
WHERE r.is_visible IS NOT FALSE
|
||||
AND r.is_delete IS NOT TRUE
|
||||
AND u.is_delete IS NOT TRUE
|
||||
ORDER BY r.sort_order ASC, r.created_at DESC, r.id DESC
|
||||
""")
|
||||
List<RecruitPostData> getVisibleRecruitPosts();
|
||||
|
||||
@Insert("""
|
||||
INSERT INTO recruit_posts (
|
||||
user_id,
|
||||
project_name,
|
||||
genre,
|
||||
summary,
|
||||
role,
|
||||
project_status,
|
||||
participation_type,
|
||||
expected_period,
|
||||
team_members,
|
||||
contact,
|
||||
description,
|
||||
reference_url,
|
||||
is_visible,
|
||||
sort_order
|
||||
) VALUES (
|
||||
#{userId},
|
||||
#{projectName},
|
||||
#{genre},
|
||||
#{summary},
|
||||
#{role},
|
||||
#{projectStatus},
|
||||
#{participationType},
|
||||
#{expectedPeriod},
|
||||
#{teamMembers},
|
||||
#{contact},
|
||||
#{description},
|
||||
#{referenceUrl},
|
||||
#{visible},
|
||||
#{sortOrder}
|
||||
)
|
||||
""")
|
||||
@Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id")
|
||||
int addRecruitPost(RecruitPostData post);
|
||||
|
||||
@Select("""
|
||||
SELECT COALESCE(MAX(sort_order), 0) + 1
|
||||
FROM recruit_posts
|
||||
WHERE is_delete IS NOT TRUE
|
||||
""")
|
||||
int nextSortOrder();
|
||||
}
|
||||
|
|
@ -61,6 +61,28 @@
|
|||
.site-header__brand:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
.site-header__nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
.site-header__nav-link {
|
||||
min-height: 2.25rem;
|
||||
padding: 0 0.75rem;
|
||||
border-radius: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--header-text);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 800;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.site-header__nav-link:hover {
|
||||
background: var(--header-btn-hover);
|
||||
color: var(--accent);
|
||||
}
|
||||
.site-header__logo {
|
||||
height: 2.25rem;
|
||||
width: auto;
|
||||
|
|
@ -156,6 +178,18 @@
|
|||
width: 1.35rem;
|
||||
height: 1.35rem;
|
||||
}
|
||||
@media (max-width: 520px) {
|
||||
.site-header__inner {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.site-header__brand span {
|
||||
display: none;
|
||||
}
|
||||
.site-header__nav-link {
|
||||
padding: 0 0.55rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<header class="site-header" role="banner">
|
||||
<div class="site-header__inner">
|
||||
|
|
@ -163,6 +197,9 @@
|
|||
<img class="site-header__logo" src="${pageContext.request.contextPath}/images/logo.png" alt="" width="120" height="36" />
|
||||
<span>bibimbap</span>
|
||||
</a>
|
||||
<nav class="site-header__nav" aria-label="주요 메뉴">
|
||||
<a class="site-header__nav-link" href="${pageContext.request.contextPath}/recruit">팀원 모집</a>
|
||||
</nav>
|
||||
<div class="site-header__actions">
|
||||
<button type="button" class="site-header__icon-btn" id="theme-toggle" aria-label="다크 모드로 전환" title="테마 전환">
|
||||
<%-- 라이트 모드일 때 달(다크로), 다크 모드일 때 해(라이트로) --%>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,295 @@
|
|||
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" language="java" %>
|
||||
<%@ page import="com.pandoli365.bibimbap.data.RecruitPostData" %>
|
||||
<%@ page import="org.springframework.web.util.HtmlUtils" %>
|
||||
<%
|
||||
String ctx = request.getContextPath();
|
||||
RecruitPostData post = (RecruitPostData) request.getAttribute("post");
|
||||
String projectName = post == null || post.getProjectName() == null || post.getProjectName().isBlank() ? "모집글" : post.getProjectName();
|
||||
String genre = post == null || post.getGenre() == null || post.getGenre().isBlank() ? "장르 미정" : post.getGenre();
|
||||
String summary = post == null || post.getSummary() == null || post.getSummary().isBlank() ? "소개가 아직 없습니다." : post.getSummary();
|
||||
String role = post == null || post.getRole() == null || post.getRole().isBlank() ? "역할" : post.getRole();
|
||||
String projectStatus = post == null || post.getProjectStatus() == null || post.getProjectStatus().isBlank() ? "진행 상태 미정" : post.getProjectStatus();
|
||||
String participationType = post == null || post.getParticipationType() == null || post.getParticipationType().isBlank() ? "참여 방식 미정" : post.getParticipationType();
|
||||
String expectedPeriod = post == null || post.getExpectedPeriod() == null || post.getExpectedPeriod().isBlank() ? "기간 미정" : post.getExpectedPeriod();
|
||||
String teamMembers = post == null || post.getTeamMembers() == null || post.getTeamMembers().isBlank() ? "팀 구성 미정" : post.getTeamMembers();
|
||||
String contact = post == null || post.getContact() == null || post.getContact().isBlank() ? "연락 방법 미정" : post.getContact();
|
||||
String description = post == null || post.getDescription() == null || post.getDescription().isBlank() ? "상세 설명이 아직 없습니다." : post.getDescription();
|
||||
String creator = post == null || post.getCreator() == null || post.getCreator().isBlank() ? "작성자" : post.getCreator();
|
||||
String contactHref = contact.contains("@") && !contact.contains(" ") ? "mailto:" + contact : "#contact-info";
|
||||
%>
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<jsp:include page="/WEB-INF/views/theme-init.jsp"/>
|
||||
<title><%= HtmlUtils.htmlEscape(projectName) %> 팀원 모집 | bibimbap</title>
|
||||
<style>
|
||||
html {
|
||||
color-scheme: light;
|
||||
--surface: #faf8f5;
|
||||
--card-bg: #fff;
|
||||
--text: #1a1a1a;
|
||||
--text-muted: #5c5c5c;
|
||||
--accent: #e8a54b;
|
||||
--accent-soft: rgba(232, 165, 75, 0.16);
|
||||
--border: rgba(0, 0, 0, 0.08);
|
||||
--shadow: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
html[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--surface: #121212;
|
||||
--card-bg: #1e1e1e;
|
||||
--text: #ece8e1;
|
||||
--text-muted: #a39e96;
|
||||
--border: rgba(255, 255, 255, 0.1);
|
||||
--shadow: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans KR", sans-serif;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
}
|
||||
.detail-page {
|
||||
max-width: 64rem;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem max(1rem, env(safe-area-inset-left)) 3rem max(1rem, env(safe-area-inset-right));
|
||||
}
|
||||
.detail-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
min-height: 2.25rem;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 800;
|
||||
text-decoration: none;
|
||||
}
|
||||
.detail-back:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
.detail-hero {
|
||||
margin-bottom: 1rem;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: var(--card-bg);
|
||||
box-shadow: 0 2px 8px var(--shadow);
|
||||
}
|
||||
.detail-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
.detail-badge {
|
||||
min-height: 1.85rem;
|
||||
padding: 0 0.65rem;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 900;
|
||||
}
|
||||
.detail-hero h1 {
|
||||
margin: 0;
|
||||
font-size: 2rem;
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.detail-hero p {
|
||||
margin: 0.65rem 0 0;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.65;
|
||||
}
|
||||
.detail-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
.detail-button {
|
||||
min-height: 2.75rem;
|
||||
padding: 0 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--card-bg);
|
||||
color: var(--text);
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 900;
|
||||
text-decoration: none;
|
||||
}
|
||||
.detail-button--primary {
|
||||
border-color: transparent;
|
||||
background: var(--accent);
|
||||
color: #1a1a1a;
|
||||
}
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 18rem minmax(0, 1fr);
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
.detail-meta,
|
||||
.detail-section {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: var(--card-bg);
|
||||
box-shadow: 0 2px 8px var(--shadow);
|
||||
}
|
||||
.detail-meta {
|
||||
position: sticky;
|
||||
top: 5rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
.detail-meta dl {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
.detail-meta dt {
|
||||
margin: 0 0 0.18rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 900;
|
||||
}
|
||||
.detail-meta dd {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.detail-content {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.detail-section {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
.detail-section h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 1.1rem;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.detail-section p,
|
||||
.detail-section li {
|
||||
color: var(--text-muted);
|
||||
line-height: 1.7;
|
||||
}
|
||||
.detail-section p {
|
||||
margin: 0;
|
||||
}
|
||||
.detail-section ul {
|
||||
margin: 0;
|
||||
padding-left: 1.15rem;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.detail-meta {
|
||||
position: static;
|
||||
}
|
||||
.detail-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
.detail-button {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<jsp:include page="/WEB-INF/views/header.jsp"/>
|
||||
<main class="detail-page">
|
||||
<a class="detail-back" href="<%= ctx %>/recruit">← 목록으로</a>
|
||||
|
||||
<section class="detail-hero" aria-labelledby="detail-title">
|
||||
<div class="detail-badges">
|
||||
<span class="detail-badge"><%= HtmlUtils.htmlEscape(role) %> 모집</span>
|
||||
<span class="detail-badge"><%= HtmlUtils.htmlEscape(participationType) %></span>
|
||||
<span class="detail-badge"><%= HtmlUtils.htmlEscape(projectStatus) %></span>
|
||||
</div>
|
||||
<h1 id="detail-title"><%= HtmlUtils.htmlEscape(projectName) %></h1>
|
||||
<p><%= HtmlUtils.htmlEscape(summary) %></p>
|
||||
<div class="detail-actions">
|
||||
<a class="detail-button detail-button--primary" href="<%= HtmlUtils.htmlEscape(contactHref) %>">연락하기</a>
|
||||
<a class="detail-button" href="<%= ctx %>/recruit">목록으로</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="detail-grid">
|
||||
<aside class="detail-meta" aria-label="모집 정보">
|
||||
<dl>
|
||||
<div>
|
||||
<dt>게임 장르</dt>
|
||||
<dd><%= HtmlUtils.htmlEscape(genre) %></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>현재 단계</dt>
|
||||
<dd><%= HtmlUtils.htmlEscape(projectStatus) %></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>모집 역할</dt>
|
||||
<dd><%= HtmlUtils.htmlEscape(role) %></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>참여 방식</dt>
|
||||
<dd><%= HtmlUtils.htmlEscape(participationType) %></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>예상 기간</dt>
|
||||
<dd><%= HtmlUtils.htmlEscape(expectedPeriod) %></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>현재 팀 구성</dt>
|
||||
<dd><%= HtmlUtils.htmlEscape(teamMembers) %></dd>
|
||||
</div>
|
||||
<div id="contact-info">
|
||||
<dt>연락 방법</dt>
|
||||
<dd><%= HtmlUtils.htmlEscape(contact) %></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>작성자</dt>
|
||||
<dd><%= HtmlUtils.htmlEscape(creator) %></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</aside>
|
||||
|
||||
<div class="detail-content">
|
||||
<section class="detail-section" aria-labelledby="project-about">
|
||||
<h2 id="project-about">프로젝트 소개</h2>
|
||||
<p><%= HtmlUtils.htmlEscape(summary) %></p>
|
||||
</section>
|
||||
<section class="detail-section" aria-labelledby="project-role">
|
||||
<h2 id="project-role">찾는 사람</h2>
|
||||
<ul>
|
||||
<li><%= HtmlUtils.htmlEscape(role) %> 역할로 프로젝트에 참여할 분</li>
|
||||
<li><%= HtmlUtils.htmlEscape(participationType) %> 방식에 동의하고 꾸준히 소통할 수 있는 분</li>
|
||||
<li>작은 범위부터 함께 완성해보고 싶은 분</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section class="detail-section" aria-labelledby="project-work">
|
||||
<h2 id="project-work">작업 방식</h2>
|
||||
<p><%= HtmlUtils.htmlEscape(description) %></p>
|
||||
</section>
|
||||
<section class="detail-section" aria-labelledby="project-links">
|
||||
<h2 id="project-links">참고 링크</h2>
|
||||
<p><%= post != null && post.getReferenceUrl() != null && !post.getReferenceUrl().isBlank() ? HtmlUtils.htmlEscape(post.getReferenceUrl()) : "참고 링크는 아직 없습니다." %></p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<jsp:include page="/WEB-INF/views/footer.jsp"/>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,445 @@
|
|||
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" language="java" %>
|
||||
<%
|
||||
String ctx = request.getContextPath();
|
||||
%>
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<jsp:include page="/WEB-INF/views/theme-init.jsp"/>
|
||||
<title>팀원 모집글 작성 | bibimbap</title>
|
||||
<style>
|
||||
html {
|
||||
color-scheme: light;
|
||||
--surface: #faf8f5;
|
||||
--card-bg: #fff;
|
||||
--text: #1a1a1a;
|
||||
--text-muted: #5c5c5c;
|
||||
--accent: #e8a54b;
|
||||
--accent-soft: rgba(232, 165, 75, 0.16);
|
||||
--border: rgba(0, 0, 0, 0.08);
|
||||
--shadow: rgba(0, 0, 0, 0.06);
|
||||
--field-bg: #fff;
|
||||
}
|
||||
html[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--surface: #121212;
|
||||
--card-bg: #1e1e1e;
|
||||
--text: #ece8e1;
|
||||
--text-muted: #a39e96;
|
||||
--border: rgba(255, 255, 255, 0.1);
|
||||
--shadow: rgba(0, 0, 0, 0.35);
|
||||
--field-bg: #181818;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans KR", sans-serif;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
}
|
||||
.form-page {
|
||||
max-width: 72rem;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem max(1rem, env(safe-area-inset-left)) 3rem max(1rem, env(safe-area-inset-right));
|
||||
}
|
||||
.form-heading {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.form-heading__eyebrow {
|
||||
margin: 0 0 0.35rem;
|
||||
color: var(--accent);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 900;
|
||||
}
|
||||
.form-heading h1 {
|
||||
margin: 0;
|
||||
font-size: 1.85rem;
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.form-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 20rem;
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
.recruit-form,
|
||||
.preview-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: var(--card-bg);
|
||||
box-shadow: 0 2px 8px var(--shadow);
|
||||
}
|
||||
.recruit-form {
|
||||
padding: 1.35rem;
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.9rem;
|
||||
}
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.field--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.field label {
|
||||
color: var(--text);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 900;
|
||||
}
|
||||
.field input,
|
||||
.field select,
|
||||
.field textarea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--field-bg);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
.field select {
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
padding-right: 3rem;
|
||||
background-image:
|
||||
linear-gradient(90deg, transparent 0, transparent calc(100% - 2.75rem), rgba(232, 165, 75, 0.12) calc(100% - 2.75rem), rgba(232, 165, 75, 0.12) 100%),
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='18' height='18' viewBox='0 0 24 24' fill='none' stroke='%23e8a54b' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat, no-repeat;
|
||||
background-position: 0 0, right 0.82rem center;
|
||||
background-size: 100% 100%, 1.05rem 1.05rem;
|
||||
}
|
||||
.field select::-ms-expand {
|
||||
display: none;
|
||||
}
|
||||
.field select:hover {
|
||||
border-color: rgba(232, 165, 75, 0.45);
|
||||
}
|
||||
.field select option {
|
||||
background: var(--field-bg);
|
||||
color: var(--text);
|
||||
}
|
||||
.field input,
|
||||
.field select {
|
||||
height: 2.85rem;
|
||||
padding: 0 0.8rem;
|
||||
}
|
||||
.field select {
|
||||
padding-right: 3rem;
|
||||
}
|
||||
.field textarea {
|
||||
min-height: 8rem;
|
||||
padding: 0.8rem;
|
||||
line-height: 1.6;
|
||||
resize: vertical;
|
||||
}
|
||||
.field input:focus,
|
||||
.field select:focus,
|
||||
.field textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(232, 165, 75, 0.22);
|
||||
}
|
||||
.form-actions {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.form-button {
|
||||
min-height: 2.75rem;
|
||||
padding: 0 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--card-bg);
|
||||
color: var(--text);
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 900;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.form-button--primary {
|
||||
border-color: transparent;
|
||||
background: var(--accent);
|
||||
color: #1a1a1a;
|
||||
}
|
||||
.preview-card {
|
||||
position: sticky;
|
||||
top: 5rem;
|
||||
padding: 1.1rem;
|
||||
}
|
||||
.preview-card__label {
|
||||
margin: 0 0 0.65rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 900;
|
||||
}
|
||||
.preview-card__role {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 1.85rem;
|
||||
margin-bottom: 0.7rem;
|
||||
padding: 0 0.65rem;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 900;
|
||||
}
|
||||
.preview-card h2 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.35;
|
||||
letter-spacing: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
.preview-card__line {
|
||||
margin: 0.55rem 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6;
|
||||
word-break: break-word;
|
||||
}
|
||||
.preview-card__meta {
|
||||
margin: 1rem 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.preview-card__meta strong {
|
||||
color: var(--text);
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.form-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.preview-card {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.form-actions {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
.form-button {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<jsp:include page="/WEB-INF/views/header.jsp"/>
|
||||
<main class="form-page">
|
||||
<section class="form-heading" aria-labelledby="form-title">
|
||||
<p class="form-heading__eyebrow">RECRUIT POST</p>
|
||||
<h1 id="form-title">팀원 모집글 작성</h1>
|
||||
</section>
|
||||
|
||||
<div class="form-layout">
|
||||
<section class="recruit-form" aria-label="팀원 모집글 입력">
|
||||
<form id="recruit-form" action="<%= ctx %>/recruit/new" method="post" novalidate>
|
||||
<div class="form-grid">
|
||||
<div class="field">
|
||||
<label for="project-name">프로젝트명</label>
|
||||
<input id="project-name" name="projectName" type="text" maxlength="80" placeholder="예: 달빛 정거장" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="genre">게임 장르</label>
|
||||
<input id="genre" name="genre" type="text" maxlength="60" placeholder="예: 2D 어드벤처">
|
||||
</div>
|
||||
<div class="field field--full">
|
||||
<label for="summary">한 줄 소개</label>
|
||||
<input id="summary" name="summary" type="text" maxlength="120" placeholder="프로젝트를 한 문장으로 소개해 주세요." required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="role">모집 역할</label>
|
||||
<select id="role" name="role">
|
||||
<option selected>기획</option>
|
||||
<option>아트</option>
|
||||
<option>프로그래머</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="status">진행 상태</label>
|
||||
<select id="status" name="status">
|
||||
<option selected>아이디어 단계</option>
|
||||
<option>프로토타입 개발 중</option>
|
||||
<option>데모 있음</option>
|
||||
<option>출시 준비 중</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="type">참여 방식</label>
|
||||
<select id="type" name="type">
|
||||
<option selected>취미</option>
|
||||
<option>수익쉐어</option>
|
||||
<option>유급</option>
|
||||
<option>게임잼</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="period">예상 기간</label>
|
||||
<input id="period" name="period" type="text" maxlength="40" placeholder="예: 약 3개월">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="team">현재 팀 구성</label>
|
||||
<input id="team" name="team" type="text" maxlength="80" placeholder="예: 기획 1명, 프로그래머 1명">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="contact">연락 방법</label>
|
||||
<input id="contact" name="contact" type="text" maxlength="100" placeholder="이메일, 디스코드, 오픈채팅 등" required>
|
||||
</div>
|
||||
<div class="field field--full">
|
||||
<label for="description">상세 설명</label>
|
||||
<textarea id="description" name="description" maxlength="1200" placeholder="프로젝트 소개, 원하는 역할, 작업 방식 등을 적어 주세요."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<a class="form-button" href="<%= ctx %>/recruit">취소</a>
|
||||
<button class="form-button form-button--primary" type="submit">미리보기 완료</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<aside class="preview-card" aria-label="모집글 미리보기">
|
||||
<p class="preview-card__label">미리보기</p>
|
||||
<span class="preview-card__role" id="preview-role">기획 모집</span>
|
||||
<h2 id="preview-name">프로젝트 이름</h2>
|
||||
<p class="preview-card__line" id="preview-summary">한 줄 소개가 여기에 표시됩니다.</p>
|
||||
<ul class="preview-card__meta">
|
||||
<li><strong>장르</strong> <span id="preview-genre">장르 미정</span></li>
|
||||
<li><strong>상태</strong> <span id="preview-status">아이디어 단계</span></li>
|
||||
<li><strong>참여</strong> <span id="preview-type">취미</span></li>
|
||||
<li><strong>기간</strong> <span id="preview-period">기간 미정</span></li>
|
||||
<li><strong>팀</strong> <span id="preview-team">팀 구성 미정</span></li>
|
||||
<li><strong>연락</strong> <span id="preview-contact">연락 방법 미정</span></li>
|
||||
</ul>
|
||||
<p class="preview-card__line" id="preview-description">상세 설명이 여기에 표시됩니다.</p>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
<jsp:include page="/WEB-INF/views/footer.jsp"/>
|
||||
<script>
|
||||
(function () {
|
||||
var pairs = [
|
||||
['project-name', 'preview-name', '프로젝트 이름'],
|
||||
['summary', 'preview-summary', '한 줄 소개가 여기에 표시됩니다.'],
|
||||
['genre', 'preview-genre', '장르 미정'],
|
||||
['status', 'preview-status', '진행 상태 미정'],
|
||||
['type', 'preview-type', '참여 방식 미정'],
|
||||
['period', 'preview-period', '기간 미정'],
|
||||
['team', 'preview-team', '팀 구성 미정'],
|
||||
['contact', 'preview-contact', '연락 방법 미정'],
|
||||
['description', 'preview-description', '상세 설명이 여기에 표시됩니다.']
|
||||
];
|
||||
var role = document.getElementById('role');
|
||||
var previewRole = document.getElementById('preview-role');
|
||||
var form = document.getElementById('recruit-form');
|
||||
|
||||
function valueOf(id) {
|
||||
var el = document.getElementById(id);
|
||||
return el ? el.value.trim() : '';
|
||||
}
|
||||
|
||||
function render() {
|
||||
pairs.forEach(function (pair) {
|
||||
var target = document.getElementById(pair[1]);
|
||||
if (target) target.textContent = valueOf(pair[0]) || pair[2];
|
||||
});
|
||||
if (previewRole) {
|
||||
previewRole.textContent = (valueOf('role') || '역할') + ' 모집';
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('#recruit-form input, #recruit-form select, #recruit-form textarea').forEach(function (el) {
|
||||
el.addEventListener('input', render);
|
||||
el.addEventListener('change', render);
|
||||
});
|
||||
|
||||
if (form) {
|
||||
form.addEventListener('submit', function (ev) {
|
||||
ev.preventDefault();
|
||||
if (!form.checkValidity()) {
|
||||
form.reportValidity();
|
||||
return;
|
||||
}
|
||||
|
||||
var body = new URLSearchParams(new FormData(form));
|
||||
fetch(form.action, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
body: body
|
||||
}).then(function (res) {
|
||||
return res.json().catch(function () {
|
||||
return { message: '모집글을 저장하지 못했습니다.' };
|
||||
}).then(function (data) {
|
||||
if (!res.ok) {
|
||||
var error = new Error(data && data.message ? data.message : '모집글을 저장하지 못했습니다.');
|
||||
error.status = res.status;
|
||||
throw error;
|
||||
}
|
||||
return data;
|
||||
});
|
||||
}).then(function (data) {
|
||||
var go = function () {
|
||||
window.location.href = '<%= ctx %>' + (data.location || '/recruit');
|
||||
};
|
||||
if (window.BibimbapModal && typeof window.BibimbapModal.alert === 'function') {
|
||||
window.BibimbapModal.alert({
|
||||
title: '모집글 등록 완료',
|
||||
message: '팀원 모집글이 등록되었습니다.',
|
||||
confirmText: '확인',
|
||||
onConfirm: go
|
||||
});
|
||||
} else {
|
||||
alert('팀원 모집글이 등록되었습니다.');
|
||||
go();
|
||||
}
|
||||
}).catch(function (err) {
|
||||
var message = err.message || '모집글을 저장하지 못했습니다.';
|
||||
if (window.BibimbapModal && typeof window.BibimbapModal.alert === 'function') {
|
||||
window.BibimbapModal.alert({
|
||||
title: '등록 실패',
|
||||
message: message,
|
||||
confirmText: '확인',
|
||||
onConfirm: function () {
|
||||
if (err.status === 401) {
|
||||
window.location.href = '<%= ctx %>/login';
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
alert(message);
|
||||
if (err.status === 401) {
|
||||
window.location.href = '<%= ctx %>/login';
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (role) render();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,364 @@
|
|||
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" language="java" %>
|
||||
<%@ page import="com.pandoli365.bibimbap.data.RecruitPostData" %>
|
||||
<%@ page import="org.springframework.web.util.HtmlUtils" %>
|
||||
<%@ page import="java.util.Collections" %>
|
||||
<%@ page import="java.util.List" %>
|
||||
<%
|
||||
String ctx = request.getContextPath();
|
||||
List<RecruitPostData> recruitPosts = Collections.emptyList();
|
||||
Object recruitPostsAttr = request.getAttribute("recruitPosts");
|
||||
if (recruitPostsAttr instanceof List<?>) {
|
||||
recruitPosts = (List<RecruitPostData>) recruitPostsAttr;
|
||||
}
|
||||
%>
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<jsp:include page="/WEB-INF/views/theme-init.jsp"/>
|
||||
<title>팀원 모집 | bibimbap</title>
|
||||
<style>
|
||||
html {
|
||||
color-scheme: light;
|
||||
--surface: #faf8f5;
|
||||
--card-bg: #fff;
|
||||
--text: #1a1a1a;
|
||||
--text-muted: #5c5c5c;
|
||||
--accent: #e8a54b;
|
||||
--accent-soft: rgba(232, 165, 75, 0.16);
|
||||
--border: rgba(0, 0, 0, 0.08);
|
||||
--shadow: rgba(0, 0, 0, 0.06);
|
||||
--field-bg: #fff;
|
||||
}
|
||||
html[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--surface: #121212;
|
||||
--card-bg: #1e1e1e;
|
||||
--text: #ece8e1;
|
||||
--text-muted: #a39e96;
|
||||
--border: rgba(255, 255, 255, 0.1);
|
||||
--shadow: rgba(0, 0, 0, 0.35);
|
||||
--field-bg: #181818;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans KR", sans-serif;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
}
|
||||
.recruit-page {
|
||||
max-width: 72rem;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem max(1rem, env(safe-area-inset-left)) 3rem max(1rem, env(safe-area-inset-right));
|
||||
}
|
||||
.recruit-hero {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.recruit-hero__eyebrow {
|
||||
margin: 0 0 0.35rem;
|
||||
color: var(--accent);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 900;
|
||||
}
|
||||
.recruit-hero h1 {
|
||||
margin: 0;
|
||||
font-size: 1.9rem;
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.recruit-hero p {
|
||||
margin: 0.45rem 0 0;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.recruit-write {
|
||||
min-height: 2.75rem;
|
||||
padding: 0 1rem;
|
||||
border-radius: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.45rem;
|
||||
background: var(--accent);
|
||||
color: #1a1a1a;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 900;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.recruit-toolbar {
|
||||
margin-bottom: 1.25rem;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.recruit-search {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.recruit-search input {
|
||||
width: 100%;
|
||||
height: 3rem;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 0 1rem;
|
||||
background: var(--field-bg);
|
||||
color: var(--text);
|
||||
font-size: 1rem;
|
||||
}
|
||||
.filter-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.filter-group__label {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
.filter-chip {
|
||||
min-height: 2.25rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 0 0.85rem;
|
||||
background: var(--card-bg);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
.filter-chip[aria-pressed="true"] {
|
||||
border-color: rgba(232, 165, 75, 0.65);
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
.recruit-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.recruit-card {
|
||||
min-height: 17rem;
|
||||
padding: 1.15rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
background: var(--card-bg);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
box-shadow: 0 2px 8px var(--shadow);
|
||||
}
|
||||
.recruit-card:hover {
|
||||
border-color: rgba(232, 165, 75, 0.45);
|
||||
box-shadow: 0 8px 20px var(--shadow);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.recruit-card__top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.recruit-card__role,
|
||||
.recruit-card__status {
|
||||
min-height: 1.75rem;
|
||||
padding: 0 0.55rem;
|
||||
border-radius: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 900;
|
||||
}
|
||||
.recruit-card__role {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
.recruit-card__status {
|
||||
background: var(--surface);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.recruit-card h2 {
|
||||
margin: 0;
|
||||
font-size: 1.15rem;
|
||||
line-height: 1.35;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.recruit-card__desc {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.recruit-card__meta {
|
||||
margin: auto 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.recruit-card__meta strong {
|
||||
color: var(--text);
|
||||
}
|
||||
.recruit-empty {
|
||||
min-height: 10rem;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 12px;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--card-bg);
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
.recruit-empty.is-visible {
|
||||
display: flex;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.recruit-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.recruit-hero {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
.recruit-write {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.recruit-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<jsp:include page="/WEB-INF/views/header.jsp"/>
|
||||
<main class="recruit-page">
|
||||
<section class="recruit-hero" aria-labelledby="recruit-title">
|
||||
<div>
|
||||
<p class="recruit-hero__eyebrow">INDIE GAME TEAM</p>
|
||||
<h1 id="recruit-title">함께 만들 팀원을 찾는 공간</h1>
|
||||
<p>기획, 아트, 프로그래머가 작은 프로젝트부터 출시 목표 팀까지 가볍게 만나는 게시판입니다.</p>
|
||||
</div>
|
||||
<a class="recruit-write" href="<%= ctx %>/recruit/new">모집글 작성</a>
|
||||
</section>
|
||||
|
||||
<section class="recruit-toolbar" aria-label="모집글 검색 및 필터">
|
||||
<div class="recruit-search">
|
||||
<input type="search" id="recruit-search" placeholder="프로젝트명, 장르, 소개 검색" autocomplete="off">
|
||||
</div>
|
||||
<div class="filter-group" data-filter-group="role">
|
||||
<span class="filter-group__label">역할</span>
|
||||
<button class="filter-chip" type="button" data-value="all" aria-pressed="true">전체</button>
|
||||
<button class="filter-chip" type="button" data-value="기획" aria-pressed="false">기획</button>
|
||||
<button class="filter-chip" type="button" data-value="아트" aria-pressed="false">아트</button>
|
||||
<button class="filter-chip" type="button" data-value="프로그래머" aria-pressed="false">프로그래머</button>
|
||||
</div>
|
||||
<div class="filter-group" data-filter-group="type">
|
||||
<span class="filter-group__label">참여 형태</span>
|
||||
<button class="filter-chip" type="button" data-value="all" aria-pressed="true">전체</button>
|
||||
<button class="filter-chip" type="button" data-value="취미" aria-pressed="false">취미</button>
|
||||
<button class="filter-chip" type="button" data-value="수익쉐어" aria-pressed="false">수익쉐어</button>
|
||||
<button class="filter-chip" type="button" data-value="유급" aria-pressed="false">유급</button>
|
||||
<button class="filter-chip" type="button" data-value="게임잼" aria-pressed="false">게임잼</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="recruit-grid" id="recruit-grid" aria-label="팀원 모집 목록">
|
||||
<%
|
||||
for (RecruitPostData post : recruitPosts) {
|
||||
if (post == null || post.getId() == null) {
|
||||
continue;
|
||||
}
|
||||
String role = HtmlUtils.htmlEscape(post.getRole() == null || post.getRole().isBlank() ? "역할" : post.getRole());
|
||||
String type = HtmlUtils.htmlEscape(post.getParticipationType() == null || post.getParticipationType().isBlank() ? "참여 방식 미정" : post.getParticipationType());
|
||||
String projectName = HtmlUtils.htmlEscape(post.getProjectName() == null || post.getProjectName().isBlank() ? "프로젝트 이름 없음" : post.getProjectName());
|
||||
String genre = HtmlUtils.htmlEscape(post.getGenre() == null || post.getGenre().isBlank() ? "장르 미정" : post.getGenre());
|
||||
String status = HtmlUtils.htmlEscape(post.getProjectStatus() == null || post.getProjectStatus().isBlank() ? "진행 상태 미정" : post.getProjectStatus());
|
||||
String summary = HtmlUtils.htmlEscape(post.getSummary() == null || post.getSummary().isBlank() ? "소개가 아직 없습니다." : post.getSummary());
|
||||
String searchText = HtmlUtils.htmlEscape(String.join(" ",
|
||||
projectName,
|
||||
genre,
|
||||
role,
|
||||
type,
|
||||
status,
|
||||
summary
|
||||
));
|
||||
%>
|
||||
<a class="recruit-card" href="<%= ctx %>/recruit/<%= post.getId() %>" data-role="<%= role %>" data-type="<%= type %>" data-search="<%= searchText %>">
|
||||
<div class="recruit-card__top">
|
||||
<span class="recruit-card__role"><%= role %> 모집</span>
|
||||
<span class="recruit-card__status">모집중</span>
|
||||
</div>
|
||||
<h2><%= projectName %></h2>
|
||||
<p class="recruit-card__desc"><%= summary %></p>
|
||||
<ul class="recruit-card__meta">
|
||||
<li><strong>장르</strong> <%= genre %></li>
|
||||
<li><strong>상태</strong> <%= status %></li>
|
||||
<li><strong>참여</strong> <%= type %></li>
|
||||
</ul>
|
||||
</a>
|
||||
<%
|
||||
}
|
||||
%>
|
||||
</section>
|
||||
<div class="recruit-empty <%= recruitPosts.isEmpty() ? "is-visible" : "" %>" id="recruit-empty">아직 올라온 팀원 모집글이 없습니다.</div>
|
||||
</main>
|
||||
<jsp:include page="/WEB-INF/views/footer.jsp"/>
|
||||
<script>
|
||||
(function () {
|
||||
var state = { role: 'all', type: 'all', query: '' };
|
||||
var cards = Array.prototype.slice.call(document.querySelectorAll('.recruit-card'));
|
||||
var empty = document.getElementById('recruit-empty');
|
||||
var search = document.getElementById('recruit-search');
|
||||
|
||||
function applyFilters() {
|
||||
var shown = 0;
|
||||
cards.forEach(function (card) {
|
||||
var roleOk = state.role === 'all' || card.getAttribute('data-role') === state.role;
|
||||
var typeOk = state.type === 'all' || card.getAttribute('data-type') === state.type;
|
||||
var haystack = (card.getAttribute('data-search') || '').toLowerCase();
|
||||
var queryOk = !state.query || haystack.indexOf(state.query.toLowerCase()) !== -1;
|
||||
var visible = roleOk && typeOk && queryOk;
|
||||
card.hidden = !visible;
|
||||
if (visible) shown += 1;
|
||||
});
|
||||
empty.classList.toggle('is-visible', shown === 0);
|
||||
}
|
||||
|
||||
document.querySelectorAll('.filter-group').forEach(function (group) {
|
||||
group.addEventListener('click', function (ev) {
|
||||
var btn = ev.target.closest('.filter-chip');
|
||||
if (!btn) return;
|
||||
var name = group.getAttribute('data-filter-group');
|
||||
state[name] = btn.getAttribute('data-value');
|
||||
group.querySelectorAll('.filter-chip').forEach(function (chip) {
|
||||
chip.setAttribute('aria-pressed', chip === btn ? 'true' : 'false');
|
||||
});
|
||||
applyFilters();
|
||||
});
|
||||
});
|
||||
|
||||
if (search) {
|
||||
search.addEventListener('input', function () {
|
||||
state.query = search.value.trim();
|
||||
applyFilters();
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue