Add Next.js + PostgreSQL rewrite scaffold with 4-theme system
Stack
- Next.js 15 (App Router) + TypeScript + Drizzle ORM + postgres-js
- Node scrypt for password hashing; PBKDF2 verifier for legacy gnuboard5 hashes
- pnpm workspace monorepo: apps/web + packages/{db,auth,themes,...}
Themes (admin-selectable at /admin/themes)
- basic : 그누보드 default reproduction (light, blue accent)
- eyoom : eb4_maga_005 매거진 reproduction (dark, orange accent, ranking sidebar)
- amina : Aminam Builder reproduction (light, violet gradient, card grid)
- youngcart : 영카트 shop reproduction (red accent, search bar, category nav)
DB
- New schema (12 tables) pushed to PG via drizzle-kit: members, sessions, boards,
posts, point_ledger, app_settings, bacara_*, lottery_tickets, roulette_spins,
game_points, board_groups
- Legacy data still readable from inspection2 schema via @slot/db/legacy
Verified end-to-end against the migrated DB on localhost:3000:
- Home renders with active theme tokens injected as CSS variables
- /free lists 442K real posts from inspection2.g5_write_free
- Login (testlogin/test1234) issues session cookie, header switches to
"테스트님 환영합니다 / 로그아웃"
- Switching app_settings.theme.global from eyoom → amina swaps colors,
layout, and Korean nav labels site-wide on next request
Migration docs added: 03-migration-plan, 04-theme-architecture,
05-local-dev-setup, 06-feature-inventory.
This commit is contained in:
@@ -0,0 +1,212 @@
|
|||||||
|
# Migration Plan — gnuboard5(PHP) → Next.js + Node.js + PostgreSQL
|
||||||
|
|
||||||
|
## 0. 목표
|
||||||
|
1. 기존 사이트(`slot-ss.com`) 의 **모든 기능을 보존**한 채 stack 을 PHP → Node/Next 로 전면 교체.
|
||||||
|
2. **테마 시스템을 빌트인** — 운영자가 관리자 화면에서 다음 4종 중 하나를 선택할 수 있어야 함:
|
||||||
|
- **기본** (그누보드 default 룩앤필 reproduction)
|
||||||
|
- **이윰빌더** (현재 운영 중인 `eb4_maga_005` 매거진 레이아웃 reproduction)
|
||||||
|
- **아미나빌더** (그누보드5 의 또 다른 인기 빌더 — Aminam Builder 룩앤필)
|
||||||
|
- **영카드(YoungCart)** (영카트 쇼핑몰 중심 레이아웃)
|
||||||
|
3. PostgreSQL 단일 DB. 회원/포인트/게시판/쇼핑/베팅/검수 모두 통합.
|
||||||
|
4. 운영 가능한 마이그레이션 체크리스트와 롤백 경로 제공.
|
||||||
|
|
||||||
|
## 1. 신규 스택
|
||||||
|
|
||||||
|
| 레이어 | 기술 | 용도 |
|
||||||
|
|--------|------|------|
|
||||||
|
| Web | **Next.js 15** (App Router, RSC) + TypeScript | SSR/ISR 페이지, SEO, 4테마 라우팅 |
|
||||||
|
| API | **Hono** (Node.js, edge-friendly) — 또는 Next.js Route Handler | REST + WebSocket 핸들러 |
|
||||||
|
| Auth | **NextAuth.js v5 (Auth.js)** | 세션, 소셜 로그인, 2FA |
|
||||||
|
| ORM | **Drizzle ORM** | TypeScript 타입 안전, raw SQL 친화 |
|
||||||
|
| DB | **PostgreSQL 17** | 메인 데이터 |
|
||||||
|
| Cache | **Redis 7** | 세션, 캐시, 랭킹, rate-limit |
|
||||||
|
| Queue | **BullMQ** (Redis backed) | 27개 그누보드 cron 대체 |
|
||||||
|
| Object Storage | **S3 호환** (R2/MinIO/AWS) | 첨부파일·에디터 이미지 (현재 82.5GB) |
|
||||||
|
| Search | **Meilisearch** (옵션) 또는 PostgreSQL FTS | 게시판/회원 검색 (현재 sphinx 대체) |
|
||||||
|
| Realtime | **Socket.IO** 또는 Pusher | 바카라/룰렛 실시간, 알림 |
|
||||||
|
| 결제 | KCP / 이니시스 / LG U+ Node SDK 또는 직접 통신 | 기존 4종 게이트웨이 대응 |
|
||||||
|
| 본인인증 | OK-Name / iNICert / Naver/Kakao API | 본인인증 |
|
||||||
|
| Deploy | Docker + (Vercel | Coolify | Self-hosted) | — |
|
||||||
|
|
||||||
|
## 2. 모노레포 레이아웃 (pnpm workspace + Turborepo)
|
||||||
|
|
||||||
|
```
|
||||||
|
slot/
|
||||||
|
├── apps/
|
||||||
|
│ ├── web/ # Next.js 사용자 사이트
|
||||||
|
│ ├── admin/ # Next.js 관리자 (별도 호스트 권장)
|
||||||
|
│ └── api/ # Hono API + WebSocket
|
||||||
|
├── packages/
|
||||||
|
│ ├── db/ # Drizzle 스키마, 마이그레이션, 시드
|
||||||
|
│ ├── auth/ # 인증 헬퍼, PBKDF2 검증, NextAuth 설정
|
||||||
|
│ ├── themes/ # 4종 테마 — basic/eyoom/amina/youngcart
|
||||||
|
│ ├── ui/ # 공통 컴포넌트 (Button, Modal, Editor, Pagination, ...)
|
||||||
|
│ ├── shop/ # 쇼핑몰 도메인 (item, cart, order, payment)
|
||||||
|
│ ├── games/ # 바카라/룰렛/복권/슬롯 코어 + Swiun API
|
||||||
|
│ ├── shared/ # 타입, 유틸, validators (zod)
|
||||||
|
│ └── workers/ # BullMQ 워커 (rank-up, point-dist, sms)
|
||||||
|
└── ops/
|
||||||
|
├── docker/ # docker-compose, Dockerfiles
|
||||||
|
└── pgloader/ # 기존 db/migrate_*.load 이전
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. URL 호환성
|
||||||
|
|
||||||
|
기존 `.htaccess` 가 정의한 라우트는 그대로 유지 (SEO 보존):
|
||||||
|
|
||||||
|
| 기존 패턴 | Next.js 라우트 |
|
||||||
|
|-----------|---------------|
|
||||||
|
| `/` | `/` |
|
||||||
|
| `/<bo_table>` | `/[boTable]` |
|
||||||
|
| `/<bo_table>/<wr_id>` | `/[boTable]/[wrId]` |
|
||||||
|
| `/group/<gr_id>` | `/group/[grId]` |
|
||||||
|
| `/page/<pid>` | `/page/[pid]` |
|
||||||
|
| `/content/<co_id>` | `/content/[coId]` |
|
||||||
|
| `/mypage/<t>` | `/mypage/[tab]` |
|
||||||
|
| `/shop/list-<ca_id>` | `/shop/list/[caId]` |
|
||||||
|
| `/shop/brand-<br_cd>` | `/shop/brand/[brCd]` |
|
||||||
|
| `/shop/<it_id>` | `/shop/[itId]` |
|
||||||
|
| `/adm/*` | `/admin/*` (별도 admin 앱) |
|
||||||
|
| `/bbs/login.php` | `/login` |
|
||||||
|
| `/bbs/board.php?bo_table=X` | `/[X]` (rewrite) |
|
||||||
|
|
||||||
|
기존 PHP URL(`/bbs/board.php?bo_table=free`)에 대해서는 미들웨어에서 새 URL로 301 리다이렉트.
|
||||||
|
|
||||||
|
## 4. 데이터 마이그레이션
|
||||||
|
|
||||||
|
1. **이미 완료**: MariaDB → PostgreSQL (pgloader, schema=`inspection2`)
|
||||||
|
2. **다음 단계**:
|
||||||
|
- `packages/db/schema/*.ts` — Drizzle 스키마를 신규 design 으로 정의
|
||||||
|
- `g5_member` → `members`
|
||||||
|
- `g5_board` + `g5_write_<bo_table>` → `boards` + 단일 `posts` 테이블 + `comments` 테이블 (write 테이블 분리 구조 폐기)
|
||||||
|
- `g5_point` → `point_ledger`
|
||||||
|
- `g5_visit` → `visits` (또는 ClickHouse / 시계열 DB 로 분리)
|
||||||
|
- 게임/베팅/검수/SMS/챗봇 → 각각 도메인 패키지로
|
||||||
|
- `packages/db/migrate-from-legacy/*.ts` — 운영 데이터를 신규 스키마로 ETL 변환
|
||||||
|
- 36개 `g5_write_<X>` 테이블을 단일 `posts` 로 union (board_id 컬럼 추가)
|
||||||
|
- PBKDF2 비밀번호 해시는 그대로 유지 (legacy verifier로 검증, 첫 로그인 시 bcrypt 로 자동 업그레이드)
|
||||||
|
3. **검증 쿼리**: 기존 vs 신규 row count, sum(mb_point), 활성 회원 수 등 매칭 확인.
|
||||||
|
|
||||||
|
## 5. 인증·세션 호환
|
||||||
|
|
||||||
|
### 5.1 비밀번호 호환
|
||||||
|
- 그누보드 PBKDF2 (sha256, 12000 iter, 24-byte salt+hash) 검증을 그대로 구현 (`packages/auth/legacy-pbkdf2.ts`)
|
||||||
|
- 신규 가입자는 `bcrypt` 또는 `argon2id` 사용
|
||||||
|
- 기존 회원이 첫 로그인 성공 시: PBKDF2 검증 후 신규 알고리즘으로 rehash (자동 업그레이드)
|
||||||
|
|
||||||
|
### 5.2 세션
|
||||||
|
- NextAuth Database session (PostgreSQL) — Redis 캐시
|
||||||
|
- `mb_id` 기반 식별자 → 신규 `user_id` (UUID v7) 매핑 테이블 유지
|
||||||
|
|
||||||
|
### 5.3 2FA (ask-otp 호환)
|
||||||
|
- 관리자 admin 계정 OTP 그대로 유지
|
||||||
|
- TOTP (Google Authenticator) 표준 사용
|
||||||
|
|
||||||
|
## 6. 게시판 모델 통합
|
||||||
|
|
||||||
|
기존: 게시판마다 `g5_write_<bo_table>` 테이블 분리 (스키마는 동일)
|
||||||
|
신규: 단일 `posts` 테이블 + `board_id` 외래키
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE posts (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
board_id TEXT NOT NULL REFERENCES boards(slug),
|
||||||
|
parent_id UUID, -- 댓글이면 부모 글 id
|
||||||
|
reply_path TEXT, -- gnuboard wr_reply 호환 (중첩 댓글 정렬)
|
||||||
|
num BIGINT, -- 글 그룹 (정렬용, gnuboard wr_num 호환)
|
||||||
|
author_id UUID REFERENCES members(id),
|
||||||
|
author_name TEXT, -- 비회원 글
|
||||||
|
subject TEXT,
|
||||||
|
content TEXT,
|
||||||
|
attachments JSONB, -- file metadata
|
||||||
|
hit INTEGER DEFAULT 0,
|
||||||
|
good INTEGER DEFAULT 0,
|
||||||
|
bad INTEGER DEFAULT 0,
|
||||||
|
is_comment BOOLEAN DEFAULT FALSE,
|
||||||
|
is_secret BOOLEAN DEFAULT FALSE,
|
||||||
|
ip INET,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
CREATE INDEX posts_board_idx ON posts (board_id, created_at DESC);
|
||||||
|
CREATE INDEX posts_board_thread_idx ON posts (board_id, num, reply_path);
|
||||||
|
CREATE INDEX posts_author_idx ON posts (author_id, created_at DESC);
|
||||||
|
```
|
||||||
|
|
||||||
|
PostgreSQL 의 partial index, partition, JSONB 활용으로 36개 테이블 union 후에도 성능 무리 없음.
|
||||||
|
초대형 게시판 (`free` 442K rows) 은 **list partition** 으로 분할 가능.
|
||||||
|
|
||||||
|
## 7. 슬롯/카지노 도메인
|
||||||
|
|
||||||
|
### 7.1 게임 포인트
|
||||||
|
- `point_ledger` (append-only) — 적립/사용/환전 내역
|
||||||
|
- 트리거 또는 워커가 회원 잔고 (`members.point_balance`) 갱신
|
||||||
|
- 운영 DB 의 `g5_point` (594만 행) → 그대로 임포트
|
||||||
|
|
||||||
|
### 7.2 슬롯 베팅 (Swiun API 브릿지)
|
||||||
|
- `apps/api/src/routes/swiun.ts` 가 외부 슬롯사 API 와 통신
|
||||||
|
- WebSocket 으로 실시간 베팅 결과 푸시
|
||||||
|
- 기존 `plugin/swiunApi/*.php` 의 로직 1:1 이전
|
||||||
|
|
||||||
|
### 7.3 바카라
|
||||||
|
- 기존 `plugin/bacara/*.php` → `packages/games/bacara/*`
|
||||||
|
- 게임 룰: 베팅(Player/Banker/Tie) + 결과 + 정산
|
||||||
|
- 실시간 페이지: Socket.IO 룸 단위 브로드캐스트
|
||||||
|
|
||||||
|
### 7.4 룰렛
|
||||||
|
- 기존 `roulette/index.php` 의 휠 게임 → React + Framer Motion 으로 재구현
|
||||||
|
- 회당 1회 무료 + 추가 회전은 포인트 차감
|
||||||
|
|
||||||
|
### 7.5 복권
|
||||||
|
- `g5_write_lottery_ticket` (45,724건) → `lottery_tickets` 테이블
|
||||||
|
- 응모 → 추첨 (cron) → 당첨자 통보 → 포인트 지급
|
||||||
|
|
||||||
|
### 7.6 블랙리스트 / 검수
|
||||||
|
- `blacklist_table` (193K), `check_table` (359K) → `blacklist`, `inspection_logs`
|
||||||
|
- 관리자가 ID/IP/회원명 검색 가능
|
||||||
|
|
||||||
|
### 7.7 챗봇
|
||||||
|
- `chatbot_conversations`, `chatbot_feedback` → `chatbot_*`
|
||||||
|
- OpenAI 또는 Anthropic API 연동 (`apps/api/src/routes/chatbot.ts`)
|
||||||
|
|
||||||
|
## 8. 27개 cron → BullMQ 워커
|
||||||
|
|
||||||
|
기존 `plugin/cron/auto.*rankup.php` 27개 + 기타 cron 들을:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// packages/workers/jobs/rank-update.ts
|
||||||
|
queue.add('rank.update', { game: 'bacara' }, { repeat: { cron: '*/5 * * * *' } });
|
||||||
|
queue.add('rank.update', { game: 'slots' }, { repeat: { cron: '*/5 * * * *' } });
|
||||||
|
// ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. 첨부파일 (총 82.5GB)
|
||||||
|
|
||||||
|
- 운영 서버에서 R2/S3 로 일회성 업로드 (rclone)
|
||||||
|
- 신규 시스템은 처음부터 직접 S3 에 저장
|
||||||
|
- DB 의 `g5_board_file.bf_file` 경로 → S3 키로 변환
|
||||||
|
- 마이그레이션 동안 폴백: missing key → 운영 서버 origin 으로 프록시
|
||||||
|
|
||||||
|
## 10. 단계별 마일스톤
|
||||||
|
|
||||||
|
| 단계 | 산출물 | 검증 |
|
||||||
|
|------|-------|------|
|
||||||
|
| **M0** (완료) | 소스 + DB 로컬 사본, PostgreSQL 셋업, 로컬 PHP 정상 가동 | 로그인 OK |
|
||||||
|
| **M1** (1~2주) | 모노레포 스캐폴딩, Drizzle 스키마, ETL 첫 패스 (members, posts, point), Next.js 기본 셸, 4-테마 토글 | 회원 로그인 (PBKDF2), 자유게시판 list/view |
|
||||||
|
| **M2** (3~4주) | 게시판 글쓰기·댓글·첨부, 마이페이지, 포인트, 회원 가입/탈퇴, 알림 | 핵심 게시판 동작 |
|
||||||
|
| **M3** (5~6주) | 슬롯/바카라/룰렛/복권 기능 이전, BullMQ 워커, 챗봇 | 게임 정상 동작 |
|
||||||
|
| **M4** (7~8주) | 영카트 쇼핑몰, 결제 게이트웨이, SMS, 본인인증, 소셜 로그인 | 주문/결제/SMS 검증 |
|
||||||
|
| **M5** (9~10주) | 관리자 페이지 (회원/게시판/베팅/룰렛 관리, **테마 선택 UI**) | 관리자 OTP 로그인 + 일상 운영 가능 |
|
||||||
|
| **M6** (11~12주) | 첨부파일 S3 이전, SEO 검사, 성능 부하 테스트 (k6), 보안 감사 | Lighthouse 90+ / 부하 테스트 통과 |
|
||||||
|
| **M7** (13주) | 카나리 트래픽 5% → 50% → 100% 절체. DNS cutover. | 라이브 |
|
||||||
|
|
||||||
|
## 11. 롤백
|
||||||
|
- 신규 시스템 cutover 후 **6주간** 기존 PHP 인프라 hot-standby 유지
|
||||||
|
- DNS TTL 60s → 4시간 단계 상승
|
||||||
|
- DB는 신규(PostgreSQL) → 구(MariaDB) 역방향 CDC (Debezium 또는 logical replication adapter) — 비상 시 MariaDB 로 1시간 내 복귀 가능
|
||||||
|
|
||||||
|
## 12. 보안 사항
|
||||||
|
- 운영 DB 패스워드 (`iiOii5*^^*`), 토큰 키 (`ac57f676fe741f0ab3471d81dbee3bf1`), 서버 root 패스워드 모두 **신규 시스템 첫 배포 시 회전**
|
||||||
|
- ENV 변수는 `.env.local` 또는 Doppler / 1Password / AWS Secrets Manager 로 관리
|
||||||
|
- 결제·본인인증 키는 운영 환경에만 주입
|
||||||
|
- WAF (Cloudflare) + rate-limit (Hono middleware + Redis)
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
# Theme Architecture — 4-Theme Built-in System
|
||||||
|
|
||||||
|
## 0. 요구사항 (사용자 명시)
|
||||||
|
> "테마 관리에서 리액트 기반 한 가지 — **기본 / 이윰빌더 / 아미나빌더 / 영카드** — 이렇게 선택할 수 있게 구현"
|
||||||
|
|
||||||
|
→ 운영자가 어드민 화면에서 사이트 전체 테마를 한 번에 전환 가능. 페이지 단위 / 게시판 단위 오버라이드도 지원.
|
||||||
|
|
||||||
|
## 1. 4종 테마 정의
|
||||||
|
|
||||||
|
| 코드 | 한글명 | 모티프 | 활용 시나리오 |
|
||||||
|
|------|--------|--------|--------------|
|
||||||
|
| `basic` | **기본** | 그누보드5 default skin (responsive bootstrap-ish) | 미니멀, 신규 도메인 시작 |
|
||||||
|
| `eyoom` | **이윰빌더** | 현재 운영 중인 `eb4_maga_005` (매거진 + 사이드바 + 위젯) | 컨텐츠 중심 커뮤니티 |
|
||||||
|
| `amina` | **아미나빌더** | 그누보드용 Amina Builder (모던 카드형 + 메가메뉴) | 트래픽 큰 종합 커뮤니티 |
|
||||||
|
| `youngcart` | **영카드(영카트)** | 영카트 쇼핑몰 + 게시판 + 마이페이지 | 쇼핑 중심 사이트 |
|
||||||
|
|
||||||
|
## 2. 패키지 구조 — `packages/themes/`
|
||||||
|
|
||||||
|
```
|
||||||
|
packages/themes/
|
||||||
|
├── core/ # 테마 공통 인터페이스
|
||||||
|
│ ├── ThemeProvider.tsx # context, 토큰, css var 주입
|
||||||
|
│ ├── ThemeRegistry.ts # 모든 테마 메타데이터 + lazy loader
|
||||||
|
│ ├── types.ts # ThemeManifest, Slot, Layout 타입
|
||||||
|
│ └── slots.ts # Header/Footer/Side/Banner/PostList/PostView 슬롯
|
||||||
|
├── basic/
|
||||||
|
│ ├── manifest.ts
|
||||||
|
│ ├── tokens.ts # CSS variables (--color-primary, --space-md, etc.)
|
||||||
|
│ ├── layouts/{root,board,post,shop}.tsx
|
||||||
|
│ ├── components/{Header,Footer,Sidebar,PostCard,...}.tsx
|
||||||
|
│ ├── styles/ # tailwind.config 또는 sass
|
||||||
|
│ └── index.ts # default export = ThemeManifest
|
||||||
|
├── eyoom/
|
||||||
|
│ ├── manifest.ts
|
||||||
|
│ ├── tokens.ts # 이윰 매거진 컬러/타이포 (참고: src/theme/eb4_maga_005)
|
||||||
|
│ ├── layouts/...
|
||||||
|
│ ├── components/{MegaMenu,RankingSidebar,LatestWidget,...}.tsx
|
||||||
|
│ └── index.ts
|
||||||
|
├── amina/ # Aminam Builder reproduction
|
||||||
|
│ └── ...
|
||||||
|
└── youngcart/ # YoungCart shop-centric
|
||||||
|
├── components/{ProductGrid,CartDrawer,CategoryNav,...}.tsx
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 테마 인터페이스
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// packages/themes/core/types.ts
|
||||||
|
export interface ThemeManifest {
|
||||||
|
id: 'basic' | 'eyoom' | 'amina' | 'youngcart';
|
||||||
|
name: string; // 한글 표시명
|
||||||
|
preview: string; // 썸네일 URL
|
||||||
|
tokens: ThemeTokens; // CSS 변수 (런타임 주입)
|
||||||
|
layouts: {
|
||||||
|
root: ComponentType<LayoutProps>;
|
||||||
|
board: ComponentType<BoardLayoutProps>;
|
||||||
|
post: ComponentType<PostLayoutProps>;
|
||||||
|
shop: ComponentType<ShopLayoutProps>;
|
||||||
|
page: ComponentType<PageLayoutProps>;
|
||||||
|
};
|
||||||
|
slots: {
|
||||||
|
Header: ComponentType<HeaderProps>;
|
||||||
|
Footer: ComponentType<FooterProps>;
|
||||||
|
Sidebar: ComponentType<SidebarProps>;
|
||||||
|
PostList: ComponentType<PostListProps>;
|
||||||
|
PostCard: ComponentType<PostCardProps>;
|
||||||
|
PostView: ComponentType<PostViewProps>;
|
||||||
|
LoginForm: ComponentType<LoginFormProps>;
|
||||||
|
NewWindow: ComponentType<NewWindowProps>;
|
||||||
|
// ...20개 정도 슬롯 표준화
|
||||||
|
};
|
||||||
|
features?: {
|
||||||
|
shop?: boolean; // youngcart 만 true
|
||||||
|
magazineWidgets?: boolean; // eyoom 만 true
|
||||||
|
megaMenu?: boolean; // amina, eyoom
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Slot/Override 메커니즘
|
||||||
|
|
||||||
|
### 4.1 글로벌 테마
|
||||||
|
- `app_settings.theme = 'eyoom'` (DB) 가 활성 테마
|
||||||
|
- `apps/web/src/app/layout.tsx` 가 `ThemeRegistry.load(activeTheme)` 으로 동적 import
|
||||||
|
- React Server Component 단계에서 결정 → 첫 페인트부터 올바른 레이아웃
|
||||||
|
|
||||||
|
### 4.2 페이지 단위 override
|
||||||
|
- 게시판/카테고리/페이지에 `theme_override` 컬럼
|
||||||
|
- 예: 자유게시판은 `eyoom`, 쇼핑은 `youngcart` 동시 운영 가능
|
||||||
|
|
||||||
|
### 4.3 사용자 토글 (옵션)
|
||||||
|
- 회원이 `mb_theme_pref` 로 본인 선호 테마 선택 가능
|
||||||
|
- cookie + DB 저장
|
||||||
|
- 관리자가 기능 on/off 제어
|
||||||
|
|
||||||
|
## 5. 관리자 — 테마 선택 UI
|
||||||
|
|
||||||
|
`apps/admin/src/app/themes/page.tsx`:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ 테마 관리 ──────────────────────────────┐
|
||||||
|
│ [기본] [이윰빌더] [아미나빌더] [영카드] │ ← 4개 카드, 활성 테마 강조
|
||||||
|
│ │
|
||||||
|
│ ◉ 기본 사이트 테마: [이윰빌더 ▼] │
|
||||||
|
│ │
|
||||||
|
│ ─ 영역별 오버라이드 ─ │
|
||||||
|
│ · 자유게시판: (글로벌) │
|
||||||
|
│ · 쇼핑몰: 영카드 │
|
||||||
|
│ · /event 페이지: 아미나빌더 │
|
||||||
|
│ │
|
||||||
|
│ ─ 미리보기 ─ │
|
||||||
|
│ https://localhost:3000/?_preview=amina │
|
||||||
|
│ │
|
||||||
|
│ [저장] [기본값 복원] │
|
||||||
|
└──────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
DB:
|
||||||
|
```sql
|
||||||
|
CREATE TABLE app_settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value JSONB
|
||||||
|
);
|
||||||
|
INSERT INTO app_settings VALUES
|
||||||
|
('theme.global', '"eyoom"'::jsonb),
|
||||||
|
('theme.overrides', '[{"path":"/shop/*","theme":"youngcart"}]');
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 디자인 토큰 표준
|
||||||
|
|
||||||
|
각 테마는 동일한 토큰 스키마를 만족 → CSS 변수로 주입 → 컴포넌트는 토큰만 참조 (매직 컬러 금지):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// tokens.ts (예: eyoom)
|
||||||
|
export const tokens: ThemeTokens = {
|
||||||
|
color: {
|
||||||
|
primary: '#ff5722',
|
||||||
|
primaryDark: '#e64a19',
|
||||||
|
bg: '#0e0f12',
|
||||||
|
bgSurface: '#16181d',
|
||||||
|
text: '#e6e6e6',
|
||||||
|
textMuted: '#9aa0a6',
|
||||||
|
border: '#2a2d34',
|
||||||
|
success: '#21d07a',
|
||||||
|
danger: '#e74c3c',
|
||||||
|
warning: '#f5b041',
|
||||||
|
},
|
||||||
|
font: {
|
||||||
|
sans: '"Noto Sans KR", system-ui, sans-serif',
|
||||||
|
mono: '"JetBrains Mono", monospace',
|
||||||
|
},
|
||||||
|
radius: { sm: '4px', md: '8px', lg: '14px', pill: '999px' },
|
||||||
|
space: { xs: '4px', sm: '8px', md: '16px', lg: '24px', xl: '40px' },
|
||||||
|
shadow: { sm: '...', md: '...', lg: '...' },
|
||||||
|
z: { sticky: 100, modal: 1000, toast: 2000 },
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
각 테마는 동일 키를 채우되 값만 다르게 (basic 은 라이트, eyoom 은 다크 매거진, amina 는 그라디언트, youngcart 는 화이트 + 액센트)
|
||||||
|
|
||||||
|
## 7. 마이그레이션 매핑 (이윰 → React)
|
||||||
|
|
||||||
|
| 기존 (`theme/eb4_maga_005/`) | 신규 (`packages/themes/eyoom/`) |
|
||||||
|
|-----------------------------|-------------------------------|
|
||||||
|
| `head.html.php` | `layouts/root.tsx` (Header) |
|
||||||
|
| `head.sub.html.php` | `slots/Header.tsx` |
|
||||||
|
| `tail.html.php` | `slots/Footer.tsx` |
|
||||||
|
| `side.html.php` | `slots/Sidebar.tsx` |
|
||||||
|
| `index.html.php` | `layouts/root.tsx` (홈 위젯) |
|
||||||
|
| `skin/board/<X>/list.skin.html.php` | `slots/PostList.tsx` |
|
||||||
|
| `skin/board/<X>/view.skin.html.php` | `slots/PostView.tsx` |
|
||||||
|
| `css/eyoom.css`, `css/eyoom-style.css` | `styles/*.css` (CSS module 또는 tailwind) |
|
||||||
|
| `js/eyoom.js` | React hooks / event handlers |
|
||||||
|
|
||||||
|
### 매거진 위젯 (이윰 코어)
|
||||||
|
- 최신글 슬라이더 (`eyoom_slider`) → `<LatestSlider />`
|
||||||
|
- 회원 랭킹 (`eyoom_ranking`) → `<MemberRanking type="bacara|slots|..." />`
|
||||||
|
- 출석 (`eyoom_attendance`) → `<AttendanceWidget />`
|
||||||
|
- 배너 (`eyoom_banner`) → `<RotatingBanner />`
|
||||||
|
|
||||||
|
## 8. 아미나빌더 디자인 노트
|
||||||
|
- 메가메뉴 (1단 큰 카테고리 + 2단 서브)
|
||||||
|
- 카드형 그리드 (3열 → 모바일 1열)
|
||||||
|
- 섹션별 스킨 시스템 (홈에 여러 게시판 위젯 동시 렌더)
|
||||||
|
- 라이트/다크 테마 토글
|
||||||
|
- 인기 회원 랭킹, 실시간 댓글, 인기글 사이드바
|
||||||
|
|
||||||
|
## 9. 영카드 디자인 노트
|
||||||
|
- 상단 카테고리 가로 네비
|
||||||
|
- 메인 = 베스트 상품 그리드 + 쿠폰존 + 이벤트 슬라이더
|
||||||
|
- 마이페이지: 주문/배송/쿠폰/적립금
|
||||||
|
- 게시판은 영카트 기본 룩 (테이블형 리스트)
|
||||||
|
|
||||||
|
## 10. 신규 테마 추가 절차 (확장성)
|
||||||
|
1. `packages/themes/<id>/` 폴더 생성, manifest 작성
|
||||||
|
2. `ThemeRegistry.register({ id: '<id>', loader: () => import('./<id>') })` 추가
|
||||||
|
3. `app_settings.theme.global` 후보에 자동 노출
|
||||||
|
4. 별도 빌드 없이 hot reload (Next.js dev 모드)
|
||||||
|
|
||||||
|
## 11. 성능
|
||||||
|
- 각 테마 번들은 lazy chunk → 활성 테마만 다운로드
|
||||||
|
- 테마 토큰은 `<style>:root{--color-primary: ...}</style>` 인라인 → FOUC 방지
|
||||||
|
- RSC 단에서 테마 결정 → 클라이언트 hydration 최소화
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
# Local Development Setup
|
||||||
|
|
||||||
|
## 0. 사전조건
|
||||||
|
- macOS / Linux
|
||||||
|
- **Docker Desktop** 실행 중
|
||||||
|
- **Homebrew** (PostgreSQL, pgloader 용)
|
||||||
|
- **Node.js 20+**, **pnpm** (신규 시스템 작업 시)
|
||||||
|
|
||||||
|
## 1. 원본 PHP 사이트 로컬 가동
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 0) 사전: Docker 데스크톱 실행 / 5432 PostgreSQL 사용 가능 / 33306 미사용
|
||||||
|
brew install postgresql@17 pgloader
|
||||||
|
brew services start postgresql@17
|
||||||
|
|
||||||
|
# 1) (한 번만) MariaDB 임시 컨테이너 띄우기
|
||||||
|
docker network create slot-net 2>/dev/null
|
||||||
|
docker run -d --name slot-mariadb \
|
||||||
|
--network slot-net \
|
||||||
|
-e MARIADB_ROOT_PASSWORD=rootpass \
|
||||||
|
-p 33306:3306 \
|
||||||
|
-v slot-mariadb-data:/var/lib/mysql \
|
||||||
|
--restart unless-stopped \
|
||||||
|
mariadb:10.5 \
|
||||||
|
--character-set-server=utf8mb4 \
|
||||||
|
--collation-server=utf8mb4_unicode_ci \
|
||||||
|
--max-allowed-packet=512M \
|
||||||
|
--innodb-buffer-pool-size=1G
|
||||||
|
|
||||||
|
# 2) DB dump 복원 (db/inspection2.sql.gz, db/inspection.sql.gz 있어야 함)
|
||||||
|
docker cp db/inspection2.sql.gz slot-mariadb:/tmp/
|
||||||
|
docker cp db/inspection.sql.gz slot-mariadb:/tmp/
|
||||||
|
docker exec slot-mariadb sh -c "mariadb -uroot -prootpass -e 'CREATE DATABASE inspection2 DEFAULT CHARACTER SET utf8mb4; CREATE DATABASE inspection DEFAULT CHARACTER SET utf8mb4;'"
|
||||||
|
docker exec slot-mariadb sh -c "gunzip -c /tmp/inspection2.sql.gz | mariadb -uroot -prootpass --default-character-set=utf8mb4 inspection2"
|
||||||
|
docker exec slot-mariadb sh -c "gunzip -c /tmp/inspection.sql.gz | mariadb -uroot -prootpass --default-character-set=utf8mb4 inspection"
|
||||||
|
|
||||||
|
# 3) PHP + Apache + Redis 컨테이너 가동
|
||||||
|
cd docker && docker compose up -d
|
||||||
|
|
||||||
|
# 첫 실행 시 entrypoint 가 ~5분 정도 걸린다 (apt-get + composer install).
|
||||||
|
# 진행 상태: docker logs -f slot-php
|
||||||
|
|
||||||
|
# 4) 사이트 접속
|
||||||
|
open http://localhost:8088/
|
||||||
|
open http://localhost:8088/adm/ # 관리자 (admin 계정 OTP 필요)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.1 테스트 계정 (로컬에서만 사용)
|
||||||
|
원본 dump 의 admin 비밀번호는 알 수 없으므로 로컬 검증용 계정을 만들어 사용:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# PBKDF2 해시 생성
|
||||||
|
docker exec slot-php php -r '
|
||||||
|
define("_GNUBOARD_", true);
|
||||||
|
include_once("/var/www/html/lib/pbkdf2.compat.php");
|
||||||
|
echo create_hash("test1234"); echo PHP_EOL;'
|
||||||
|
|
||||||
|
# 위 출력 해시를 변수에 담아 회원 추가 (level 10 = 일반회원, 12 = 최고관리자)
|
||||||
|
HASH='<위에서 출력된 해시>'
|
||||||
|
docker exec slot-mariadb mariadb -uroot -prootpass inspection2 -e "
|
||||||
|
INSERT IGNORE INTO g5_member
|
||||||
|
(mb_id, mb_password, mb_name, mb_nick, mb_email, mb_level, mb_datetime, mb_today_login, mb_open)
|
||||||
|
VALUES
|
||||||
|
('testlogin', '$HASH', '테스트', '테스트', 'test@local.test', 10, NOW(), NOW(), 1);"
|
||||||
|
```
|
||||||
|
|
||||||
|
이제 `testlogin` / `test1234` 로 로그인 가능. 관리자는 admin 계정 비밀번호를 PBKDF2 해시로 직접 update 후 로그인 (운영 OTP 가 활성화되어 있어 OTP 단계 통과 필요).
|
||||||
|
|
||||||
|
## 2. PostgreSQL 마이그레이션 (재실행)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 빈 DB 생성
|
||||||
|
psql -h localhost -d postgres -c "
|
||||||
|
DROP DATABASE IF EXISTS slot;
|
||||||
|
CREATE DATABASE slot WITH ENCODING='UTF8' LC_COLLATE='C' LC_CTYPE='C' TEMPLATE=template0;
|
||||||
|
DROP DATABASE IF EXISTS slot_legacy;
|
||||||
|
CREATE DATABASE slot_legacy WITH ENCODING='UTF8' LC_COLLATE='C' LC_CTYPE='C' TEMPLATE=template0;"
|
||||||
|
|
||||||
|
# pgloader 실행
|
||||||
|
cd db
|
||||||
|
pgloader migrate_inspection2.load # ~6분, 165 테이블
|
||||||
|
pgloader migrate_inspection.load # ~10초
|
||||||
|
|
||||||
|
# 검증
|
||||||
|
psql -h localhost -d slot -c "
|
||||||
|
SELECT count(*) AS pg_tables, COALESCE(SUM(reltuples)::bigint,0) AS approx_rows
|
||||||
|
FROM pg_class c JOIN pg_namespace n ON c.relnamespace=n.oid
|
||||||
|
WHERE n.nspname='inspection2' AND c.relkind='r';"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 운영 서버에서 새 DB dump 가져오기 (옵션)
|
||||||
|
|
||||||
|
`scripts/refresh-db.sh` (예시):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# 1) 원격에서 dump 생성
|
||||||
|
sshpass -p "$SSH_PASS" ssh -p 21693 root@103.31.14.207 'bash -s' < db/remote_dump.sh
|
||||||
|
# 2) 로컬로 복사
|
||||||
|
sshpass -p "$SSH_PASS" rsync -az -e 'ssh -p 21693' root@103.31.14.207:/tmp/slot_dbdump/ db/
|
||||||
|
# 3) 로컬 MariaDB 로 재복원 (위 1.2 단계 반복)
|
||||||
|
```
|
||||||
|
|
||||||
|
운영 서버 SSH 패스워드는 환경변수 `SSH_PASS` 로만 주입하고 저장소에는 두지 말 것.
|
||||||
|
|
||||||
|
## 4. 자주 쓰는 명령
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# PHP 컨테이너 셸
|
||||||
|
docker exec -it slot-php bash
|
||||||
|
|
||||||
|
# Apache 에러 로그
|
||||||
|
docker logs slot-php 2>&1 | grep -iE "error|warn|fatal" | tail -30
|
||||||
|
# (php_errors.log 는 컨테이너 안 /var/log/apache2/php_errors.log 에 저장됨)
|
||||||
|
docker exec slot-php tail -f /var/log/apache2/php_errors.log
|
||||||
|
|
||||||
|
# MariaDB 셸
|
||||||
|
docker exec -it slot-mariadb mariadb -uroot -prootpass inspection2
|
||||||
|
|
||||||
|
# PostgreSQL 셸
|
||||||
|
psql -h localhost -d slot
|
||||||
|
# 데이터는 schema 'inspection2' 안:
|
||||||
|
# slot=> SET search_path TO inspection2; SELECT count(*) FROM g5_member;
|
||||||
|
|
||||||
|
# Redis 셸
|
||||||
|
docker exec -it slot-redis redis-cli
|
||||||
|
|
||||||
|
# 전체 스택 종료
|
||||||
|
cd docker && docker compose down
|
||||||
|
|
||||||
|
# 전체 스택 재시작 + 재초기화 (entrypoint 다시 실행)
|
||||||
|
cd docker && docker compose down && docker compose up -d
|
||||||
|
docker logs -f slot-php
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 알려진 경고 (정상 동작과 무관)
|
||||||
|
|
||||||
|
| 메시지 | 원인 | 해결 |
|
||||||
|
|--------|------|------|
|
||||||
|
| `Notice: Undefined index HTTP_X_FORWARDED_FOR` | common.php:2 의 부주의한 헤더 접근 | `display_errors=Off` 로 숨김 (현재 적용됨) |
|
||||||
|
| `Class 'Redis' not found` | php-redis 익스텐션 누락 | entrypoint 가 `pecl install redis` 자동 실행 |
|
||||||
|
| `Headers already sent ...` | display_errors 가 On 이고 PHP notice 출력 | display_errors Off (현재 적용됨) |
|
||||||
|
| `apache2: Could not reliably determine ...` | ServerName 미설정 (cosmetic) | 무시 |
|
||||||
|
| `pdo (pdo.so) is already loaded!` | Debian 기본 PHP 에 pdo 가 빌트인이라 두 번 로드 | 무시 |
|
||||||
|
|
||||||
|
## 6. 운영 서버 / 로컬 차이점 요약
|
||||||
|
|
||||||
|
| 항목 | 운영 | 로컬 |
|
||||||
|
|------|------|------|
|
||||||
|
| URL | https://slot-ss.com | http://localhost:8088 |
|
||||||
|
| `G5_DOMAIN` | https://slot-ss.com (config.php) | env `G5_DOMAIN_OVERRIDE` |
|
||||||
|
| DB 호스트 | localhost | docker network "slot-net" / `slot-mariadb` |
|
||||||
|
| DB 비밀번호 | `iiOii5*^^*` (운영) | `rootpass` (로컬) |
|
||||||
|
| Redis | (운영 서버 redis) | `slot-redis` 컨테이너 |
|
||||||
|
| 첨부파일 | `/var/www/slot-ss.com/data/file/...`, `data/editor/...` | 로컬 src/data 는 빈 디렉토리 (실파일 없음) |
|
||||||
|
| 사용자 업로드 | 운영 그대로 | **없음** — 다운받지 않음 |
|
||||||
|
|
||||||
|
## 7. 트러블슈팅
|
||||||
|
|
||||||
|
### 7.1 컨테이너가 unhealthy 또는 재시작 반복
|
||||||
|
```bash
|
||||||
|
docker logs slot-php 2>&1 | tail -50
|
||||||
|
# pecl install redis 가 빌드 의존성 문제로 실패할 수 있음 → entrypoint 가 || true 로 무시
|
||||||
|
# 그래도 실패 시:
|
||||||
|
docker exec slot-php bash -lc 'apt-get update && apt-get install -y libssl-dev && pecl install redis && docker-php-ext-enable redis && apache2ctl restart'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 사이트 500
|
||||||
|
```bash
|
||||||
|
# php_errors.log 확인 (display_errors 는 Off 이므로 화면엔 안 뜸)
|
||||||
|
docker exec slot-php tail -50 /var/log/apache2/php_errors.log
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 DB 연결 실패
|
||||||
|
```bash
|
||||||
|
# slot-mariadb 가 같은 네트워크에 있는지 확인
|
||||||
|
docker network inspect slot-net
|
||||||
|
# 없으면:
|
||||||
|
docker network connect slot-net slot-mariadb
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.4 포트 충돌
|
||||||
|
- 8088 사용 중이면 `docker/docker-compose.yml` 의 `ports: "8088:80"` 을 다른 포트로 변경
|
||||||
|
- 33306 사용 중이면 위와 마찬가지
|
||||||
|
|
||||||
|
## 8. 신규 시스템 (Next.js) 개발 셋업
|
||||||
|
(추후 M1 단계에서 추가 예정)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 모노레포 루트
|
||||||
|
pnpm install
|
||||||
|
pnpm db:migrate
|
||||||
|
pnpm dev # apps/web (3000) + apps/admin (3001) + apps/api (4000) 동시 실행
|
||||||
|
```
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
# Feature Inventory — slot-ss.com (슬롯/카지노 커뮤니티 도메인)
|
||||||
|
|
||||||
|
이 문서는 신규 시스템 (Next.js + Node.js + PostgreSQL) 으로 옮겨야 할 모든 기능을 도메인별로 정리한다. 항목당 (1) 기존 위치, (2) DB 의존, (3) 신규 패키지 매핑.
|
||||||
|
|
||||||
|
## 1. 회원·인증
|
||||||
|
|
||||||
|
| 기능 | 기존 | DB | 신규 패키지 |
|
||||||
|
|------|-----|-----|-------------|
|
||||||
|
| 일반 회원가입 | `bbs/register*.php` | `g5_member` | `apps/web/auth/register` |
|
||||||
|
| 로그인 / 로그아웃 | `bbs/login.php`, `login_check.php`, `logout.php` | `g5_member`, `g5_login` | `packages/auth` (NextAuth.js) |
|
||||||
|
| 비밀번호 찾기 | `bbs/password_lost.php` | `g5_member.mb_lost_certify` | `apps/web/auth/recovery` |
|
||||||
|
| 메일 인증 | (다양한 register*.php) | `g5_member.mb_email_certify` | `packages/auth/email-verify` |
|
||||||
|
| **2FA OTP** | `plugin/ask-otp/` | (별도 외부 OTP 서버) | `packages/auth/totp` |
|
||||||
|
| **본인인증 — OK-Name** | `plugin/okname/` | `g5_member_cert_history`, `g5_cert_history` | `packages/auth/okname` |
|
||||||
|
| **본인인증 — KCP cert** | `plugin/kcpcert/` | 동상 | `packages/auth/kcp` |
|
||||||
|
| **본인인증 — iNICert** | `plugin/inicert/` | 동상 | `packages/auth/inicert` |
|
||||||
|
| **소셜 로그인** | `plugin/sns/` (Naver/Kakao/Facebook) | `g5_member_social_profiles` | NextAuth providers |
|
||||||
|
| 회원 등급 (lv 1-12) | `g5_member.mb_level` + `g5_auth` | `g5_auth` | `packages/auth/permissions` |
|
||||||
|
| 차단/탈퇴 | `g5_member.mb_intercept_date`, `mb_leave_date` | 동상 | 동상 |
|
||||||
|
| 회원 검색/관리 | `adm/member_*.php` | `g5_member` | `apps/admin/members` |
|
||||||
|
| **레벨/랭크 시스템** | `bbs/*rank.php` (12종) + `lib/member.rank.lib.php` | `g5_eyoom_member`, 게임별 포인트 테이블 | `packages/games/ranking` |
|
||||||
|
| 출석체크 | `bbs/attendance.php`, `data/attendance.config.php` | `g5_eyoom_attendance` | `apps/web/attendance` |
|
||||||
|
| 회원 활동 로그 | `g5_eyoom_activity`, `writing_activity` | 동상 | `packages/shared/activity` |
|
||||||
|
| 회원 메모 (관리자 메모) | `g5_eyoom_mbmemo` | 동상 | `apps/admin/members/memo` |
|
||||||
|
| 옐로카드 (경고) | `g5_eyoom_yellowcard` | 동상 | `apps/admin/members/yellowcard` |
|
||||||
|
|
||||||
|
## 2. 게시판
|
||||||
|
|
||||||
|
| 기능 | 기존 | DB | 신규 |
|
||||||
|
|------|-----|-----|------|
|
||||||
|
| 게시판 목록 | `bbs/group.php`, `bbs/board.php?bo_table=X` | `g5_group`, `g5_board` | `apps/web/[boardSlug]` |
|
||||||
|
| 글 작성/수정/삭제 | `bbs/write.php`, `write_update.php`, `delete.php` | `g5_write_<bo_table>` | `apps/web/[boardSlug]/write` + API |
|
||||||
|
| 글 보기 | `bbs/board.php?bo_table=X&wr_id=N` | `g5_write_<bo_table>` + `g5_board_file` | `apps/web/[boardSlug]/[wrId]` |
|
||||||
|
| 댓글 | `bbs/write_comment_update.php` | `g5_write_<bo_table>` (is_comment) | 동상 |
|
||||||
|
| 추천/비추천 | `bbs/good.php`, `bbs/bad.php` | `g5_board_good` | API |
|
||||||
|
| 첨부 파일 업로드 | `lib/board.lib.php` | `g5_board_file` | `packages/shared/upload` (S3) |
|
||||||
|
| 글 검색 | `bbs/search.php`, `lib/parsing.lib.php`, `lib/SphinxSearch.class.php` | sphinx index | Meilisearch 또는 PG FTS |
|
||||||
|
| 인기 검색어 | `bbs/popular*.php`, `lib/popular.lib.php` | `g5_popular` | API + Redis |
|
||||||
|
| 신규글 위젯 | `lib/latest.lib.php` | `g5_board_new` | RSC + ISR |
|
||||||
|
| 인기글 / 추천글 | `lib/eblatest.*` | `g5_eyoom_*` | 동상 |
|
||||||
|
| 글 임시저장 | `bbs/autosave.php` | `g5_autosave` | localStorage + 서버 백업 |
|
||||||
|
| 스크랩 | `bbs/scrap_popin.php` | `g5_scrap` | 동상 |
|
||||||
|
| 1:1 문의 | `bbs/qa*.php` | `g5_qa_config`, `g5_qa_content` | `apps/web/help/qa` |
|
||||||
|
| 자주묻는질문 | `bbs/faq.php` | `g5_faq`, `g5_faq_master` | `apps/web/help/faq` |
|
||||||
|
| 메모(쪽지) | `bbs/memo*.php` | `g5_memo` | `apps/web/inbox` |
|
||||||
|
| 새창 알림 | `bbs/new.php` | `g5_new_win` | `apps/admin/popups` |
|
||||||
|
| 게시판 새글 표시 | (자동 — `g5_board_new`) | 동상 | 동상 |
|
||||||
|
| 멀티미디어 게시판 (`webtoon`, `ai`) | board skin 변형 | 게시판 데이터 | 테마 슬롯 + `<MediaGrid />` |
|
||||||
|
|
||||||
|
### 운영 게시판 36종
|
||||||
|
| 카테고리 | 게시판 슬러그 |
|
||||||
|
|---------|----------|
|
||||||
|
| 종합 / 자유 | `free`, `humor`, `notice`, `event`, `column`, `news` |
|
||||||
|
| 슬롯 후기/리뷰 | `review`, `slotreview`, `pick` |
|
||||||
|
| 슬롯 제조사 | `slotche`, `slotche2~12` (프라그마틱, 플레이엔고, 릴랙스게이밍, 하바네로, CQ9 등) |
|
||||||
|
| 게임/이벤트 | `lottery_ticket`(슬생복권), `dividend`(배당), `gift_coupons`/`gift_exchanges`(기프티콘) |
|
||||||
|
| 검수/검증 | `mukti`(먹튀사이트), `fakesite`(가품사이트), `complaint`(먹튀신고), `inspection`(검수), `guarantee`(보증) |
|
||||||
|
| AI / 19금 | `ai`(AI 사진), `webtoon`(번역망가), `rear`(후방) |
|
||||||
|
| 안내 | `guide`, `qa`, `reservation` |
|
||||||
|
| 가짜 (테스트?) | `fakes`, `fakeframe` |
|
||||||
|
|
||||||
|
## 3. 슬롯/카지노 게임
|
||||||
|
|
||||||
|
| 기능 | 기존 | DB | 신규 |
|
||||||
|
|------|-----|-----|------|
|
||||||
|
| **바카라 게임** | `plugin/bacara/`, `bbs/bacara.php` | `bacara_betting` | `packages/games/bacara` + WebSocket |
|
||||||
|
| 바카라 랭킹 | `bbs/bacararank.php` | 동상 | `apps/web/games/bacara/rank` |
|
||||||
|
| **룰렛 게임** | `roulette/index.php`, `plugin/roulette/` | `g5_eyoom_*` | `packages/games/roulette` |
|
||||||
|
| 룰렛 어드민 | `adm/roulette/{roulettelist,form,rewardlist,chancelist}.php` | 동상 | `apps/admin/games/roulette` |
|
||||||
|
| **복권 (슬생복권)** | `lottery/index.php`, `bok.php`, `lib/lottery.lib.php` | `g5_write_lottery_ticket`, `lottery_history` | `packages/games/lottery` |
|
||||||
|
| 복권 추첨 | `adm/roulette/lotterywinninglist.php` | 동상 | BullMQ cron + 어드민 |
|
||||||
|
| **Swiun 슬롯 API** | `plugin/swiunApi/` (`trans_point`, `game`, `get_betlist`, `_config`) | `swiun_betting`, `game_point` | `packages/games/swiun` |
|
||||||
|
| **게임 포인트** | (전반적) | `game_point` (4.4M rows), `g5_point` (5.9M) | `point_ledger` 통합 |
|
||||||
|
| **27개 게임별 랭크 자동 업데이트** | `plugin/cron/auto.{slot,bacara,marilyn,...}rankup.php` (27개) | `g5_eyoom_member` + 게임 포인트 | `packages/workers/jobs/rank.update.ts` |
|
||||||
|
| 가짜 게임 포인트 (개발용?) | `plugin/cron/fake_game_points.php` | 동상 | dev-only 시드 |
|
||||||
|
|
||||||
|
## 4. 검수·블랙리스트 (커뮤니티 신뢰)
|
||||||
|
|
||||||
|
| 기능 | 기존 | DB | 신규 |
|
||||||
|
|------|-----|-----|------|
|
||||||
|
| 먹튀 사이트 등록/검색 | `bbs/inspection.php`, `inspection.list.update.php`, `inspection.update.php`, `bbs/cagumsa.php`(가검사) | `blacklist_table` (193K), `blacklist_search`, `check_table` | `apps/web/blacklist` |
|
||||||
|
| 블랙리스트 어드민 | `adm/eyoom_admin/core/board/blacklist_search.php` | 동상 | `apps/admin/blacklist` |
|
||||||
|
| 100up 블랙리스트 (구버전) | `db100up/` | 동상 | (legacy, 사용 안 함) |
|
||||||
|
| 검수 로그 | `writing_activity`, `writing_activity_bak`, `writing_today` | 동상 | `inspection_logs` |
|
||||||
|
|
||||||
|
## 5. 포인트·환전
|
||||||
|
|
||||||
|
| 기능 | 기존 | DB | 신규 |
|
||||||
|
|------|-----|-----|------|
|
||||||
|
| 포인트 적립/사용 원장 | `lib/common.lib.php::insert_point()` | `g5_point` | `point_ledger` |
|
||||||
|
| 포인트 환전 신청 | (회원 페이지) | `point_exchange`, `event_point_exchange` | `apps/web/wallet/exchange` |
|
||||||
|
| 기프티콘 교환 | `bbs/giftcon*.php` | `g5_write_gift_coupons`, `g5_write_gift_exchanges`, `officecon_products` | `apps/web/wallet/gifticon` |
|
||||||
|
| 슬롯버프 (특별 보상) | `bbs/slotbuff*.php` | `slotbuff`, `slotbuff-category` | `apps/web/wallet/slotbuff` |
|
||||||
|
| 베팅 어드민 | `adm/betting_list.php`, `betting_list_update.php` | `bacara_betting`, `swiun_betting`, `game_point` | `apps/admin/betting` |
|
||||||
|
|
||||||
|
## 6. 영카트 쇼핑몰 (현재 데이터 거의 없음, 기능은 보존)
|
||||||
|
|
||||||
|
| 기능 | 기존 | DB | 신규 |
|
||||||
|
|------|-----|-----|------|
|
||||||
|
| 상품 등록/수정 | `adm/shop_admin/item_*.php` | `g5_shop_item`, `g5_shop_item_option` | `apps/admin/shop/items` |
|
||||||
|
| 카테고리 | `shop/list.php`, `adm/shop_admin/categoryform.php` | `g5_shop_category` | `apps/admin/shop/categories` |
|
||||||
|
| 브랜드 | `shop/brand.php` | (`g5_shop_item.it_brand`) | 동상 |
|
||||||
|
| 장바구니 | `shop/cart.php` | `g5_shop_cart` | `apps/web/cart` |
|
||||||
|
| 주문/결제 | `shop/orderform*.php`, `shop/inicis/*` | `g5_shop_order`, `g5_shop_order_data`, `g5_shop_inicis_log` | `apps/web/checkout` |
|
||||||
|
| 결제 게이트웨이 — 이니시스 | `shop/inicis/`, `shop/kcp/` | 동상 | `packages/shop/payments/inicis` |
|
||||||
|
| 결제 게이트웨이 — KCP | `shop/kcp/`, `plugin/kcpcert/` | 동상 | `packages/shop/payments/kcp` |
|
||||||
|
| 결제 게이트웨이 — LG U+ | `plugin/lgxpay/` | 동상 | `packages/shop/payments/lgxpay` |
|
||||||
|
| 쿠폰 | `g5_shop_coupon`, `g5_shop_coupon_zone`, `g5_shop_coupon_log` | 동상 | `apps/web/coupons` |
|
||||||
|
| 위시리스트 | `shop/wishlist.php` | `g5_shop_wish` | 동상 |
|
||||||
|
| 상품 후기 | `shop/itemuse*.php` | `g5_shop_item_use` | 동상 |
|
||||||
|
| 상품 Q&A | `shop/itemqa*.php` | `g5_shop_item_qa` | 동상 |
|
||||||
|
| 재입고 SMS | `g5_shop_item_stocksms` | 동상 | `packages/shop/restock-notify` |
|
||||||
|
|
||||||
|
## 7. SMS / 메일 / 알림
|
||||||
|
|
||||||
|
| 기능 | 기존 | DB | 신규 |
|
||||||
|
|------|-----|-----|------|
|
||||||
|
| SMS 발송 (Aligo) | `plugin/sms5/`, `data/aligo-sms.data` | `sms5_*` (`book`, `book_group`, `config`, `form`, `form_group`, `history`, `write`) | `packages/notify/sms` |
|
||||||
|
| 이메일 발송 | `plugin/PHPMailer/` | `g5_mail` | `packages/notify/email` (Resend/SES) |
|
||||||
|
| 푸시 알림 | `plugin/notifier/`, `g5_plugin_notifier_hist` | 동상 | `packages/notify/push` (Web Push) |
|
||||||
|
| Slack 알림 | `plugin/slack/` | — | `packages/notify/slack` |
|
||||||
|
|
||||||
|
## 8. 챗봇 (AI)
|
||||||
|
|
||||||
|
| 기능 | 기존 | DB | 신규 |
|
||||||
|
|------|-----|-----|------|
|
||||||
|
| 챗봇 대화 | `plugin/chatbot/api_proxy.php`, `view.php`, `script.js`, `style.css` | `chatbot_conversations` | `packages/chatbot` (OpenAI/Anthropic) |
|
||||||
|
| 챗봇 피드백 | `plugin/chatbot/feedback.php` | `chatbot_feedback` | 동상 |
|
||||||
|
| 챗봇 로그 | `plugin/chatbot/log.php` | 동상 | 동상 |
|
||||||
|
|
||||||
|
## 9. SEO·마케팅
|
||||||
|
|
||||||
|
| 기능 | 기존 | DB | 신규 |
|
||||||
|
|------|-----|-----|------|
|
||||||
|
| SEO 메타 (페이지/게시판별) | `data/eyoom.seocfg.php`, `theme/.../meta_config.php`, `theme/.../seo.map.php` | `ask_seo`, `ask_seo_url` | `packages/shared/seo` + Next.js metadata API |
|
||||||
|
| robots.txt / sitemap | `robots.txt` | — | Next.js sitemap.ts |
|
||||||
|
| Naver Syndication | `plugin/syndi/` | — | `packages/notify/naver-syn` |
|
||||||
|
| Google Indexing API | `google_indexing-api.php`, `silicon-cocoa-*.json` | — | API 라우트 |
|
||||||
|
| OG 이미지 / Twitter 카드 | `head.html.php` 인라인 | — | Next.js Open Graph |
|
||||||
|
| 도메인 이력 / 도메인체크 | `domainCheck.php`, `domainCheck2.php`, `ajax.domainCheck.php`, DB `DomainCheck` | (별도 DB, 거의 빈 상태) | `apps/web/tools/domain-check` (옵션) |
|
||||||
|
|
||||||
|
## 10. 관리자
|
||||||
|
|
||||||
|
| 기능 | 기존 | DB | 신규 |
|
||||||
|
|------|-----|-----|------|
|
||||||
|
| 관리자 대시보드 | `adm/index.php` | (요약쿼리) | `apps/admin/dashboard` |
|
||||||
|
| 회원 관리 | `adm/member_*.php` | `g5_member` | `apps/admin/members` |
|
||||||
|
| 게시판 관리 | `adm/boardgroup_*.php`, `board_*.php` | `g5_board`, `g5_group` | `apps/admin/boards` |
|
||||||
|
| 환경설정 | `adm/config_*.php` | `g5_config` | `apps/admin/settings` |
|
||||||
|
| 메뉴 관리 | `adm/menu_*.php` | `g5_menu` | `apps/admin/menu` |
|
||||||
|
| 권한 관리 | `adm/auth_*.php` | `g5_auth` | `apps/admin/permissions` |
|
||||||
|
| 점프 (관리자 전환) | (이윰) | `g5_eyoom_manager` | `apps/admin/managers` |
|
||||||
|
| 베팅/룰렛/복권 어드민 | (위 §3) | (위 §3) | `apps/admin/games/*` |
|
||||||
|
| 통계 (방문/회원/포인트) | `adm/visit_*.php` | `g5_visit`, `g5_visit_sum`, `g5_login` | `apps/admin/stats` |
|
||||||
|
| **테마 관리 (4종 선택)** | (없음 — 이윰 빌더 내부) | (신규) `app_settings` | `apps/admin/themes` |
|
||||||
|
|
||||||
|
## 11. 기타 / 인프라
|
||||||
|
|
||||||
|
| 기능 | 기존 | DB | 신규 |
|
||||||
|
|------|-----|-----|------|
|
||||||
|
| 캐시 (Sphinx Search) | `lib/SphinxSearch.class.php`, `sphinx.conf` | sphinx index | Meilisearch |
|
||||||
|
| 캐시 (Redis) | `lib/RedisCache.class.php` | redis | Redis (그대로) |
|
||||||
|
| 캐시 (파일) | `lib/Cache/` | `data/cache/` | Redis |
|
||||||
|
| 훅 시스템 | `lib/Hook/` (`run_event`, `run_replace`) | — | EventEmitter / Plugin pattern |
|
||||||
|
| reCAPTCHA | `plugin/recaptcha/`, `plugin/recaptcha_inv/` | — | next-recaptcha-v3 |
|
||||||
|
| 자체 캡차 | `plugin/kcaptcha/` | — | (필요 시) |
|
||||||
|
| 디버그바 | `plugin/debugbar/` | — | dev only — Next.js devtools |
|
||||||
|
| jQuery 차트 | `plugin/jqplot/` | — | Recharts / Chart.js |
|
||||||
|
| HTML 정화 | `plugin/htmlpurifier/` | — | DOMPurify (server side: sanitize-html) |
|
||||||
|
|
||||||
|
## 12. 도메인 데이터 통계 (현재 운영)
|
||||||
|
|
||||||
|
| 항목 | 값 |
|
||||||
|
|------|-----|
|
||||||
|
| 회원 수 | 3,064 |
|
||||||
|
| 관리자 수 | 4 (`admin`, `admin2`, `admin3`, `admin4`) |
|
||||||
|
| 운영 게시판 수 | 36 |
|
||||||
|
| 총 게시글 수 (모든 g5_write_*) | ~764K |
|
||||||
|
| 자유게시판 글 / 댓글 | 90,591 / 351,685 |
|
||||||
|
| 후기게시판 글 / 댓글 | 8,399 / 112,462 |
|
||||||
|
| 먹튀사이트 글 / 댓글 | 1,824 / 35,501 |
|
||||||
|
| 가품사이트 글 / 댓글 | 1,517 / 31,609 |
|
||||||
|
| 포인트 원장 행 수 | 5,950,013 |
|
||||||
|
| 게임 포인트 행 수 | 4,383,542 |
|
||||||
|
| 슬롯 게임 회차 (games_table) | 303,238 |
|
||||||
|
| 바카라 베팅 | 7,987 |
|
||||||
|
| 복권 응모 | 45,724 |
|
||||||
|
| 블랙리스트 | 193,190 |
|
||||||
|
| 누적 방문 | 4,566,650 |
|
||||||
|
| 첨부파일 메타 | 12,606 |
|
||||||
|
| 첨부파일 실용량 (서버) | data/file 8.5GB + data/editor 74GB |
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
## Postgres (the migrated DB)
|
||||||
|
DATABASE_URL="postgresql://chpark@localhost:5432/slot"
|
||||||
|
DATABASE_LEGACY_URL="postgresql://chpark@localhost:5432/slot_legacy"
|
||||||
|
|
||||||
|
## Redis (cache + BullMQ)
|
||||||
|
REDIS_URL="redis://localhost:6379"
|
||||||
|
|
||||||
|
## Auth
|
||||||
|
AUTH_SECRET="change-me-32-chars-minimum-please-rotate"
|
||||||
|
AUTH_URL="http://localhost:3000"
|
||||||
|
|
||||||
|
## App
|
||||||
|
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
||||||
|
NEXT_PUBLIC_API_URL="http://localhost:4000"
|
||||||
|
|
||||||
|
## Theme default (overridable in DB app_settings)
|
||||||
|
THEME_DEFAULT="eyoom"
|
||||||
|
|
||||||
|
## S3-compatible object storage (R2 / MinIO / AWS)
|
||||||
|
S3_ENDPOINT=""
|
||||||
|
S3_REGION="auto"
|
||||||
|
S3_BUCKET="slot-uploads"
|
||||||
|
S3_ACCESS_KEY=""
|
||||||
|
S3_SECRET_KEY=""
|
||||||
|
|
||||||
|
## OpenAI / Anthropic (chatbot)
|
||||||
|
OPENAI_API_KEY=""
|
||||||
|
ANTHROPIC_API_KEY=""
|
||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
/// <reference path="./.next/types/routes.d.ts" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
transpilePackages: ['@slot/themes', '@slot/db', '@slot/auth'],
|
||||||
|
serverExternalPackages: ['postgres', '@node-rs/argon2'],
|
||||||
|
// Backwards-compatible 301 redirects from gnuboard URLs handled by middleware
|
||||||
|
};
|
||||||
|
export default nextConfig;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "@slot/web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev -p 3000",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start -p 3000",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@slot/db": "workspace:*",
|
||||||
|
"@slot/auth": "workspace:*",
|
||||||
|
"@slot/themes": "workspace:*",
|
||||||
|
"next": "^15.1.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"drizzle-orm": "^0.36.4",
|
||||||
|
"postgres": "^3.4.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"@types/react": "^19.0.1",
|
||||||
|
"@types/react-dom": "^19.0.2",
|
||||||
|
"@types/node": "^22.10.0",
|
||||||
|
"eslint": "^9.17.0",
|
||||||
|
"eslint-config-next": "^15.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { getBoardMeta, getPost } from '@/lib/legacy-board';
|
||||||
|
import { getThemeForPath } from '@/lib/theme';
|
||||||
|
import { headers } from 'next/headers';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default async function PostPage({ params }: { params: Promise<{ boardSlug: string; wrId: string }> }) {
|
||||||
|
const { boardSlug, wrId } = await params;
|
||||||
|
const meta = await getBoardMeta(boardSlug);
|
||||||
|
if (!meta) return notFound();
|
||||||
|
const post = await getPost(boardSlug, wrId);
|
||||||
|
if (!post) return notFound();
|
||||||
|
const h = await headers();
|
||||||
|
const theme = await getThemeForPath(h.get('x-pathname') ?? `/${boardSlug}/${wrId}`);
|
||||||
|
const PostView = theme.slots.PostView;
|
||||||
|
const commentsTree = (
|
||||||
|
<div>
|
||||||
|
<h3 style={{ fontSize: 16, marginBottom: 'var(--space-md)' }}>댓글 ({post.comments.length})</h3>
|
||||||
|
{post.comments.length === 0 && <p style={{ color: 'var(--color-text-muted, var(--color-textMuted, #888))', fontSize: 13 }}>아직 댓글이 없습니다.</p>}
|
||||||
|
{post.comments.map((c, i) => (
|
||||||
|
<div key={i} style={{ borderBottom: '1px solid var(--color-border)', padding: 'var(--space-md) 0' }}>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted, var(--color-textMuted, #888))', marginBottom: 4 }}>
|
||||||
|
<strong>{c.authorName}</strong> · {new Date(c.createdAt).toLocaleString('ko-KR')}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 14 }} dangerouslySetInnerHTML={{ __html: c.content }} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<PostView
|
||||||
|
boardSlug={boardSlug}
|
||||||
|
boardTitle={meta.title}
|
||||||
|
subject={post.subject}
|
||||||
|
authorName={post.authorName}
|
||||||
|
createdAt={post.createdAt}
|
||||||
|
content={post.content}
|
||||||
|
hit={post.hit}
|
||||||
|
good={post.good}
|
||||||
|
bad={post.bad}
|
||||||
|
comments={commentsTree}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { getBoardMeta, listPosts } from '@/lib/legacy-board';
|
||||||
|
import { getThemeForPath } from '@/lib/theme';
|
||||||
|
import { headers } from 'next/headers';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default async function BoardPage({ params, searchParams }: { params: Promise<{ boardSlug: string }>; searchParams: Promise<{ page?: string }> }) {
|
||||||
|
const { boardSlug } = await params;
|
||||||
|
const sp = await searchParams;
|
||||||
|
const page = Math.max(1, parseInt(sp.page ?? '1', 10) || 1);
|
||||||
|
const meta = await getBoardMeta(boardSlug);
|
||||||
|
if (!meta) return notFound();
|
||||||
|
const { items, totalPages } = await listPosts(boardSlug, page);
|
||||||
|
const h = await headers();
|
||||||
|
const theme = await getThemeForPath(h.get('x-pathname') ?? `/${boardSlug}`);
|
||||||
|
const PostList = theme.slots.PostList;
|
||||||
|
return <PostList boardSlug={boardSlug} boardTitle={meta.title} items={items} page={page} totalPages={totalPages} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { listThemes, THEME_LABELS, type ThemeId } from '@slot/themes';
|
||||||
|
import { getActiveGlobalTheme, setActiveGlobalTheme } from '@/lib/theme';
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
async function selectTheme(formData: FormData) {
|
||||||
|
'use server';
|
||||||
|
const id = String(formData.get('themeId') ?? '') as ThemeId;
|
||||||
|
if (!['basic', 'eyoom', 'amina', 'youngcart'].includes(id)) return;
|
||||||
|
await setActiveGlobalTheme(id);
|
||||||
|
revalidatePath('/', 'layout');
|
||||||
|
redirect('/admin/themes?ok=1');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminThemesPage({ searchParams }: { searchParams: Promise<{ ok?: string }> }) {
|
||||||
|
const sp = await searchParams;
|
||||||
|
const active = await getActiveGlobalTheme().catch(() => 'eyoom' as ThemeId);
|
||||||
|
const themes = listThemes();
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 980, margin: '0 auto', padding: 'var(--space-xl) var(--space-lg)' }}>
|
||||||
|
<h1 style={{ fontSize: 28, marginBottom: 'var(--space-sm)' }}>테마 관리</h1>
|
||||||
|
<p style={{ color: 'var(--color-text-muted, var(--color-textMuted, #777))', marginBottom: 'var(--space-lg)' }}>
|
||||||
|
사용자에게 보여줄 사이트 전체 테마를 선택하세요. 4종 중 하나를 선택할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
{sp.ok && <div style={{ background: 'var(--color-success)', color: '#fff', padding: 'var(--space-sm) var(--space-md)', borderRadius: 'var(--radius-md)', marginBottom: 'var(--space-md)' }}>저장되었습니다. 페이지를 새로고침하여 확인하세요.</div>}
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: 'var(--space-md)' }}>
|
||||||
|
{themes.map((t) => (
|
||||||
|
<form key={t.id} action={selectTheme}>
|
||||||
|
<input type="hidden" name="themeId" value={t.id} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
style={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
width: '100%',
|
||||||
|
textAlign: 'left',
|
||||||
|
border: t.id === active ? '3px solid var(--color-primary)' : '1px solid var(--color-border)',
|
||||||
|
background: t.id === active ? 'var(--color-bg-surface, var(--color-bgSurface, #f6f8fa))' : 'transparent',
|
||||||
|
padding: 'var(--space-md)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
color: 'inherit',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong style={{ fontSize: 18 }}>{THEME_LABELS[t.id]}</strong>
|
||||||
|
{t.id === active && <span style={{ marginLeft: 8, fontSize: 12, color: 'var(--color-primary)' }}>● 활성</span>}
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted, var(--color-textMuted, #777))', marginTop: 4 }}>id: {t.id}</div>
|
||||||
|
<div style={{ marginTop: 'var(--space-md)', display: 'flex', gap: 4 }}>
|
||||||
|
{Object.values(t.tokens.color).slice(0, 5).map((c, i) => (
|
||||||
|
<div key={i} style={{ width: 24, height: 24, borderRadius: '50%', background: c, border: '1px solid #0001' }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{t.features?.shop && <div style={{ marginTop: 6, fontSize: 11, color: 'var(--color-success)' }}>✓ 쇼핑몰</div>}
|
||||||
|
{t.features?.magazineWidgets && <div style={{ marginTop: 6, fontSize: 11, color: 'var(--color-success)' }}>✓ 매거진 위젯</div>}
|
||||||
|
{t.features?.megaMenu && <div style={{ marginTop: 6, fontSize: 11, color: 'var(--color-success)' }}>✓ 메가메뉴</div>}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style={{ marginTop: 'var(--space-xl)', fontSize: 13, color: 'var(--color-text-muted, var(--color-textMuted, #888))' }}>
|
||||||
|
※ 영역별 오버라이드 (게시판/페이지마다 다른 테마)는 향후 추가 예정입니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { login, SESSION_COOKIE } from '@slot/auth';
|
||||||
|
import { getRequestIp } from '@/lib/session';
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const form = await req.formData();
|
||||||
|
const loginId = String(form.get('loginId') ?? '').trim();
|
||||||
|
const password = String(form.get('password') ?? '');
|
||||||
|
if (!loginId || !password) {
|
||||||
|
return NextResponse.redirect(new URL('/login?error=1', req.url), { status: 303 });
|
||||||
|
}
|
||||||
|
const ip = await getRequestIp();
|
||||||
|
const result = await login(loginId, password, { ip, userAgent: req.headers.get('user-agent') ?? undefined });
|
||||||
|
if (!result.ok) {
|
||||||
|
return NextResponse.redirect(new URL('/login?error=1', req.url), { status: 303 });
|
||||||
|
}
|
||||||
|
const res = NextResponse.redirect(new URL('/', req.url), { status: 303 });
|
||||||
|
res.cookies.set(SESSION_COOKIE, result.session.id, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
expires: result.session.expiresAt,
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { destroySession, SESSION_COOKIE } from '@slot/auth';
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const sid = req.cookies.get(SESSION_COOKIE)?.value;
|
||||||
|
if (sid) await destroySession(sid).catch(() => {});
|
||||||
|
const res = NextResponse.redirect(new URL('/', req.url), { status: 303 });
|
||||||
|
res.cookies.delete(SESSION_COOKIE);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { headers } from 'next/headers';
|
||||||
|
import { getThemeForPath } from '@/lib/theme';
|
||||||
|
import { tokensToCssVars } from '@slot/themes';
|
||||||
|
import { getCurrentMember } from '@/lib/session';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Slot - 슬롯 커뮤니티',
|
||||||
|
description: '안전 슬롯사이트 추천 및 슬롯커뮤니티',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const h = await headers();
|
||||||
|
const pathname = h.get('x-pathname') ?? '/';
|
||||||
|
const member = await getCurrentMember();
|
||||||
|
const theme = await getThemeForPath(pathname, member?.themePref as any);
|
||||||
|
const Root = theme.layouts.root;
|
||||||
|
return (
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<style dangerouslySetInnerHTML={{ __html: `:root{${tokensToCssVars(theme)}}body{margin:0;padding:0}*{box-sizing:border-box}a{color:inherit}` }} />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<Root>
|
||||||
|
<theme.slots.Header activeTheme={theme.id} siteName="슬생닷컴" loggedInName={member?.nick ?? null} />
|
||||||
|
<main>{children}</main>
|
||||||
|
<theme.slots.Footer activeTheme={theme.id} siteName="슬생닷컴" />
|
||||||
|
</Root>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { headers } from 'next/headers';
|
||||||
|
import { getThemeForPath } from '@/lib/theme';
|
||||||
|
|
||||||
|
export default async function LoginPage({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
|
||||||
|
const sp = await searchParams;
|
||||||
|
const h = await headers();
|
||||||
|
const theme = await getThemeForPath(h.get('x-pathname') ?? '/login');
|
||||||
|
const Login = theme.slots.LoginForm;
|
||||||
|
return <Login error={sp.error ? '아이디 또는 비밀번호가 올바르지 않습니다.' : undefined} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import { listBoards } from '@/lib/legacy-board';
|
||||||
|
import { getActiveGlobalTheme } from '@/lib/theme';
|
||||||
|
import { THEME_LABELS } from '@slot/themes';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default async function HomePage() {
|
||||||
|
const [boards, activeTheme] = await Promise.all([
|
||||||
|
listBoards().catch(() => []),
|
||||||
|
getActiveGlobalTheme().catch(() => 'eyoom' as const),
|
||||||
|
]);
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 1100, margin: '0 auto', padding: 'var(--space-xl) var(--space-lg)' }}>
|
||||||
|
<section style={{ background: 'var(--color-bg-surface, var(--color-bgSurface, #f6f8fa))', borderRadius: 'var(--radius-lg)', padding: 'var(--space-xl)', marginBottom: 'var(--space-lg)' }}>
|
||||||
|
<h1 style={{ fontSize: 32, margin: '0 0 12px' }}>슬생닷컴 — 안전 슬롯 커뮤니티</h1>
|
||||||
|
<p style={{ color: 'var(--color-text-muted, var(--color-textMuted, #777))', margin: 0 }}>
|
||||||
|
현재 테마: <strong>{THEME_LABELS[activeTheme]}</strong> · <Link href="/admin/themes" style={{ color: 'var(--color-primary)' }}>테마 변경</Link>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 style={{ fontSize: 20, marginBottom: 'var(--space-md)' }}>게시판 목록 ({boards.length})</h2>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: 'var(--space-md)' }}>
|
||||||
|
{boards.length === 0 && <p style={{ color: 'var(--color-text-muted, var(--color-textMuted, #777))' }}>DB 연결 또는 게시판 데이터가 없습니다. <code>pnpm db:push</code> 와 <code>pnpm db:seed</code> 를 먼저 실행하세요.</p>}
|
||||||
|
{boards.map((b) => (
|
||||||
|
<Link
|
||||||
|
key={b.slug}
|
||||||
|
href={`/${b.slug}`}
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
padding: 'var(--space-md)',
|
||||||
|
background: 'var(--color-bg-surface, var(--color-bgSurface, #f6f8fa))',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: 'inherit',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>{b.title}</strong>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted, var(--color-textMuted, #777))', marginTop: 4 }}>/{b.slug}</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// Read-through helpers that pull data from the migrated gnuboard tables in
|
||||||
|
// the `inspection2` schema. Returns shapes match the new theme contracts so
|
||||||
|
// the UI doesn't need to know we're reading legacy data.
|
||||||
|
|
||||||
|
import { legacySql } from '@slot/db/legacy';
|
||||||
|
import type { PostListItem } from '@slot/themes';
|
||||||
|
|
||||||
|
export type LegacyBoardMeta = { slug: string; title: string; description: string | null };
|
||||||
|
|
||||||
|
export async function listBoards(): Promise<LegacyBoardMeta[]> {
|
||||||
|
const rows = await legacySql<{ bo_table: string; bo_subject: string }[]>`
|
||||||
|
SELECT bo_table, bo_subject
|
||||||
|
FROM inspection2.g5_board
|
||||||
|
WHERE bo_use_search > 0 OR bo_count_write > 0
|
||||||
|
ORDER BY bo_count_write DESC NULLS LAST
|
||||||
|
LIMIT 60
|
||||||
|
`;
|
||||||
|
return rows.map((r) => ({ slug: r.bo_table, title: r.bo_subject, description: null }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBoardMeta(slug: string): Promise<LegacyBoardMeta | null> {
|
||||||
|
const rows = await legacySql<{ bo_table: string; bo_subject: string }[]>`
|
||||||
|
SELECT bo_table, bo_subject
|
||||||
|
FROM inspection2.g5_board
|
||||||
|
WHERE bo_table = ${slug}
|
||||||
|
`;
|
||||||
|
return rows[0] ? { slug: rows[0].bo_table, title: rows[0].bo_subject, description: null } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
export async function listPosts(slug: string, page = 1): Promise<{ items: PostListItem[]; totalPages: number }> {
|
||||||
|
const safe = slug.replace(/[^a-z0-9_]/gi, '');
|
||||||
|
if (!safe) return { items: [], totalPages: 0 };
|
||||||
|
const tbl = `inspection2.g5_write_${safe}`;
|
||||||
|
// Count topics
|
||||||
|
const countRows = await legacySql<{ c: string }[]>`SELECT COUNT(*)::text AS c FROM ${legacySql(tbl)} WHERE wr_is_comment = 0`;
|
||||||
|
const total = Number(countRows[0]?.c ?? '0');
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
|
const offset = (page - 1) * PAGE_SIZE;
|
||||||
|
const rows = await legacySql<{ wr_id: number; wr_subject: string; wr_name: string; wr_datetime: Date; wr_hit: number; wr_comment: number }[]>`
|
||||||
|
SELECT wr_id, wr_subject, wr_name, wr_datetime, wr_hit, wr_comment
|
||||||
|
FROM ${legacySql(tbl)}
|
||||||
|
WHERE wr_is_comment = 0
|
||||||
|
ORDER BY wr_num, wr_reply
|
||||||
|
LIMIT ${PAGE_SIZE} OFFSET ${offset}
|
||||||
|
`;
|
||||||
|
const items: PostListItem[] = rows.map((r) => ({
|
||||||
|
boardSlug: safe,
|
||||||
|
id: String(r.wr_id),
|
||||||
|
subject: r.wr_subject,
|
||||||
|
authorName: r.wr_name,
|
||||||
|
createdAt: new Date(r.wr_datetime),
|
||||||
|
commentCount: r.wr_comment,
|
||||||
|
hit: r.wr_hit,
|
||||||
|
}));
|
||||||
|
return { items, totalPages };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPost(slug: string, wrId: string): Promise<{ subject: string; content: string; authorName: string; createdAt: Date; hit: number; good: number; bad: number; comments: { authorName: string; content: string; createdAt: Date }[] } | null> {
|
||||||
|
const safe = slug.replace(/[^a-z0-9_]/gi, '');
|
||||||
|
const id = parseInt(wrId, 10);
|
||||||
|
if (!safe || !id) return null;
|
||||||
|
const tbl = `inspection2.g5_write_${safe}`;
|
||||||
|
const rows = await legacySql<{ wr_id: number; wr_subject: string; wr_content: string; wr_name: string; wr_datetime: Date; wr_hit: number; wr_good: number; wr_nogood: number }[]>`
|
||||||
|
SELECT wr_id, wr_subject, wr_content, wr_name, wr_datetime, wr_hit, wr_good, wr_nogood
|
||||||
|
FROM ${legacySql(tbl)}
|
||||||
|
WHERE wr_id = ${id} AND wr_is_comment = 0
|
||||||
|
`;
|
||||||
|
const r = rows[0];
|
||||||
|
if (!r) return null;
|
||||||
|
const cmts = await legacySql<{ wr_name: string; wr_content: string; wr_datetime: Date }[]>`
|
||||||
|
SELECT wr_name, wr_content, wr_datetime
|
||||||
|
FROM ${legacySql(tbl)}
|
||||||
|
WHERE wr_parent = ${id} AND wr_is_comment = 1
|
||||||
|
ORDER BY wr_id ASC
|
||||||
|
LIMIT 200
|
||||||
|
`;
|
||||||
|
return {
|
||||||
|
subject: r.wr_subject,
|
||||||
|
content: r.wr_content,
|
||||||
|
authorName: r.wr_name,
|
||||||
|
createdAt: new Date(r.wr_datetime),
|
||||||
|
hit: r.wr_hit,
|
||||||
|
good: r.wr_good,
|
||||||
|
bad: r.wr_nogood,
|
||||||
|
comments: cmts.map((c) => ({ authorName: c.wr_name, content: c.wr_content, createdAt: new Date(c.wr_datetime) })),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { cookies, headers } from 'next/headers';
|
||||||
|
import { loadSession, SESSION_COOKIE } from '@slot/auth';
|
||||||
|
|
||||||
|
/** Server Component / Server Action helper to get the current logged-in member. */
|
||||||
|
export async function getCurrentMember() {
|
||||||
|
const c = await cookies();
|
||||||
|
const sid = c.get(SESSION_COOKIE)?.value;
|
||||||
|
if (!sid) return null;
|
||||||
|
const row = await loadSession(sid);
|
||||||
|
return row?.member ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRequestIp(): Promise<string | undefined> {
|
||||||
|
const h = await headers();
|
||||||
|
return h.get('x-forwarded-for')?.split(',')[0]?.trim() ?? h.get('x-real-ip') ?? undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { db, appSettings, type ThemeOverride } from '@slot/db';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { getTheme, resolveThemeForPath, type ThemeId, type ThemeManifest } from '@slot/themes';
|
||||||
|
|
||||||
|
const FALLBACK: ThemeId = (process.env.THEME_DEFAULT as ThemeId | undefined) ?? 'eyoom';
|
||||||
|
|
||||||
|
let cache: { value: { global: ThemeId; overrides: ThemeOverride[] }; expires: number } | null = null;
|
||||||
|
const TTL_MS = 5_000; // small cache to avoid hammering DB on every request; admin save invalidates by setting cache=null
|
||||||
|
|
||||||
|
export function invalidateThemeCache() { cache = null; }
|
||||||
|
|
||||||
|
async function loadThemeSettings(): Promise<{ global: ThemeId; overrides: ThemeOverride[] }> {
|
||||||
|
if (cache && cache.expires > Date.now()) return cache.value;
|
||||||
|
const rows = await db.select().from(appSettings).where(eq(appSettings.key, 'theme.global'));
|
||||||
|
const ovRows = await db.select().from(appSettings).where(eq(appSettings.key, 'theme.overrides'));
|
||||||
|
const global = (rows[0]?.value as ThemeId | undefined) ?? FALLBACK;
|
||||||
|
const overrides = (ovRows[0]?.value as ThemeOverride[] | undefined) ?? [];
|
||||||
|
cache = { value: { global, overrides }, expires: Date.now() + TTL_MS };
|
||||||
|
return cache.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getThemeForPath(pathname: string, userPref?: ThemeId | null): Promise<ThemeManifest> {
|
||||||
|
if (userPref) return getTheme(userPref);
|
||||||
|
const { global, overrides } = await loadThemeSettings();
|
||||||
|
const id = resolveThemeForPath(pathname, global, overrides);
|
||||||
|
return getTheme(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActiveGlobalTheme(): Promise<ThemeId> {
|
||||||
|
const { global } = await loadThemeSettings();
|
||||||
|
return global;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setActiveGlobalTheme(id: ThemeId): Promise<void> {
|
||||||
|
const exists = await db.select().from(appSettings).where(eq(appSettings.key, 'theme.global')).limit(1);
|
||||||
|
if (exists[0]) {
|
||||||
|
await db.update(appSettings).set({ value: id, updatedAt: new Date() }).where(eq(appSettings.key, 'theme.global'));
|
||||||
|
} else {
|
||||||
|
await db.insert(appSettings).values({ key: 'theme.global', value: id });
|
||||||
|
}
|
||||||
|
invalidateThemeCache();
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { NextResponse, type NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
// Forward the pathname as a header so the root layout (a Server Component
|
||||||
|
// that doesn't have URL access by default) can pick the right theme.
|
||||||
|
export function middleware(req: NextRequest) {
|
||||||
|
const res = NextResponse.next();
|
||||||
|
res.headers.set('x-pathname', req.nextUrl.pathname);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ['/((?!_next/static|_next/image|favicon.ico|api).*)'],
|
||||||
|
};
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"noEmit": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
],
|
||||||
|
"@slot/db": [
|
||||||
|
"../../packages/db/src"
|
||||||
|
],
|
||||||
|
"@slot/db/*": [
|
||||||
|
"../../packages/db/src/*"
|
||||||
|
],
|
||||||
|
"@slot/auth": [
|
||||||
|
"../../packages/auth/src"
|
||||||
|
],
|
||||||
|
"@slot/auth/*": [
|
||||||
|
"../../packages/auth/src/*"
|
||||||
|
],
|
||||||
|
"@slot/themes": [
|
||||||
|
"../../packages/themes/src"
|
||||||
|
],
|
||||||
|
"@slot/themes/*": [
|
||||||
|
"../../packages/themes/src/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"allowJs": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"src/**/*.ts",
|
||||||
|
"src/**/*.tsx",
|
||||||
|
".next/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "slot-monorepo",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "concurrently -n web,api \"pnpm --filter @slot/web dev\" \"pnpm --filter @slot/api dev\"",
|
||||||
|
"dev:web": "pnpm --filter @slot/web dev",
|
||||||
|
"dev:api": "pnpm --filter @slot/api dev",
|
||||||
|
"build": "pnpm -r build",
|
||||||
|
"lint": "pnpm -r lint",
|
||||||
|
"db:push": "pnpm --filter @slot/db drizzle:push",
|
||||||
|
"db:studio": "pnpm --filter @slot/db drizzle:studio",
|
||||||
|
"db:seed": "pnpm --filter @slot/db seed"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"concurrently": "^9.0.1",
|
||||||
|
"typescript": "^5.7.2"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@9.15.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "@slot/auth",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "./src/index.ts",
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts",
|
||||||
|
"./pbkdf2": "./src/pbkdf2-legacy.ts",
|
||||||
|
"./password": "./src/password.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@slot/db": "workspace:*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"@types/node": "^22.10.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { db } from '@slot/db';
|
||||||
|
import { sessions, members } from '@slot/db';
|
||||||
|
import { eq, and, gt } from 'drizzle-orm';
|
||||||
|
import { verifyPassword, hashPassword } from './password';
|
||||||
|
|
||||||
|
export * from './password';
|
||||||
|
export * from './pbkdf2-legacy';
|
||||||
|
|
||||||
|
const SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 14; // 14 days
|
||||||
|
export const SESSION_COOKIE = 'slot_sid';
|
||||||
|
|
||||||
|
export async function createSession(memberId: string, opts?: { ip?: string; userAgent?: string }): Promise<{ id: string; expiresAt: Date }> {
|
||||||
|
const id = randomBytes(32).toString('base64url');
|
||||||
|
const expiresAt = new Date(Date.now() + SESSION_TTL_MS);
|
||||||
|
await db.insert(sessions).values({
|
||||||
|
id,
|
||||||
|
memberId,
|
||||||
|
ip: opts?.ip,
|
||||||
|
userAgent: opts?.userAgent,
|
||||||
|
expiresAt,
|
||||||
|
});
|
||||||
|
return { id, expiresAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function destroySession(id: string): Promise<void> {
|
||||||
|
await db.delete(sessions).where(eq(sessions.id, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadSession(id: string) {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
member: members,
|
||||||
|
session: sessions,
|
||||||
|
})
|
||||||
|
.from(sessions)
|
||||||
|
.innerJoin(members, eq(members.id, sessions.memberId))
|
||||||
|
.where(and(eq(sessions.id, id), gt(sessions.expiresAt, new Date())))
|
||||||
|
.limit(1);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a login attempt. On success returns the member row and creates a
|
||||||
|
* session. Transparently upgrades legacy PBKDF2 password hashes to argon2id.
|
||||||
|
*/
|
||||||
|
export async function login(loginId: string, password: string, ctx?: { ip?: string; userAgent?: string }) {
|
||||||
|
const found = await db.select().from(members).where(eq(members.loginId, loginId)).limit(1);
|
||||||
|
const m = found[0];
|
||||||
|
if (!m) return { ok: false as const, error: 'invalid_credentials' as const };
|
||||||
|
const { ok, needsRehash } = await verifyPassword(password, m.passwordHash);
|
||||||
|
if (!ok) return { ok: false as const, error: 'invalid_credentials' as const };
|
||||||
|
if (m.isBlocked) return { ok: false as const, error: 'blocked' as const };
|
||||||
|
|
||||||
|
if (needsRehash) {
|
||||||
|
const newHash = await hashPassword(password);
|
||||||
|
await db.update(members).set({ passwordHash: newHash, passwordAlgo: 'argon2id', updatedAt: new Date() }).where(eq(members.id, m.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await createSession(m.id, ctx);
|
||||||
|
await db.update(members).set({ lastLoginAt: new Date(), lastLoginIp: ctx?.ip }).where(eq(members.id, m.id));
|
||||||
|
|
||||||
|
return { ok: true as const, member: m, session };
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// Password hashing using Node's built-in scrypt (no native deps).
|
||||||
|
// Format: scrypt$<N>$<r>$<p>$<salt-b64>$<hash-b64>
|
||||||
|
import { scrypt as _scrypt, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
import { verifyLegacyHash } from './pbkdf2-legacy';
|
||||||
|
|
||||||
|
const scrypt = promisify(_scrypt) as (pw: string | Buffer, salt: string | Buffer, keylen: number, opts?: { N?: number; r?: number; p?: number; maxmem?: number }) => Promise<Buffer>;
|
||||||
|
|
||||||
|
const N = 16384, R = 8, P = 1, KEYLEN = 32, MAXMEM = 64 * 1024 * 1024;
|
||||||
|
|
||||||
|
export type PasswordAlgo = 'scrypt' | 'pbkdf2-sha256' | 'pbkdf2-sha512' | 'pbkdf2-sha1';
|
||||||
|
|
||||||
|
export async function hashPassword(plain: string): Promise<string> {
|
||||||
|
const salt = randomBytes(16);
|
||||||
|
const hash = await scrypt(plain, salt, KEYLEN, { N, r: R, p: P, maxmem: MAXMEM });
|
||||||
|
return `scrypt$${N}$${R}$${P}$${salt.toString('base64')}$${hash.toString('base64')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyScrypt(plain: string, stored: string): Promise<boolean> {
|
||||||
|
const parts = stored.split('$');
|
||||||
|
if (parts.length !== 6 || parts[0] !== 'scrypt') return false;
|
||||||
|
const [, nStr, rStr, pStr, saltB64, hashB64] = parts as [string, string, string, string, string, string];
|
||||||
|
const N2 = parseInt(nStr, 10), R2 = parseInt(rStr, 10), P2 = parseInt(pStr, 10);
|
||||||
|
const salt = Buffer.from(saltB64, 'base64');
|
||||||
|
const expected = Buffer.from(hashB64, 'base64');
|
||||||
|
const calc = await scrypt(plain, salt, expected.length, { N: N2, r: R2, p: P2, maxmem: MAXMEM });
|
||||||
|
return calc.length === expected.length && timingSafeEqual(calc, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a password regardless of which algorithm produced the stored hash.
|
||||||
|
* Returns { ok, needsRehash } so the caller can transparently upgrade legacy
|
||||||
|
* gnuboard PBKDF2 hashes to scrypt on first successful login.
|
||||||
|
*/
|
||||||
|
export async function verifyPassword(plain: string, stored: string): Promise<{ ok: boolean; needsRehash: boolean }> {
|
||||||
|
if (stored.startsWith('scrypt$')) {
|
||||||
|
const ok = await verifyScrypt(plain, stored);
|
||||||
|
return { ok, needsRehash: false };
|
||||||
|
}
|
||||||
|
if (/^(sha1|sha256|sha512):\d+:/i.test(stored)) {
|
||||||
|
const ok = await verifyLegacyHash(plain, stored);
|
||||||
|
return { ok, needsRehash: ok };
|
||||||
|
}
|
||||||
|
return { ok: false, needsRehash: false };
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// Verify a password against a gnuboard5 PBKDF2 hash.
|
||||||
|
//
|
||||||
|
// Hash format: "<algo>:<iter>:<base64-salt>:<base64-hash>"
|
||||||
|
// e.g. sha256:12000:YOv3uq2h2LKfZNy07IJw4A59U48Jw/zb:XmElIvpW3eF7Gfy2jjZH2xNbYPKpiHi8
|
||||||
|
//
|
||||||
|
// `pbkdf2_default` in src/lib/pbkdf2.compat.php pre-encodes salt as the raw
|
||||||
|
// base64 string and hashes it together with the password — i.e. the salt
|
||||||
|
// passed into pbkdf2 is the *encoded* form, not its decoded bytes. We
|
||||||
|
// reproduce that quirk exactly so existing hashes verify.
|
||||||
|
|
||||||
|
import { pbkdf2 } from 'node:crypto';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
const pbkdf2Async = promisify(pbkdf2);
|
||||||
|
|
||||||
|
export async function verifyLegacyHash(password: string, hash: string): Promise<boolean> {
|
||||||
|
const parts = hash.split(':');
|
||||||
|
if (parts.length < 4) return false;
|
||||||
|
const [algo, iterStr, salt, expectedB64] = parts as [string, string, string, string];
|
||||||
|
const iter = parseInt(iterStr, 10);
|
||||||
|
if (!iter || iter < 1) return false;
|
||||||
|
|
||||||
|
const expected = Buffer.from(expectedB64, 'base64');
|
||||||
|
const len = expected.length;
|
||||||
|
|
||||||
|
// Map gnuboard algo name to Node digest names
|
||||||
|
const digest = algo.toLowerCase();
|
||||||
|
if (!['sha1', 'sha256', 'sha512'].includes(digest)) return false;
|
||||||
|
|
||||||
|
// The PHP code passes the (already base64-encoded) salt string into the
|
||||||
|
// PBKDF2 routine as bytes. Node accepts a string and uses utf-8 — same result.
|
||||||
|
const derived = await pbkdf2Async(password, salt, iter, len, digest);
|
||||||
|
return timingSafeEqual(derived, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
function timingSafeEqual(a: Buffer, b: Buffer): boolean {
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
let diff = 0;
|
||||||
|
for (let i = 0; i < a.length; i++) diff |= (a[i]! ^ b[i]!);
|
||||||
|
return diff === 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": { "noEmit": true },
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { Config } from 'drizzle-kit';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
schema: './src/schema/index.ts',
|
||||||
|
out: './drizzle',
|
||||||
|
dialect: 'postgresql',
|
||||||
|
dbCredentials: {
|
||||||
|
url: process.env.DATABASE_URL ?? 'postgresql://chpark@localhost:5432/slot',
|
||||||
|
},
|
||||||
|
schemaFilter: ['public'],
|
||||||
|
verbose: true,
|
||||||
|
strict: true,
|
||||||
|
} satisfies Config;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "@slot/db",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "./src/index.ts",
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts",
|
||||||
|
"./schema": "./src/schema/index.ts",
|
||||||
|
"./legacy": "./src/legacy.ts"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"drizzle:push": "drizzle-kit push",
|
||||||
|
"drizzle:studio": "drizzle-kit studio",
|
||||||
|
"drizzle:generate": "drizzle-kit generate",
|
||||||
|
"seed": "tsx src/seed.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"drizzle-orm": "^0.36.4",
|
||||||
|
"postgres": "^3.4.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"drizzle-kit": "^0.28.1",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"@types/node": "^22.10.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import postgres from 'postgres';
|
||||||
|
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||||
|
import * as schema from './schema/index';
|
||||||
|
|
||||||
|
const url = process.env.DATABASE_URL ?? 'postgresql://chpark@localhost:5432/slot';
|
||||||
|
|
||||||
|
export const sql = postgres(url, {
|
||||||
|
max: 16,
|
||||||
|
idle_timeout: 20,
|
||||||
|
prepare: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const db = drizzle(sql, { schema });
|
||||||
|
|
||||||
|
export * from './schema/index';
|
||||||
|
export { sql as raw };
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// Read-only client for the legacy schema (`inspection2` schema in PG).
|
||||||
|
// Until the new normalized tables are populated by ETL, the new app reads
|
||||||
|
// gnuboard tables directly via this client. After ETL completion this file
|
||||||
|
// can be deleted.
|
||||||
|
|
||||||
|
import postgres from 'postgres';
|
||||||
|
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||||
|
|
||||||
|
const url = process.env.DATABASE_URL ?? 'postgresql://chpark@localhost:5432/slot';
|
||||||
|
|
||||||
|
// Use a separate connection pool with search_path pinned to inspection2
|
||||||
|
export const legacySql = postgres(url, {
|
||||||
|
max: 8,
|
||||||
|
idle_timeout: 20,
|
||||||
|
prepare: false,
|
||||||
|
connection: {
|
||||||
|
search_path: 'inspection2,public',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const legacyDb = drizzle(legacySql);
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { pgTable, text, jsonb, timestamp } from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
|
// Key/value store for runtime configuration the admin can change without
|
||||||
|
// redeploy. The active theme lives here under the key 'theme.global'.
|
||||||
|
export const appSettings = pgTable('app_settings', {
|
||||||
|
key: text('key').primaryKey(),
|
||||||
|
value: jsonb('value').notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ThemeId = 'basic' | 'eyoom' | 'amina' | 'youngcart';
|
||||||
|
|
||||||
|
export type ThemeOverride = {
|
||||||
|
/** path glob that must match request URL.pathname; supports * suffix */
|
||||||
|
path: string;
|
||||||
|
theme: ThemeId;
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { pgTable, text, integer, smallint, boolean, timestamp, jsonb, index } from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
|
// One row per board. Slug matches the legacy `bo_table` so that URLs like
|
||||||
|
// /free, /humor, /slotreview don't change.
|
||||||
|
export const boards = pgTable('boards', {
|
||||||
|
slug: text('slug').primaryKey(),
|
||||||
|
groupSlug: text('group_slug').notNull(),
|
||||||
|
title: text('title').notNull(),
|
||||||
|
description: text('description'),
|
||||||
|
/** Min level to read */
|
||||||
|
readLevel: smallint('read_level').notNull().default(1),
|
||||||
|
/** Min level to write */
|
||||||
|
writeLevel: smallint('write_level').notNull().default(2),
|
||||||
|
/** Min level to comment */
|
||||||
|
commentLevel: smallint('comment_level').notNull().default(2),
|
||||||
|
pointsRead: integer('points_read').notNull().default(0),
|
||||||
|
pointsWrite: integer('points_write').notNull().default(0),
|
||||||
|
pointsComment: integer('points_comment').notNull().default(0),
|
||||||
|
/** Optional theme override just for this board */
|
||||||
|
themeOverride: text('theme_override'),
|
||||||
|
/** Free-form per-board options (page size, comment max depth, …) */
|
||||||
|
options: jsonb('options').notNull().default({}),
|
||||||
|
isHidden: boolean('is_hidden').notNull().default(false),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
}, (t) => ({
|
||||||
|
groupIdx: index('boards_group_idx').on(t.groupSlug),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const boardGroups = pgTable('board_groups', {
|
||||||
|
slug: text('slug').primaryKey(),
|
||||||
|
title: text('title').notNull(),
|
||||||
|
sort: smallint('sort').notNull().default(0),
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { pgTable, uuid, integer, text, timestamp, varchar, jsonb, bigint, index } from 'drizzle-orm/pg-core';
|
||||||
|
import { members } from './members';
|
||||||
|
|
||||||
|
// Game point ledger (mirrors gnuboard `game_point` — separate from forum points).
|
||||||
|
export const gamePoints = pgTable('game_points', {
|
||||||
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
memberId: uuid('member_id').notNull().references(() => members.id, { onDelete: 'cascade' }),
|
||||||
|
game: varchar('game', { length: 32 }).notNull(), // 'bacara' | 'roulette' | 'slot.swiun' | ...
|
||||||
|
delta: bigint('delta', { mode: 'number' }).notNull(),
|
||||||
|
reason: varchar('reason', { length: 64 }).notNull(), // 'bet', 'win', 'lose', 'refund', 'exchange'
|
||||||
|
meta: jsonb('meta').notNull().default({}),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
}, (t) => ({
|
||||||
|
memberDateIdx: index('game_points_member_date_idx').on(t.memberId, t.createdAt),
|
||||||
|
gameDateIdx: index('game_points_game_date_idx').on(t.game, t.createdAt),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Bacara game rounds (one row per round, multiple bets per round in bacara_bets).
|
||||||
|
export const bacaraRounds = pgTable('bacara_rounds', {
|
||||||
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
shoeId: varchar('shoe_id', { length: 32 }).notNull(),
|
||||||
|
roundNo: integer('round_no').notNull(),
|
||||||
|
result: varchar('result', { length: 8 }), // 'P' | 'B' | 'T'
|
||||||
|
playerCards: jsonb('player_cards').notNull().default([]),
|
||||||
|
bankerCards: jsonb('banker_cards').notNull().default([]),
|
||||||
|
startedAt: timestamp('started_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
endedAt: timestamp('ended_at', { withTimezone: true }),
|
||||||
|
}, (t) => ({
|
||||||
|
shoeIdx: index('bacara_rounds_shoe_idx').on(t.shoeId, t.roundNo),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const bacaraBets = pgTable('bacara_bets', {
|
||||||
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
roundId: uuid('round_id').notNull().references(() => bacaraRounds.id, { onDelete: 'cascade' }),
|
||||||
|
memberId: uuid('member_id').notNull().references(() => members.id, { onDelete: 'cascade' }),
|
||||||
|
side: varchar('side', { length: 8 }).notNull(), // 'P' | 'B' | 'T'
|
||||||
|
amount: bigint('amount', { mode: 'number' }).notNull(),
|
||||||
|
payout: bigint('payout', { mode: 'number' }).notNull().default(0),
|
||||||
|
status: varchar('status', { length: 8 }).notNull().default('open'), // 'open' | 'won' | 'lost' | 'refund'
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
}, (t) => ({
|
||||||
|
roundIdx: index('bacara_bets_round_idx').on(t.roundId),
|
||||||
|
memberIdx: index('bacara_bets_member_idx').on(t.memberId, t.createdAt),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Lottery tickets ("슬생복권")
|
||||||
|
export const lotteryTickets = pgTable('lottery_tickets', {
|
||||||
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
drawNo: integer('draw_no').notNull(),
|
||||||
|
memberId: uuid('member_id').notNull().references(() => members.id, { onDelete: 'cascade' }),
|
||||||
|
numbers: jsonb('numbers').notNull(), // [3, 12, 24, 27, 38, 41]
|
||||||
|
prizeRank: integer('prize_rank'), // null until drawn
|
||||||
|
prizePoints: bigint('prize_points', { mode: 'number' }).notNull().default(0),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
}, (t) => ({
|
||||||
|
drawIdx: index('lottery_tickets_draw_idx').on(t.drawNo),
|
||||||
|
memberIdx: index('lottery_tickets_member_idx').on(t.memberId),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Roulette spins
|
||||||
|
export const rouletteSpins = pgTable('roulette_spins', {
|
||||||
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
memberId: uuid('member_id').notNull().references(() => members.id, { onDelete: 'cascade' }),
|
||||||
|
prizeId: integer('prize_id').notNull(),
|
||||||
|
prizePoints: bigint('prize_points', { mode: 'number' }).notNull().default(0),
|
||||||
|
cost: bigint('cost', { mode: 'number' }).notNull().default(0),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
}, (t) => ({
|
||||||
|
memberIdx: index('roulette_spins_member_idx').on(t.memberId, t.createdAt),
|
||||||
|
}));
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// Re-export every table from per-domain files so a single import
|
||||||
|
// (`import * as schema from '@slot/db/schema'`) gives Drizzle the full graph.
|
||||||
|
export * from './app-settings';
|
||||||
|
export * from './members';
|
||||||
|
export * from './sessions';
|
||||||
|
export * from './boards';
|
||||||
|
export * from './posts';
|
||||||
|
export * from './point-ledger';
|
||||||
|
export * from './games';
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { pgTable, uuid, text, integer, smallint, boolean, timestamp, varchar, index, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
|
// Members are imported from gnuboard `g5_member` via ETL.
|
||||||
|
// Password verification is dual-mode: legacy PBKDF2 hashes are accepted and
|
||||||
|
// transparently rehashed to argon2id on first successful login.
|
||||||
|
export const members = pgTable('members', {
|
||||||
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
legacyMbId: varchar('legacy_mb_id', { length: 32 }).notNull(),
|
||||||
|
loginId: varchar('login_id', { length: 32 }).notNull(),
|
||||||
|
passwordHash: text('password_hash').notNull(),
|
||||||
|
/** Algorithm marker so we know how to verify */
|
||||||
|
passwordAlgo: varchar('password_algo', { length: 16 }).notNull().default('pbkdf2-sha256'),
|
||||||
|
name: text('name'),
|
||||||
|
nick: text('nick').notNull(),
|
||||||
|
email: varchar('email', { length: 255 }),
|
||||||
|
level: smallint('level').notNull().default(2),
|
||||||
|
pointBalance: integer('point_balance').notNull().default(0),
|
||||||
|
isBlocked: boolean('is_blocked').notNull().default(false),
|
||||||
|
blockedUntil: timestamp('blocked_until', { withTimezone: true }),
|
||||||
|
emailVerifiedAt: timestamp('email_verified_at', { withTimezone: true }),
|
||||||
|
totpSecret: text('totp_secret'), // null = 2FA off
|
||||||
|
lastLoginAt: timestamp('last_login_at', { withTimezone: true }),
|
||||||
|
lastLoginIp: varchar('last_login_ip', { length: 64 }),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
/** which theme this user prefers (null = use site default) */
|
||||||
|
themePref: text('theme_pref'),
|
||||||
|
}, (t) => ({
|
||||||
|
loginIdUnique: uniqueIndex('members_login_id_unique').on(t.loginId),
|
||||||
|
legacyIdUnique: uniqueIndex('members_legacy_mb_id_unique').on(t.legacyMbId),
|
||||||
|
emailIdx: index('members_email_idx').on(t.email),
|
||||||
|
createdIdx: index('members_created_idx').on(t.createdAt),
|
||||||
|
}));
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { pgTable, uuid, integer, text, timestamp, varchar, index } from 'drizzle-orm/pg-core';
|
||||||
|
import { members } from './members';
|
||||||
|
|
||||||
|
// Append-only ledger of every point movement (earn/spend/exchange).
|
||||||
|
// Members.point_balance is a denormalized sum kept current by trigger or worker.
|
||||||
|
export const pointLedger = pgTable('point_ledger', {
|
||||||
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
memberId: uuid('member_id').notNull().references(() => members.id, { onDelete: 'cascade' }),
|
||||||
|
delta: integer('delta').notNull(), // signed
|
||||||
|
reason: varchar('reason', { length: 64 }).notNull(), // 'post.write', 'comment.write', 'attendance', 'game.bacara.win', 'exchange', ...
|
||||||
|
refType: varchar('ref_type', { length: 32 }),
|
||||||
|
refId: text('ref_id'),
|
||||||
|
memo: text('memo'),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
}, (t) => ({
|
||||||
|
memberDateIdx: index('point_ledger_member_date_idx').on(t.memberId, t.createdAt),
|
||||||
|
reasonIdx: index('point_ledger_reason_idx').on(t.reason, t.createdAt),
|
||||||
|
}));
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { pgTable, uuid, text, integer, smallint, boolean, timestamp, jsonb, bigint, varchar, index } from 'drizzle-orm/pg-core';
|
||||||
|
import { members } from './members';
|
||||||
|
import { boards } from './boards';
|
||||||
|
|
||||||
|
// Single posts table replaces gnuboard's per-board g5_write_<slug> tables.
|
||||||
|
// Comments share the same table (is_comment = true, parent_id = topic id).
|
||||||
|
export const posts = pgTable('posts', {
|
||||||
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
boardSlug: text('board_slug').notNull().references(() => boards.slug),
|
||||||
|
/** Topic id for comments; null for top-level posts */
|
||||||
|
parentId: uuid('parent_id'),
|
||||||
|
/** Legacy gnuboard wr_num for thread sort compatibility */
|
||||||
|
threadOrder: bigint('thread_order', { mode: 'number' }).notNull().default(0),
|
||||||
|
/** Legacy gnuboard wr_reply for nested comment ordering */
|
||||||
|
replyPath: varchar('reply_path', { length: 32 }).notNull().default(''),
|
||||||
|
authorId: uuid('author_id').references(() => members.id),
|
||||||
|
authorName: text('author_name'), // for guest posts
|
||||||
|
authorIp: varchar('author_ip', { length: 64 }),
|
||||||
|
subject: text('subject'),
|
||||||
|
content: text('content').notNull().default(''),
|
||||||
|
attachments: jsonb('attachments').notNull().default([]),
|
||||||
|
hit: integer('hit').notNull().default(0),
|
||||||
|
good: integer('good').notNull().default(0),
|
||||||
|
bad: integer('bad').notNull().default(0),
|
||||||
|
isComment: boolean('is_comment').notNull().default(false),
|
||||||
|
isSecret: boolean('is_secret').notNull().default(false),
|
||||||
|
isNotice: boolean('is_notice').notNull().default(false),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
}, (t) => ({
|
||||||
|
boardCreatedIdx: index('posts_board_created_idx').on(t.boardSlug, t.createdAt),
|
||||||
|
boardThreadIdx: index('posts_board_thread_idx').on(t.boardSlug, t.threadOrder, t.replyPath),
|
||||||
|
parentIdx: index('posts_parent_idx').on(t.parentId),
|
||||||
|
authorIdx: index('posts_author_idx').on(t.authorId, t.createdAt),
|
||||||
|
}));
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { pgTable, uuid, text, timestamp, index, varchar } from 'drizzle-orm/pg-core';
|
||||||
|
import { members } from './members';
|
||||||
|
|
||||||
|
export const sessions = pgTable('sessions', {
|
||||||
|
id: text('id').primaryKey(), // opaque session token (random 32 bytes b64url)
|
||||||
|
memberId: uuid('member_id').notNull().references(() => members.id, { onDelete: 'cascade' }),
|
||||||
|
ip: varchar('ip', { length: 64 }),
|
||||||
|
userAgent: text('user_agent'),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||||
|
}, (t) => ({
|
||||||
|
memberIdx: index('sessions_member_idx').on(t.memberId),
|
||||||
|
expiresIdx: index('sessions_expires_idx').on(t.expiresAt),
|
||||||
|
}));
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { db, appSettings, members } from './index';
|
||||||
|
import { hashPassword } from '@slot/auth/password';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
// Idempotent seed: ensures app_settings has the default theme picked, and a
|
||||||
|
// development admin account exists in the new schema so login can be tested
|
||||||
|
// without running the full ETL.
|
||||||
|
async function main() {
|
||||||
|
console.log('Seeding app_settings...');
|
||||||
|
const existing = await db.select().from(appSettings).where(eq(appSettings.key, 'theme.global')).limit(1);
|
||||||
|
if (!existing[0]) {
|
||||||
|
await db.insert(appSettings).values({ key: 'theme.global', value: 'eyoom' });
|
||||||
|
console.log(' ✓ theme.global = "eyoom"');
|
||||||
|
} else {
|
||||||
|
console.log(' - theme.global already set:', existing[0].value);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Seeding dev admin (admin/test1234)...');
|
||||||
|
const adminExists = await db.select().from(members).where(eq(members.loginId, 'admin')).limit(1);
|
||||||
|
if (!adminExists[0]) {
|
||||||
|
const hash = await hashPassword('test1234');
|
||||||
|
await db.insert(members).values({
|
||||||
|
legacyMbId: 'admin',
|
||||||
|
loginId: 'admin',
|
||||||
|
passwordHash: hash,
|
||||||
|
passwordAlgo: 'argon2id',
|
||||||
|
name: '슬생관리자',
|
||||||
|
nick: '슬생관리자',
|
||||||
|
email: 'admin@local.test',
|
||||||
|
level: 12,
|
||||||
|
});
|
||||||
|
console.log(' ✓ admin / test1234 created');
|
||||||
|
} else {
|
||||||
|
console.log(' - admin already exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Seeding dev member (testlogin/test1234)...');
|
||||||
|
const userExists = await db.select().from(members).where(eq(members.loginId, 'testlogin')).limit(1);
|
||||||
|
if (!userExists[0]) {
|
||||||
|
const hash = await hashPassword('test1234');
|
||||||
|
await db.insert(members).values({
|
||||||
|
legacyMbId: 'testlogin',
|
||||||
|
loginId: 'testlogin',
|
||||||
|
passwordHash: hash,
|
||||||
|
passwordAlgo: 'argon2id',
|
||||||
|
name: '테스트',
|
||||||
|
nick: '테스트',
|
||||||
|
email: 'test@local.test',
|
||||||
|
level: 2,
|
||||||
|
});
|
||||||
|
console.log(' ✓ testlogin / test1234 created');
|
||||||
|
} else {
|
||||||
|
console.log(' - testlogin already exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Done.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "@slot/themes",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "./src/index.ts",
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts",
|
||||||
|
"./registry": "./src/registry.ts",
|
||||||
|
"./types": "./src/types.ts",
|
||||||
|
"./basic": "./src/basic/index.tsx",
|
||||||
|
"./eyoom": "./src/eyoom/index.tsx",
|
||||||
|
"./amina": "./src/amina/index.tsx",
|
||||||
|
"./youngcart": "./src/youngcart/index.tsx"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"@types/react": "^19.0.1",
|
||||||
|
"@types/react-dom": "^19.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import type { ThemeManifest, LayoutProps, HeaderProps, FooterProps, SidebarProps, PostListProps, PostViewProps, LoginFormProps } from '../types';
|
||||||
|
|
||||||
|
// Aminam Builder reproduction — bright modern card-based layout, mega menu,
|
||||||
|
// gradient accent, card grid for boards.
|
||||||
|
|
||||||
|
const tokens = {
|
||||||
|
color: {
|
||||||
|
primary: '#7c3aed', // violet
|
||||||
|
primaryDark: '#6d28d9',
|
||||||
|
bg: '#fafafa',
|
||||||
|
bgSurface: '#ffffff',
|
||||||
|
text: '#0f172a',
|
||||||
|
textMuted: '#64748b',
|
||||||
|
border: '#e2e8f0',
|
||||||
|
success: '#16a34a',
|
||||||
|
danger: '#dc2626',
|
||||||
|
warning: '#d97706',
|
||||||
|
},
|
||||||
|
font: { sans: '"Pretendard", "Noto Sans KR", system-ui, sans-serif', mono: '"JetBrains Mono", monospace' },
|
||||||
|
radius: { sm: '6px', md: '12px', lg: '20px', pill: '999px' },
|
||||||
|
space: { xs: '4px', sm: '8px', md: '16px', lg: '24px', xl: '48px' },
|
||||||
|
z: { sticky: 100, modal: 1000, toast: 2000 },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function Root({ children }: LayoutProps) {
|
||||||
|
return <div style={{ background: 'var(--color-bg)', color: 'var(--color-text)', minHeight: '100vh', fontFamily: 'var(--font-sans)' }}>{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Header({ siteName, loggedInName }: HeaderProps) {
|
||||||
|
return (
|
||||||
|
<header style={{ background: 'linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-dark) 100%)', color: '#fff', padding: 'var(--space-md) var(--space-xl)' }}>
|
||||||
|
<div style={{ maxWidth: 1280, margin: '0 auto', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<a href="/" style={{ fontSize: 24, fontWeight: 800, color: '#fff', textDecoration: 'none' }}>✨ {siteName}</a>
|
||||||
|
<nav style={{ display: 'flex', gap: 'var(--space-lg)', fontSize: 14 }}>
|
||||||
|
<a href="/free" style={hLink()}>커뮤니티</a>
|
||||||
|
<a href="/review" style={hLink()}>리뷰</a>
|
||||||
|
<a href="/event" style={hLink()}>이벤트</a>
|
||||||
|
<a href="/mukti" style={hLink()}>검증</a>
|
||||||
|
</nav>
|
||||||
|
<div style={{ display: 'flex', gap: 'var(--space-md)', fontSize: 14 }}>
|
||||||
|
{loggedInName ? (
|
||||||
|
<>
|
||||||
|
<span>👤 {loggedInName}</span>
|
||||||
|
<form action="/api/auth/logout" method="POST"><button style={ghostBtn()}>로그아웃</button></form>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<a href="/login" style={{ ...hLink(), background: '#fff', color: 'var(--color-primary)', padding: '6px 16px', borderRadius: 'var(--radius-pill)' }}>로그인</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Footer({ siteName }: FooterProps) {
|
||||||
|
return (
|
||||||
|
<footer style={{ background: 'var(--color-bgSurface)', borderTop: '1px solid var(--color-border)', padding: 'var(--space-xl)', marginTop: 'var(--space-xl)', textAlign: 'center', color: 'var(--color-textMuted)', fontSize: 13 }}>
|
||||||
|
© {new Date().getFullYear()} {siteName} — Aminam Builder theme
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Sidebar() {
|
||||||
|
return (
|
||||||
|
<aside style={{ background: 'var(--color-bgSurface)', borderRadius: 'var(--radius-lg)', padding: 'var(--space-lg)', boxShadow: '0 1px 3px rgba(0,0,0,0.1)' }}>
|
||||||
|
<h3 style={{ fontSize: 14, color: 'var(--color-text)', margin: '0 0 12px' }}>실시간 인기</h3>
|
||||||
|
<ul style={{ listStyle: 'none', padding: 0, fontSize: 13, lineHeight: 2 }}>
|
||||||
|
<li>🔥 <a href="/free" style={{ color: 'var(--color-primary)', textDecoration: 'none' }}>슬생복권 1024회 당첨자 발표</a></li>
|
||||||
|
<li>📈 <a href="/review" style={{ color: 'var(--color-primary)', textDecoration: 'none' }}>프라그마틱 잭팟 후기</a></li>
|
||||||
|
<li>⚠️ <a href="/mukti" style={{ color: 'var(--color-primary)', textDecoration: 'none' }}>먹튀 신고 — XX카지노</a></li>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PostList({ boardTitle, items, page, totalPages, boardSlug }: PostListProps) {
|
||||||
|
return (
|
||||||
|
<section style={{ maxWidth: 1280, margin: '0 auto', padding: 'var(--space-xl)' }}>
|
||||||
|
<h1 style={{ fontSize: 28, marginBottom: 'var(--space-lg)' }}>{boardTitle}</h1>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 'var(--space-md)' }}>
|
||||||
|
{items.map((p) => (
|
||||||
|
<a key={p.id} href={`/${boardSlug}/${p.id}`} style={card()}>
|
||||||
|
<h3 style={{ fontSize: 16, margin: '0 0 8px', color: 'var(--color-text)' }}>{p.subject}</h3>
|
||||||
|
<p style={{ margin: 0, fontSize: 12, color: 'var(--color-textMuted)' }}>
|
||||||
|
<strong style={{ color: 'var(--color-primary)' }}>{p.authorName}</strong> · {new Date(p.createdAt).toLocaleDateString('ko-KR')}
|
||||||
|
</p>
|
||||||
|
<div style={{ marginTop: 12, display: 'flex', gap: 'var(--space-md)', fontSize: 12, color: 'var(--color-textMuted)' }}>
|
||||||
|
<span>💬 {p.commentCount}</span>
|
||||||
|
<span>👁 {p.hit}</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Pagination boardSlug={boardSlug} page={page} totalPages={totalPages} />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PostView({ boardTitle, subject, authorName, createdAt, content, hit, good, bad, comments }: PostViewProps) {
|
||||||
|
return (
|
||||||
|
<article style={{ maxWidth: 980, margin: '0 auto', padding: 'var(--space-xl)' }}>
|
||||||
|
<div style={{ background: 'var(--color-bgSurface)', borderRadius: 'var(--radius-lg)', padding: 'var(--space-xl)', boxShadow: '0 4px 12px rgba(0,0,0,0.06)' }}>
|
||||||
|
<span style={{ display: 'inline-block', background: 'var(--color-primary)', color: '#fff', padding: '4px 12px', borderRadius: 'var(--radius-pill)', fontSize: 12 }}>{boardTitle}</span>
|
||||||
|
<h1 style={{ fontSize: 32, margin: '12px 0' }}>{subject}</h1>
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--color-textMuted)' }}>
|
||||||
|
<strong style={{ color: 'var(--color-primary)' }}>{authorName}</strong> · {new Date(createdAt).toLocaleString('ko-KR')} · 조회 {hit}
|
||||||
|
</div>
|
||||||
|
<div style={{ borderTop: '1px solid var(--color-border)', marginTop: 'var(--space-lg)', paddingTop: 'var(--space-lg)', minHeight: 200, lineHeight: 1.8 }} dangerouslySetInnerHTML={{ __html: content }} />
|
||||||
|
<div style={{ display: 'flex', gap: 'var(--space-md)', justifyContent: 'center', marginTop: 'var(--space-xl)' }}>
|
||||||
|
<button style={{ ...pillBtn(), background: 'var(--color-success)' }}>👍 {good}</button>
|
||||||
|
<button style={{ ...pillBtn(), background: 'var(--color-danger)' }}>👎 {bad}</button>
|
||||||
|
</div>
|
||||||
|
<section style={{ marginTop: 'var(--space-xl)' }}>{comments}</section>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginForm({ error }: LoginFormProps) {
|
||||||
|
return (
|
||||||
|
<form action="/api/auth/login" method="POST" style={{ maxWidth: 400, margin: '80px auto', background: 'var(--color-bgSurface)', borderRadius: 'var(--radius-lg)', padding: 'var(--space-xl)', boxShadow: '0 8px 24px rgba(0,0,0,0.08)' }}>
|
||||||
|
<h1 style={{ fontSize: 24, marginBottom: 'var(--space-lg)', textAlign: 'center', background: 'linear-gradient(135deg, var(--color-primary), var(--color-primary-dark))', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent' }}>아미나 로그인</h1>
|
||||||
|
{error && <p style={{ color: 'var(--color-danger)', fontSize: 13, marginBottom: 'var(--space-md)' }}>{error}</p>}
|
||||||
|
<input name="loginId" placeholder="아이디" required style={input()} />
|
||||||
|
<input name="password" type="password" placeholder="비밀번호" required style={input()} />
|
||||||
|
<button type="submit" style={{ ...pillBtn(), width: '100%', marginTop: 'var(--space-md)' }}>로그인</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Pagination({ boardSlug, page, totalPages }: { boardSlug: string; page: number; totalPages: number }) {
|
||||||
|
if (totalPages <= 1) return null;
|
||||||
|
const start = Math.max(1, page - 4);
|
||||||
|
const end = Math.min(totalPages, start + 9);
|
||||||
|
const range: number[] = [];
|
||||||
|
for (let p = start; p <= end; p++) range.push(p);
|
||||||
|
return (
|
||||||
|
<nav style={{ display: 'flex', gap: 6, justifyContent: 'center', marginTop: 'var(--space-xl)' }}>
|
||||||
|
{range.map((p) => (
|
||||||
|
<a key={p} href={`/${boardSlug}?page=${p}`} style={{ padding: '8px 14px', borderRadius: 'var(--radius-pill)', background: p === page ? 'var(--color-primary)' : 'var(--color-bgSurface)', color: p === page ? '#fff' : 'var(--color-text)', textDecoration: 'none', fontSize: 13, boxShadow: '0 1px 3px rgba(0,0,0,0.1)' }}>{p}</a>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hLink = (): React.CSSProperties => ({ color: '#fff', textDecoration: 'none', fontWeight: 500 });
|
||||||
|
const card = (): React.CSSProperties => ({ background: 'var(--color-bgSurface)', borderRadius: 'var(--radius-md)', padding: 'var(--space-md)', textDecoration: 'none', boxShadow: '0 1px 3px rgba(0,0,0,0.1)', transition: 'transform 0.15s' });
|
||||||
|
const input = (): React.CSSProperties => ({ display: 'block', width: '100%', padding: '12px 16px', marginBottom: 'var(--space-md)', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-pill)', fontSize: 14, boxSizing: 'border-box' });
|
||||||
|
const pillBtn = (): React.CSSProperties => ({ padding: '10px 20px', border: 'none', borderRadius: 'var(--radius-pill)', background: 'var(--color-primary)', color: '#fff', cursor: 'pointer', fontSize: 14, fontWeight: 600 });
|
||||||
|
const ghostBtn = (): React.CSSProperties => ({ padding: '6px 14px', border: '1px solid #fff', background: 'transparent', color: '#fff', borderRadius: 'var(--radius-pill)', cursor: 'pointer', fontSize: 13 });
|
||||||
|
|
||||||
|
export const aminaTheme: ThemeManifest = {
|
||||||
|
id: 'amina',
|
||||||
|
name: '아미나빌더',
|
||||||
|
tokens,
|
||||||
|
layouts: { root: Root },
|
||||||
|
slots: { Header, Footer, Sidebar, PostList, PostView, LoginForm },
|
||||||
|
features: { megaMenu: true },
|
||||||
|
};
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import type { ThemeManifest, LayoutProps, HeaderProps, FooterProps, SidebarProps, PostListProps, PostViewProps, LoginFormProps } from '../types';
|
||||||
|
|
||||||
|
const tokens = {
|
||||||
|
color: {
|
||||||
|
primary: '#1f6feb',
|
||||||
|
primaryDark: '#1158c7',
|
||||||
|
bg: '#ffffff',
|
||||||
|
bgSurface: '#f6f8fa',
|
||||||
|
text: '#1f2328',
|
||||||
|
textMuted: '#656d76',
|
||||||
|
border: '#d0d7de',
|
||||||
|
success: '#1a7f37',
|
||||||
|
danger: '#cf222e',
|
||||||
|
warning: '#bf8700',
|
||||||
|
},
|
||||||
|
font: { sans: '"Noto Sans KR", -apple-system, system-ui, sans-serif', mono: '"JetBrains Mono", monospace' },
|
||||||
|
radius: { sm: '4px', md: '6px', lg: '8px', pill: '999px' },
|
||||||
|
space: { xs: '4px', sm: '8px', md: '16px', lg: '24px', xl: '40px' },
|
||||||
|
z: { sticky: 100, modal: 1000, toast: 2000 },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function Root({ children }: LayoutProps) {
|
||||||
|
return <div style={{ background: 'var(--color-bg)', color: 'var(--color-text)', minHeight: '100vh', fontFamily: 'var(--font-sans)' }}>{children}</div>;
|
||||||
|
}
|
||||||
|
function Header({ siteName, loggedInName }: HeaderProps) {
|
||||||
|
return (
|
||||||
|
<header style={{ borderBottom: '1px solid var(--color-border)', padding: 'var(--space-md) var(--space-lg)', display: 'flex', justifyContent: 'space-between', alignItems: 'center', background: 'var(--color-bg)' }}>
|
||||||
|
<a href="/" style={{ fontSize: 20, fontWeight: 700, color: 'var(--color-primary)', textDecoration: 'none' }}>{siteName}</a>
|
||||||
|
<nav style={{ display: 'flex', gap: 'var(--space-lg)', fontSize: 14 }}>
|
||||||
|
<a href="/free" style={{ color: 'var(--color-text)', textDecoration: 'none' }}>자유게시판</a>
|
||||||
|
<a href="/review" style={{ color: 'var(--color-text)', textDecoration: 'none' }}>리뷰</a>
|
||||||
|
<a href="/mukti" style={{ color: 'var(--color-text)', textDecoration: 'none' }}>먹튀검증</a>
|
||||||
|
<a href="/notice" style={{ color: 'var(--color-text)', textDecoration: 'none' }}>공지</a>
|
||||||
|
{loggedInName ? (
|
||||||
|
<>
|
||||||
|
<span style={{ color: 'var(--color-text-muted)' }}>{loggedInName}님</span>
|
||||||
|
<form action="/api/auth/logout" method="POST"><button style={btn()}>로그아웃</button></form>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<a href="/login" style={{ color: 'var(--color-primary)', textDecoration: 'none' }}>로그인</a>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function Footer({ siteName }: FooterProps) {
|
||||||
|
return (
|
||||||
|
<footer style={{ borderTop: '1px solid var(--color-border)', padding: 'var(--space-lg)', textAlign: 'center', color: 'var(--color-text-muted)', fontSize: 13 }}>
|
||||||
|
<p>© {new Date().getFullYear()} {siteName} — basic theme</p>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function Sidebar() {
|
||||||
|
return (
|
||||||
|
<aside style={{ width: 240, padding: 'var(--space-md)', borderLeft: '1px solid var(--color-border)' }}>
|
||||||
|
<h3 style={{ fontSize: 14, color: 'var(--color-text-muted)', margin: '0 0 8px' }}>커뮤니티</h3>
|
||||||
|
<ul style={{ listStyle: 'none', padding: 0, fontSize: 14 }}>
|
||||||
|
<li><a href="/free" style={linkSt()}>자유게시판</a></li>
|
||||||
|
<li><a href="/humor" style={linkSt()}>유머</a></li>
|
||||||
|
<li><a href="/pick" style={linkSt()}>픽게시판</a></li>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function PostList({ boardTitle, items, page, totalPages, boardSlug }: PostListProps) {
|
||||||
|
return (
|
||||||
|
<section style={{ padding: 'var(--space-lg)' }}>
|
||||||
|
<h1 style={{ fontSize: 22, marginBottom: 'var(--space-md)' }}>{boardTitle}</h1>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
|
||||||
|
<thead><tr style={{ borderBottom: '2px solid var(--color-border)' }}>
|
||||||
|
<th style={{ textAlign: 'left', padding: 8, width: 60 }}>번호</th>
|
||||||
|
<th style={{ textAlign: 'left', padding: 8 }}>제목</th>
|
||||||
|
<th style={{ textAlign: 'left', padding: 8, width: 100 }}>글쓴이</th>
|
||||||
|
<th style={{ textAlign: 'left', padding: 8, width: 60 }}>조회</th>
|
||||||
|
<th style={{ textAlign: 'left', padding: 8, width: 100 }}>날짜</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((p, i) => (
|
||||||
|
<tr key={p.id} style={{ borderBottom: '1px solid var(--color-border)' }}>
|
||||||
|
<td style={{ padding: 8 }}>{(page - 1) * 20 + i + 1}</td>
|
||||||
|
<td style={{ padding: 8 }}><a href={`/${boardSlug}/${p.id}`} style={{ color: 'var(--color-text)', textDecoration: 'none' }}>{p.subject}{p.commentCount > 0 && <span style={{ color: 'var(--color-primary)', marginLeft: 4 }}>[{p.commentCount}]</span>}</a></td>
|
||||||
|
<td style={{ padding: 8 }}>{p.authorName}</td>
|
||||||
|
<td style={{ padding: 8 }}>{p.hit}</td>
|
||||||
|
<td style={{ padding: 8 }}>{new Date(p.createdAt).toISOString().slice(0, 10)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<Pagination boardSlug={boardSlug} page={page} totalPages={totalPages} />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function PostView({ boardTitle, subject, authorName, createdAt, content, hit, good, bad, comments }: PostViewProps) {
|
||||||
|
return (
|
||||||
|
<article style={{ padding: 'var(--space-lg)' }}>
|
||||||
|
<h2 style={{ fontSize: 14, color: 'var(--color-text-muted)' }}>{boardTitle}</h2>
|
||||||
|
<h1 style={{ fontSize: 24, margin: '8px 0 16px' }}>{subject}</h1>
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--color-text-muted)', marginBottom: 'var(--space-lg)' }}>
|
||||||
|
<span>{authorName}</span> · <span>{new Date(createdAt).toLocaleString('ko-KR')}</span> · <span>조회 {hit}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ borderTop: '1px solid var(--color-border)', borderBottom: '1px solid var(--color-border)', padding: 'var(--space-lg) 0', minHeight: 200 }} dangerouslySetInnerHTML={{ __html: content }} />
|
||||||
|
<div style={{ display: 'flex', gap: 'var(--space-md)', margin: 'var(--space-lg) 0' }}>
|
||||||
|
<button style={btn('var(--color-success)')}>👍 추천 {good}</button>
|
||||||
|
<button style={btn('var(--color-danger)')}>👎 비추천 {bad}</button>
|
||||||
|
</div>
|
||||||
|
<section>{comments}</section>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function LoginForm({ error }: LoginFormProps) {
|
||||||
|
return (
|
||||||
|
<form action="/api/auth/login" method="POST" style={{ maxWidth: 320, margin: '60px auto', padding: 'var(--space-lg)', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-md)' }}>
|
||||||
|
<h1 style={{ fontSize: 20, marginBottom: 'var(--space-md)' }}>로그인</h1>
|
||||||
|
{error && <p style={{ color: 'var(--color-danger)', fontSize: 13, marginBottom: 'var(--space-md)' }}>{error}</p>}
|
||||||
|
<input name="loginId" placeholder="아이디" required style={input()} />
|
||||||
|
<input name="password" type="password" placeholder="비밀번호" required style={input()} />
|
||||||
|
<button type="submit" style={btn('var(--color-primary)', true)}>로그인</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Pagination({ boardSlug, page, totalPages }: { boardSlug: string; page: number; totalPages: number }) {
|
||||||
|
if (totalPages <= 1) return null;
|
||||||
|
const start = Math.max(1, page - 4);
|
||||||
|
const end = Math.min(totalPages, start + 9);
|
||||||
|
const range: number[] = [];
|
||||||
|
for (let p = start; p <= end; p++) range.push(p);
|
||||||
|
return (
|
||||||
|
<nav style={{ display: 'flex', gap: 4, justifyContent: 'center', marginTop: 'var(--space-lg)' }}>
|
||||||
|
{range.map((p) => (
|
||||||
|
<a key={p} href={`/${boardSlug}?page=${p}`} style={{ padding: '6px 10px', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', background: p === page ? 'var(--color-primary)' : 'transparent', color: p === page ? '#fff' : 'var(--color-text)', textDecoration: 'none', fontSize: 13 }}>{p}</a>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkSt = (): React.CSSProperties => ({ display: 'block', padding: '6px 0', color: 'var(--color-text)', textDecoration: 'none' });
|
||||||
|
const input = (): React.CSSProperties => ({ display: 'block', width: '100%', padding: 'var(--space-sm)', marginBottom: 'var(--space-sm)', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', background: 'var(--color-bg)', color: 'var(--color-text)', fontSize: 14 });
|
||||||
|
const btn = (color = 'var(--color-primary)', block = false): React.CSSProperties => ({ padding: '8px 14px', border: 'none', borderRadius: 'var(--radius-sm)', background: color, color: '#fff', cursor: 'pointer', fontSize: 14, width: block ? '100%' : 'auto' });
|
||||||
|
|
||||||
|
export const basicTheme: ThemeManifest = {
|
||||||
|
id: 'basic',
|
||||||
|
name: '기본',
|
||||||
|
tokens,
|
||||||
|
layouts: { root: Root },
|
||||||
|
slots: { Header, Footer, Sidebar, PostList, PostView, LoginForm },
|
||||||
|
};
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import type { ThemeManifest, LayoutProps, HeaderProps, FooterProps, SidebarProps, PostListProps, PostViewProps, LoginFormProps } from '../types';
|
||||||
|
|
||||||
|
// Reproduction of the production theme/eb4_maga_005 magazine layout — dark
|
||||||
|
// magazine UI with orange accent, mega menu, and a right-side ranking sidebar.
|
||||||
|
|
||||||
|
const tokens = {
|
||||||
|
color: {
|
||||||
|
primary: '#ff5722',
|
||||||
|
primaryDark: '#e64a19',
|
||||||
|
bg: '#0e0f12',
|
||||||
|
bgSurface: '#16181d',
|
||||||
|
text: '#e6e6e6',
|
||||||
|
textMuted: '#9aa0a6',
|
||||||
|
border: '#2a2d34',
|
||||||
|
success: '#21d07a',
|
||||||
|
danger: '#e74c3c',
|
||||||
|
warning: '#f5b041',
|
||||||
|
},
|
||||||
|
font: { sans: '"Noto Sans KR", system-ui, sans-serif', mono: '"JetBrains Mono", monospace' },
|
||||||
|
radius: { sm: '4px', md: '8px', lg: '14px', pill: '999px' },
|
||||||
|
space: { xs: '4px', sm: '8px', md: '16px', lg: '24px', xl: '40px' },
|
||||||
|
z: { sticky: 100, modal: 1000, toast: 2000 },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function Root({ children }: LayoutProps) {
|
||||||
|
return (
|
||||||
|
<div style={{ background: 'var(--color-bg)', color: 'var(--color-text)', minHeight: '100vh', fontFamily: 'var(--font-sans)' }}>
|
||||||
|
<div style={{ maxWidth: 1280, margin: '0 auto' }}>{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Header({ siteName, loggedInName }: HeaderProps) {
|
||||||
|
const menus = [
|
||||||
|
{ label: '커뮤니티', items: [{ href: '/free', label: '자유게시판' }, { href: '/humor', label: '유머/이슈' }, { href: '/pick', label: '픽게시판' }] },
|
||||||
|
{ label: '리뷰', items: [{ href: '/review', label: '후기게시판' }, { href: '/slotreview', label: '슬롯 리뷰' }] },
|
||||||
|
{ label: '슬롯사', items: [{ href: '/slotche2', label: '프라그마틱' }, { href: '/slotche4', label: '플레이엔고' }, { href: '/slotche5', label: '릴랙스게이밍' }] },
|
||||||
|
{ label: '검증', items: [{ href: '/mukti', label: '먹튀사이트' }, { href: '/fakesite', label: '가품사이트' }, { href: '/complaint', label: '먹튀신고' }] },
|
||||||
|
{ label: '이벤트', items: [{ href: '/event', label: '이벤트' }, { href: '/lottery_ticket', label: '슬생복권' }, { href: '/gift_coupons', label: '기프티콘교환' }] },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<header style={{ background: 'linear-gradient(180deg, #16181d 0%, #0e0f12 100%)', borderBottom: '1px solid var(--color-border)', padding: 'var(--space-md) var(--space-lg)' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<a href="/" style={{ fontSize: 24, fontWeight: 800, color: 'var(--color-primary)', textDecoration: 'none', letterSpacing: '-0.02em' }}>{siteName}</a>
|
||||||
|
<div style={{ display: 'flex', gap: 'var(--space-md)', fontSize: 13 }}>
|
||||||
|
{loggedInName ? (
|
||||||
|
<>
|
||||||
|
<span style={{ color: 'var(--color-text-muted)' }}>{loggedInName}님 환영합니다</span>
|
||||||
|
<a href="/mypage" style={linkSt()}>마이페이지</a>
|
||||||
|
<form action="/api/auth/logout" method="POST"><button style={btn(false)}>로그아웃</button></form>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<a href="/login" style={linkSt()}>로그인</a>
|
||||||
|
<a href="/register" style={linkSt()}>회원가입</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<nav style={{ display: 'flex', gap: 'var(--space-lg)', marginTop: 'var(--space-md)' }}>
|
||||||
|
{menus.map((m) => (
|
||||||
|
<div key={m.label} style={{ position: 'relative', padding: '8px 0' }}>
|
||||||
|
<span style={{ color: 'var(--color-text)', fontWeight: 600, fontSize: 14, cursor: 'pointer' }}>{m.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Footer({ siteName }: FooterProps) {
|
||||||
|
return (
|
||||||
|
<footer style={{ background: 'var(--color-bg-surface)', borderTop: '1px solid var(--color-border)', padding: 'var(--space-xl)', marginTop: 'var(--space-xl)', color: 'var(--color-text-muted)', fontSize: 13 }}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr 1fr 1fr', gap: 'var(--space-lg)' }}>
|
||||||
|
<div>
|
||||||
|
<h4 style={{ color: 'var(--color-primary)', margin: '0 0 8px' }}>{siteName}</h4>
|
||||||
|
<p style={{ lineHeight: 1.6 }}>안전 슬롯사이트 추천 및 슬롯커뮤니티</p>
|
||||||
|
</div>
|
||||||
|
<div><h5 style={{ color: 'var(--color-text)' }}>커뮤니티</h5><a href="/free" style={ftLink()}>자유게시판</a><a href="/review" style={ftLink()}>리뷰</a></div>
|
||||||
|
<div><h5 style={{ color: 'var(--color-text)' }}>검증</h5><a href="/mukti" style={ftLink()}>먹튀사이트</a><a href="/complaint" style={ftLink()}>먹튀신고</a></div>
|
||||||
|
<div><h5 style={{ color: 'var(--color-text)' }}>고객지원</h5><a href="/help/qa" style={ftLink()}>1:1문의</a><a href="/help/faq" style={ftLink()}>FAQ</a></div>
|
||||||
|
</div>
|
||||||
|
<p style={{ borderTop: '1px solid var(--color-border)', paddingTop: 'var(--space-md)', marginTop: 'var(--space-lg)', textAlign: 'center', fontSize: 12 }}>© {new Date().getFullYear()} {siteName} — Eyoom magazine theme</p>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Sidebar() {
|
||||||
|
return (
|
||||||
|
<aside style={{ background: 'var(--color-bg-surface)', borderRadius: 'var(--radius-lg)', padding: 'var(--space-md)', minWidth: 280 }}>
|
||||||
|
<h3 style={{ fontSize: 14, color: 'var(--color-primary)', borderBottom: '2px solid var(--color-primary)', paddingBottom: 6, margin: '0 0 12px' }}>🏆 회원랭킹</h3>
|
||||||
|
<ol style={{ paddingLeft: 20, fontSize: 13, lineHeight: 1.8 }}>
|
||||||
|
<li>대환장파티 — 723,564p</li>
|
||||||
|
<li>즐라탄 — 689,012p</li>
|
||||||
|
<li>슬생전설 — 612,300p</li>
|
||||||
|
</ol>
|
||||||
|
<h3 style={{ fontSize: 14, color: 'var(--color-primary)', borderBottom: '2px solid var(--color-primary)', paddingBottom: 6, margin: '20px 0 12px' }}>🎰 인기 게시판</h3>
|
||||||
|
<ul style={{ listStyle: 'none', padding: 0, fontSize: 13, lineHeight: 1.9 }}>
|
||||||
|
<li><a href="/free" style={{ color: 'var(--color-text)', textDecoration: 'none' }}># 자유게시판</a></li>
|
||||||
|
<li><a href="/review" style={{ color: 'var(--color-text)', textDecoration: 'none' }}># 후기게시판</a></li>
|
||||||
|
<li><a href="/mukti" style={{ color: 'var(--color-text)', textDecoration: 'none' }}># 먹튀사이트</a></li>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PostList({ boardTitle, items, page, totalPages, boardSlug }: PostListProps) {
|
||||||
|
return (
|
||||||
|
<section style={{ display: 'grid', gridTemplateColumns: '1fr 280px', gap: 'var(--space-lg)', padding: 'var(--space-lg)' }}>
|
||||||
|
<div>
|
||||||
|
<h1 style={{ fontSize: 26, color: 'var(--color-primary)', borderBottom: '3px solid var(--color-primary)', paddingBottom: 8, marginBottom: 'var(--space-md)' }}>📰 {boardTitle}</h1>
|
||||||
|
<div style={{ display: 'grid', gap: 'var(--space-sm)' }}>
|
||||||
|
{items.map((p) => (
|
||||||
|
<a key={p.id} href={`/${boardSlug}/${p.id}`} style={{ background: 'var(--color-bg-surface)', borderRadius: 'var(--radius-md)', padding: 'var(--space-md)', display: 'flex', justifyContent: 'space-between', textDecoration: 'none', color: 'var(--color-text)', borderLeft: '3px solid var(--color-primary)' }}>
|
||||||
|
<div>
|
||||||
|
<strong style={{ fontSize: 15 }}>{p.subject}</strong>
|
||||||
|
{p.commentCount > 0 && <span style={{ color: 'var(--color-primary)', marginLeft: 8, fontSize: 13 }}>[{p.commentCount}]</span>}
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>{p.authorName} · {new Date(p.createdAt).toLocaleString('ko-KR')}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>👁 {p.hit}</div>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Pagination boardSlug={boardSlug} page={page} totalPages={totalPages} />
|
||||||
|
</div>
|
||||||
|
<Sidebar activeTheme="eyoom" />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PostView({ boardTitle, subject, authorName, createdAt, content, hit, good, bad, comments }: PostViewProps) {
|
||||||
|
return (
|
||||||
|
<article style={{ display: 'grid', gridTemplateColumns: '1fr 280px', gap: 'var(--space-lg)', padding: 'var(--space-lg)' }}>
|
||||||
|
<div style={{ background: 'var(--color-bg-surface)', borderRadius: 'var(--radius-lg)', padding: 'var(--space-lg)' }}>
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--color-primary)', margin: 0 }}>📰 {boardTitle}</p>
|
||||||
|
<h1 style={{ fontSize: 28, margin: '8px 0 12px' }}>{subject}</h1>
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--color-text-muted)', borderBottom: '1px solid var(--color-border)', paddingBottom: 12 }}>
|
||||||
|
<strong style={{ color: 'var(--color-primary)' }}>{authorName}</strong> · {new Date(createdAt).toLocaleString('ko-KR')} · 조회 {hit}
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: 'var(--space-lg) 0', minHeight: 200, lineHeight: 1.8 }} dangerouslySetInnerHTML={{ __html: content }} />
|
||||||
|
<div style={{ display: 'flex', gap: 'var(--space-md)', justifyContent: 'center', margin: 'var(--space-xl) 0' }}>
|
||||||
|
<button style={btn(true, 'var(--color-success)')}>👍 추천 {good}</button>
|
||||||
|
<button style={btn(true, 'var(--color-danger)')}>👎 비추천 {bad}</button>
|
||||||
|
</div>
|
||||||
|
<section>{comments}</section>
|
||||||
|
</div>
|
||||||
|
<Sidebar activeTheme="eyoom" />
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginForm({ error }: LoginFormProps) {
|
||||||
|
return (
|
||||||
|
<form action="/api/auth/login" method="POST" style={{ maxWidth: 380, margin: '80px auto', background: 'var(--color-bg-surface)', borderRadius: 'var(--radius-lg)', padding: 'var(--space-xl)', borderTop: '4px solid var(--color-primary)' }}>
|
||||||
|
<h1 style={{ fontSize: 24, marginBottom: 'var(--space-lg)', textAlign: 'center', color: 'var(--color-primary)' }}>슬생닷컴 로그인</h1>
|
||||||
|
{error && <p style={{ color: 'var(--color-danger)', fontSize: 13, marginBottom: 'var(--space-md)' }}>{error}</p>}
|
||||||
|
<input name="loginId" placeholder="아이디" required style={input()} />
|
||||||
|
<input name="password" type="password" placeholder="비밀번호" required style={input()} />
|
||||||
|
<button type="submit" style={btn(true)}>로그인</button>
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--color-text-muted)', textAlign: 'center', marginTop: 'var(--space-md)' }}>아직 회원이 아니신가요? <a href="/register" style={{ color: 'var(--color-primary)' }}>회원가입</a></p>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Pagination({ boardSlug, page, totalPages }: { boardSlug: string; page: number; totalPages: number }) {
|
||||||
|
if (totalPages <= 1) return null;
|
||||||
|
const start = Math.max(1, page - 4);
|
||||||
|
const end = Math.min(totalPages, start + 9);
|
||||||
|
const range: number[] = [];
|
||||||
|
for (let p = start; p <= end; p++) range.push(p);
|
||||||
|
return (
|
||||||
|
<nav style={{ display: 'flex', gap: 6, justifyContent: 'center', marginTop: 'var(--space-xl)' }}>
|
||||||
|
{range.map((p) => (
|
||||||
|
<a key={p} href={`/${boardSlug}?page=${p}`} style={{ padding: '8px 12px', borderRadius: 'var(--radius-sm)', background: p === page ? 'var(--color-primary)' : 'var(--color-bg-surface)', color: p === page ? '#fff' : 'var(--color-text)', textDecoration: 'none', fontSize: 14 }}>{p}</a>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkSt = (): React.CSSProperties => ({ color: 'var(--color-text)', textDecoration: 'none' });
|
||||||
|
const ftLink = (): React.CSSProperties => ({ display: 'block', padding: '4px 0', color: 'var(--color-text-muted)', textDecoration: 'none', fontSize: 13 });
|
||||||
|
const input = (): React.CSSProperties => ({ display: 'block', width: '100%', padding: '12px', marginBottom: 'var(--space-md)', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-md)', background: 'var(--color-bg)', color: 'var(--color-text)', fontSize: 14, boxSizing: 'border-box' });
|
||||||
|
const btn = (block = false, color = 'var(--color-primary)'): React.CSSProperties => ({ padding: '12px 20px', border: 'none', borderRadius: 'var(--radius-md)', background: color, color: '#fff', cursor: 'pointer', fontSize: 14, fontWeight: 600, width: block ? '100%' : 'auto' });
|
||||||
|
|
||||||
|
export const eyoomTheme: ThemeManifest = {
|
||||||
|
id: 'eyoom',
|
||||||
|
name: '이윰빌더',
|
||||||
|
tokens,
|
||||||
|
layouts: { root: Root },
|
||||||
|
slots: { Header, Footer, Sidebar, PostList, PostView, LoginForm },
|
||||||
|
features: { magazineWidgets: true, megaMenu: true },
|
||||||
|
};
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './types';
|
||||||
|
export * from './registry';
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { ThemeManifest, ThemeId } from './types';
|
||||||
|
import { basicTheme } from './basic/index';
|
||||||
|
import { eyoomTheme } from './eyoom/index';
|
||||||
|
import { aminaTheme } from './amina/index';
|
||||||
|
import { youngcartTheme } from './youngcart/index';
|
||||||
|
|
||||||
|
const registry: Record<ThemeId, ThemeManifest> = {
|
||||||
|
basic: basicTheme,
|
||||||
|
eyoom: eyoomTheme,
|
||||||
|
amina: aminaTheme,
|
||||||
|
youngcart: youngcartTheme,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getTheme(id: ThemeId | undefined | null): ThemeManifest {
|
||||||
|
return registry[(id ?? 'eyoom') as ThemeId] ?? registry.eyoom;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listThemes(): ThemeManifest[] {
|
||||||
|
return Object.values(registry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve which theme to use for the given URL pathname, given the global
|
||||||
|
* theme and any path-specific overrides stored in DB app_settings.
|
||||||
|
*/
|
||||||
|
export function resolveThemeForPath(pathname: string, globalId: ThemeId, overrides: { path: string; theme: ThemeId }[] = []): ThemeId {
|
||||||
|
for (const o of overrides) {
|
||||||
|
if (o.path.endsWith('/*') ? pathname.startsWith(o.path.slice(0, -2)) : pathname === o.path) {
|
||||||
|
return o.theme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return globalId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inline `<style>` tag for CSS variables — call from a Server Component to avoid FOUC. */
|
||||||
|
export function tokensToCssVars(theme: ThemeManifest): string {
|
||||||
|
const t = theme.tokens;
|
||||||
|
return [
|
||||||
|
`--color-primary:${t.color.primary}`,
|
||||||
|
`--color-primary-dark:${t.color.primaryDark}`,
|
||||||
|
`--color-bg:${t.color.bg}`,
|
||||||
|
`--color-bg-surface:${t.color.bgSurface}`,
|
||||||
|
`--color-text:${t.color.text}`,
|
||||||
|
`--color-text-muted:${t.color.textMuted}`,
|
||||||
|
`--color-border:${t.color.border}`,
|
||||||
|
`--color-success:${t.color.success}`,
|
||||||
|
`--color-danger:${t.color.danger}`,
|
||||||
|
`--color-warning:${t.color.warning}`,
|
||||||
|
`--font-sans:${t.font.sans}`,
|
||||||
|
`--font-mono:${t.font.mono}`,
|
||||||
|
`--radius-sm:${t.radius.sm}`,
|
||||||
|
`--radius-md:${t.radius.md}`,
|
||||||
|
`--radius-lg:${t.radius.lg}`,
|
||||||
|
`--radius-pill:${t.radius.pill}`,
|
||||||
|
`--space-xs:${t.space.xs}`,
|
||||||
|
`--space-sm:${t.space.sm}`,
|
||||||
|
`--space-md:${t.space.md}`,
|
||||||
|
`--space-lg:${t.space.lg}`,
|
||||||
|
`--space-xl:${t.space.xl}`,
|
||||||
|
].join(';');
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { ComponentType, ReactNode } from 'react';
|
||||||
|
|
||||||
|
export type ThemeId = 'basic' | 'eyoom' | 'amina' | 'youngcart';
|
||||||
|
|
||||||
|
export interface ThemeTokens {
|
||||||
|
color: {
|
||||||
|
primary: string;
|
||||||
|
primaryDark: string;
|
||||||
|
bg: string;
|
||||||
|
bgSurface: string;
|
||||||
|
text: string;
|
||||||
|
textMuted: string;
|
||||||
|
border: string;
|
||||||
|
success: string;
|
||||||
|
danger: string;
|
||||||
|
warning: string;
|
||||||
|
};
|
||||||
|
font: { sans: string; mono: string };
|
||||||
|
radius: { sm: string; md: string; lg: string; pill: string };
|
||||||
|
space: { xs: string; sm: string; md: string; lg: string; xl: string };
|
||||||
|
z: { sticky: number; modal: number; toast: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LayoutProps { children: ReactNode }
|
||||||
|
|
||||||
|
export interface HeaderProps { activeTheme: ThemeId; siteName: string; loggedInName?: string | null }
|
||||||
|
export interface FooterProps { activeTheme: ThemeId; siteName: string }
|
||||||
|
export interface SidebarProps { activeTheme: ThemeId }
|
||||||
|
export interface PostListItem { boardSlug: string; id: string; subject: string; authorName: string; createdAt: Date; commentCount: number; hit: number }
|
||||||
|
export interface PostListProps { boardSlug: string; boardTitle: string; items: PostListItem[]; page: number; totalPages: number }
|
||||||
|
export interface PostViewProps { boardSlug: string; boardTitle: string; subject: string; authorName: string; createdAt: Date; content: string; hit: number; good: number; bad: number; comments: ReactNode }
|
||||||
|
export interface LoginFormProps { error?: string }
|
||||||
|
|
||||||
|
export interface ThemeManifest {
|
||||||
|
id: ThemeId;
|
||||||
|
name: string; // Korean display name
|
||||||
|
preview?: string; // thumbnail URL
|
||||||
|
tokens: ThemeTokens;
|
||||||
|
layouts: { root: ComponentType<LayoutProps> };
|
||||||
|
slots: {
|
||||||
|
Header: ComponentType<HeaderProps>;
|
||||||
|
Footer: ComponentType<FooterProps>;
|
||||||
|
Sidebar: ComponentType<SidebarProps>;
|
||||||
|
PostList: ComponentType<PostListProps>;
|
||||||
|
PostView: ComponentType<PostViewProps>;
|
||||||
|
LoginForm: ComponentType<LoginFormProps>;
|
||||||
|
};
|
||||||
|
features?: { shop?: boolean; magazineWidgets?: boolean; megaMenu?: boolean };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const THEME_IDS: readonly ThemeId[] = ['basic', 'eyoom', 'amina', 'youngcart'] as const;
|
||||||
|
export const THEME_LABELS: Record<ThemeId, string> = {
|
||||||
|
basic: '기본',
|
||||||
|
eyoom: '이윰빌더',
|
||||||
|
amina: '아미나빌더',
|
||||||
|
youngcart: '영카드',
|
||||||
|
};
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import type { ThemeManifest, LayoutProps, HeaderProps, FooterProps, SidebarProps, PostListProps, PostViewProps, LoginFormProps } from '../types';
|
||||||
|
|
||||||
|
// YoungCart (영카트) shop-centric layout. Hero banner, product grid feel,
|
||||||
|
// utility nav for cart / wishlist / orders.
|
||||||
|
|
||||||
|
const tokens = {
|
||||||
|
color: {
|
||||||
|
primary: '#dc2626', // red — sale accent
|
||||||
|
primaryDark: '#b91c1c',
|
||||||
|
bg: '#ffffff',
|
||||||
|
bgSurface: '#f9fafb',
|
||||||
|
text: '#111827',
|
||||||
|
textMuted: '#6b7280',
|
||||||
|
border: '#e5e7eb',
|
||||||
|
success: '#059669',
|
||||||
|
danger: '#dc2626',
|
||||||
|
warning: '#f59e0b',
|
||||||
|
},
|
||||||
|
font: { sans: '"Noto Sans KR", "Malgun Gothic", system-ui, sans-serif', mono: '"JetBrains Mono", monospace' },
|
||||||
|
radius: { sm: '2px', md: '4px', lg: '8px', pill: '999px' },
|
||||||
|
space: { xs: '4px', sm: '8px', md: '12px', lg: '20px', xl: '32px' },
|
||||||
|
z: { sticky: 100, modal: 1000, toast: 2000 },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function Root({ children }: LayoutProps) {
|
||||||
|
return <div style={{ background: 'var(--color-bg)', color: 'var(--color-text)', minHeight: '100vh', fontFamily: 'var(--font-sans)' }}>{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Header({ siteName, loggedInName }: HeaderProps) {
|
||||||
|
return (
|
||||||
|
<header>
|
||||||
|
{/* Top utility nav */}
|
||||||
|
<div style={{ background: 'var(--color-bgSurface)', borderBottom: '1px solid var(--color-border)', padding: '6px 0' }}>
|
||||||
|
<div style={{ maxWidth: 1280, margin: '0 auto', padding: '0 var(--space-lg)', display: 'flex', justifyContent: 'flex-end', gap: 'var(--space-md)', fontSize: 12, color: 'var(--color-textMuted)' }}>
|
||||||
|
{loggedInName ? (
|
||||||
|
<>
|
||||||
|
<span style={{ color: 'var(--color-text)' }}>{loggedInName}님</span>
|
||||||
|
<a href="/mypage" style={uLink()}>마이페이지</a>
|
||||||
|
<a href="/cart" style={uLink()}>장바구니</a>
|
||||||
|
<a href="/orders" style={uLink()}>주문내역</a>
|
||||||
|
<form action="/api/auth/logout" method="POST" style={{ display: 'inline' }}><button style={txtBtn()}>로그아웃</button></form>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<a href="/login" style={uLink()}>로그인</a>
|
||||||
|
<a href="/register" style={uLink()}>회원가입</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<a href="/help/faq" style={uLink()}>고객센터</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Main header */}
|
||||||
|
<div style={{ borderBottom: '2px solid var(--color-primary)', padding: 'var(--space-lg) 0' }}>
|
||||||
|
<div style={{ maxWidth: 1280, margin: '0 auto', padding: '0 var(--space-lg)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<a href="/" style={{ fontSize: 28, fontWeight: 800, color: 'var(--color-primary)', textDecoration: 'none' }}>{siteName}</a>
|
||||||
|
<form style={{ display: 'flex', gap: 0 }}>
|
||||||
|
<input placeholder="상품 / 게시글 검색..." style={{ width: 320, padding: '10px 14px', border: '2px solid var(--color-primary)', borderRight: 0, fontSize: 14 }} />
|
||||||
|
<button style={{ background: 'var(--color-primary)', color: '#fff', border: 'none', padding: '0 20px', cursor: 'pointer' }}>🔍</button>
|
||||||
|
</form>
|
||||||
|
<div style={{ display: 'flex', gap: 'var(--space-md)', fontSize: 13 }}>
|
||||||
|
<a href="/cart" style={iconLink()}>🛒 장바구니</a>
|
||||||
|
<a href="/wishlist" style={iconLink()}>❤ 위시</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Category nav */}
|
||||||
|
<nav style={{ background: 'var(--color-bgSurface)', borderBottom: '1px solid var(--color-border)' }}>
|
||||||
|
<div style={{ maxWidth: 1280, margin: '0 auto', padding: '12px var(--space-lg)', display: 'flex', gap: 'var(--space-lg)', fontSize: 14 }}>
|
||||||
|
<a href="/shop" style={catLink()}>전체상품</a>
|
||||||
|
<a href="/shop/list-best" style={catLink()}>BEST</a>
|
||||||
|
<a href="/shop/list-new" style={catLink()}>신상품</a>
|
||||||
|
<a href="/shop/list-sale" style={catLink()}>특가</a>
|
||||||
|
<a href="/free" style={catLink()}>커뮤니티</a>
|
||||||
|
<a href="/event" style={catLink()}>이벤트</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Footer({ siteName }: FooterProps) {
|
||||||
|
return (
|
||||||
|
<footer style={{ background: 'var(--color-bgSurface)', borderTop: '1px solid var(--color-border)', padding: 'var(--space-xl)', marginTop: 'var(--space-xl)' }}>
|
||||||
|
<div style={{ maxWidth: 1280, margin: '0 auto', display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 'var(--space-xl)', fontSize: 13, color: 'var(--color-textMuted)' }}>
|
||||||
|
<div>
|
||||||
|
<h4 style={{ color: 'var(--color-text)', margin: '0 0 8px' }}>고객센터</h4>
|
||||||
|
<p style={{ fontSize: 18, color: 'var(--color-primary)', margin: '4px 0' }}>1588-0000</p>
|
||||||
|
<p>평일 09:00 - 18:00</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 style={{ color: 'var(--color-text)', margin: '0 0 8px' }}>이용안내</h4>
|
||||||
|
<a href="/help/faq" style={ftLink()}>FAQ</a>
|
||||||
|
<a href="/help/qa" style={ftLink()}>1:1문의</a>
|
||||||
|
<a href="/notice" style={ftLink()}>공지사항</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 style={{ color: 'var(--color-text)', margin: '0 0 8px' }}>회사소개</h4>
|
||||||
|
<p style={{ lineHeight: 1.6 }}>{siteName} - 안전 슬롯 사이트 추천 커뮤니티</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p style={{ borderTop: '1px solid var(--color-border)', marginTop: 'var(--space-lg)', paddingTop: 'var(--space-md)', textAlign: 'center', fontSize: 12, color: 'var(--color-textMuted)' }}>© {new Date().getFullYear()} {siteName} — YoungCart theme</p>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Sidebar() {
|
||||||
|
return (
|
||||||
|
<aside style={{ width: 220, padding: 'var(--space-md)', background: 'var(--color-bgSurface)', borderRight: '1px solid var(--color-border)' }}>
|
||||||
|
<h3 style={{ fontSize: 14, fontWeight: 700, margin: '0 0 12px', borderBottom: '2px solid var(--color-primary)', paddingBottom: 6 }}>카테고리</h3>
|
||||||
|
<ul style={{ listStyle: 'none', padding: 0, fontSize: 13, lineHeight: 2 }}>
|
||||||
|
<li><a href="/shop/list-1" style={{ color: 'var(--color-text)', textDecoration: 'none' }}>슬롯 게임</a></li>
|
||||||
|
<li><a href="/shop/list-2" style={{ color: 'var(--color-text)', textDecoration: 'none' }}>기프티콘</a></li>
|
||||||
|
<li><a href="/shop/list-3" style={{ color: 'var(--color-text)', textDecoration: 'none' }}>이벤트 상품</a></li>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PostList({ boardTitle, items, page, totalPages, boardSlug }: PostListProps) {
|
||||||
|
return (
|
||||||
|
<section style={{ maxWidth: 1280, margin: '0 auto', padding: 'var(--space-lg)' }}>
|
||||||
|
<h1 style={{ fontSize: 22, marginBottom: 'var(--space-md)', borderBottom: '2px solid var(--color-primary)', paddingBottom: 8 }}>{boardTitle}</h1>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||||
|
<thead><tr style={{ background: 'var(--color-bgSurface)', borderTop: '2px solid var(--color-primary)', borderBottom: '1px solid var(--color-border)' }}>
|
||||||
|
<th style={th()}>번호</th>
|
||||||
|
<th style={{ ...th(), textAlign: 'left' }}>제목</th>
|
||||||
|
<th style={th()}>글쓴이</th>
|
||||||
|
<th style={th()}>날짜</th>
|
||||||
|
<th style={th()}>조회</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((p, i) => (
|
||||||
|
<tr key={p.id} style={{ borderBottom: '1px solid var(--color-border)' }}>
|
||||||
|
<td style={td()}>{(page - 1) * 20 + i + 1}</td>
|
||||||
|
<td style={{ ...td(), textAlign: 'left' }}>
|
||||||
|
<a href={`/${boardSlug}/${p.id}`} style={{ color: 'var(--color-text)', textDecoration: 'none' }}>{p.subject}</a>
|
||||||
|
{p.commentCount > 0 && <span style={{ color: 'var(--color-primary)', marginLeft: 4, fontWeight: 700 }}>({p.commentCount})</span>}
|
||||||
|
</td>
|
||||||
|
<td style={td()}>{p.authorName}</td>
|
||||||
|
<td style={td()}>{new Date(p.createdAt).toISOString().slice(5, 10)}</td>
|
||||||
|
<td style={td()}>{p.hit}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<Pagination boardSlug={boardSlug} page={page} totalPages={totalPages} />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PostView({ boardTitle, subject, authorName, createdAt, content, hit, good, bad, comments }: PostViewProps) {
|
||||||
|
return (
|
||||||
|
<article style={{ maxWidth: 1024, margin: '0 auto', padding: 'var(--space-lg)' }}>
|
||||||
|
<div style={{ borderTop: '2px solid var(--color-primary)' }}>
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--color-textMuted)', margin: 'var(--space-md) 0 4px' }}>{boardTitle}</p>
|
||||||
|
<h1 style={{ fontSize: 24, margin: '0 0 12px' }}>{subject}</h1>
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--color-textMuted)', borderBottom: '1px solid var(--color-border)', paddingBottom: 12 }}>
|
||||||
|
{authorName} · {new Date(createdAt).toLocaleString('ko-KR')} · 조회 {hit}
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: 'var(--space-lg) 0', minHeight: 200, lineHeight: 1.7 }} dangerouslySetInnerHTML={{ __html: content }} />
|
||||||
|
<div style={{ display: 'flex', gap: 'var(--space-md)', justifyContent: 'center', borderTop: '1px solid var(--color-border)', padding: 'var(--space-lg) 0' }}>
|
||||||
|
<button style={btn('var(--color-success)')}>👍 추천 {good}</button>
|
||||||
|
<button style={btn('var(--color-danger)')}>👎 비추천 {bad}</button>
|
||||||
|
</div>
|
||||||
|
<section>{comments}</section>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginForm({ error }: LoginFormProps) {
|
||||||
|
return (
|
||||||
|
<form action="/api/auth/login" method="POST" style={{ maxWidth: 380, margin: '60px auto', border: '2px solid var(--color-primary)', padding: 'var(--space-xl)' }}>
|
||||||
|
<h1 style={{ fontSize: 22, marginBottom: 'var(--space-lg)', textAlign: 'center', color: 'var(--color-primary)' }}>회원 로그인</h1>
|
||||||
|
{error && <p style={{ color: 'var(--color-danger)', fontSize: 13, marginBottom: 'var(--space-md)' }}>{error}</p>}
|
||||||
|
<input name="loginId" placeholder="아이디" required style={input()} />
|
||||||
|
<input name="password" type="password" placeholder="비밀번호" required style={input()} />
|
||||||
|
<button type="submit" style={{ ...btn('var(--color-primary)'), width: '100%' }}>로그인</button>
|
||||||
|
<div style={{ marginTop: 'var(--space-md)', fontSize: 12, textAlign: 'center', color: 'var(--color-textMuted)' }}>
|
||||||
|
<a href="/register" style={{ color: 'var(--color-primary)' }}>회원가입</a> · <a href="/auth/recover" style={{ color: 'var(--color-textMuted)' }}>비밀번호 찾기</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Pagination({ boardSlug, page, totalPages }: { boardSlug: string; page: number; totalPages: number }) {
|
||||||
|
if (totalPages <= 1) return null;
|
||||||
|
const start = Math.max(1, page - 4);
|
||||||
|
const end = Math.min(totalPages, start + 9);
|
||||||
|
const range: number[] = [];
|
||||||
|
for (let p = start; p <= end; p++) range.push(p);
|
||||||
|
return (
|
||||||
|
<nav style={{ display: 'flex', gap: 4, justifyContent: 'center', marginTop: 'var(--space-lg)' }}>
|
||||||
|
{range.map((p) => (
|
||||||
|
<a key={p} href={`/${boardSlug}?page=${p}`} style={{ padding: '6px 10px', border: '1px solid var(--color-border)', background: p === page ? 'var(--color-primary)' : 'var(--color-bg)', color: p === page ? '#fff' : 'var(--color-text)', textDecoration: 'none', fontSize: 13 }}>{p}</a>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uLink = (): React.CSSProperties => ({ color: 'var(--color-textMuted)', textDecoration: 'none' });
|
||||||
|
const txtBtn = (): React.CSSProperties => ({ background: 'transparent', border: 'none', color: 'var(--color-textMuted)', cursor: 'pointer', fontSize: 12, padding: 0 });
|
||||||
|
const iconLink = (): React.CSSProperties => ({ color: 'var(--color-text)', textDecoration: 'none' });
|
||||||
|
const catLink = (): React.CSSProperties => ({ color: 'var(--color-text)', textDecoration: 'none', fontWeight: 600 });
|
||||||
|
const ftLink = (): React.CSSProperties => ({ display: 'block', padding: '2px 0', color: 'var(--color-textMuted)', textDecoration: 'none' });
|
||||||
|
const th = (): React.CSSProperties => ({ padding: '10px', fontWeight: 700, textAlign: 'center' });
|
||||||
|
const td = (): React.CSSProperties => ({ padding: '10px', textAlign: 'center' });
|
||||||
|
const input = (): React.CSSProperties => ({ display: 'block', width: '100%', padding: '12px', marginBottom: 'var(--space-md)', border: '1px solid var(--color-border)', fontSize: 14, boxSizing: 'border-box' });
|
||||||
|
const btn = (color: string): React.CSSProperties => ({ padding: '10px 20px', border: 'none', background: color, color: '#fff', cursor: 'pointer', fontSize: 14, fontWeight: 600 });
|
||||||
|
|
||||||
|
export const youngcartTheme: ThemeManifest = {
|
||||||
|
id: 'youngcart',
|
||||||
|
name: '영카드',
|
||||||
|
tokens,
|
||||||
|
layouts: { root: Root },
|
||||||
|
slots: { Header, Footer, Sidebar, PostList, PostView, LoginForm },
|
||||||
|
features: { shop: true },
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx"]
|
||||||
|
}
|
||||||
Generated
+4498
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
|||||||
|
packages:
|
||||||
|
- 'apps/*'
|
||||||
|
- 'packages/*'
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"verbatimModuleSyntax": false,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@slot/db": ["packages/db/src"],
|
||||||
|
"@slot/db/*": ["packages/db/src/*"],
|
||||||
|
"@slot/auth": ["packages/auth/src"],
|
||||||
|
"@slot/auth/*": ["packages/auth/src/*"],
|
||||||
|
"@slot/themes": ["packages/themes/src"],
|
||||||
|
"@slot/themes/*": ["packages/themes/src/*"],
|
||||||
|
"@slot/ui": ["packages/ui/src"],
|
||||||
|
"@slot/ui/*": ["packages/ui/src/*"],
|
||||||
|
"@slot/shop": ["packages/shop/src"],
|
||||||
|
"@slot/shop/*": ["packages/shop/src/*"],
|
||||||
|
"@slot/games": ["packages/games/src"],
|
||||||
|
"@slot/games/*": ["packages/games/src/*"],
|
||||||
|
"@slot/shared": ["packages/shared/src"],
|
||||||
|
"@slot/shared/*": ["packages/shared/src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"exclude": ["node_modules", "dist", ".next", "build"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user