비빕밥 팀원 모집 페이지 작업

This commit is contained in:
pandoli365 2026-06-13 14:43:33 +09:00
parent 8f6a49cb54
commit 2cca6c72a6
8 changed files with 1696 additions and 0 deletions

View File

@ -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 '소프트 삭제 여부';

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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();
}

View File

@ -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="테마 전환">
<%-- 라이트 모드일 때 달(다크로), 다크 모드일 때 해(라이트로) --%>

View File

@ -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>

View File

@ -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>

View File

@ -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>