From 7dc3ec692e37c5a2d875e5fc13d7a7693d16d288 Mon Sep 17 00:00:00 2001 From: caadiq Date: Thu, 22 Jan 2026 20:35:05 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EC=99=84=EB=A3=8C=20(Phase=204-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 관리자 페이지 폴더 구조 재구성 (pages/pc/admin/) - login/, dashboard/, members/, albums/, schedules/ - 앨범 관리 페이지 마이그레이션 (Albums, AlbumForm, AlbumPhotos, AlbumTeasers) - 일정 관리 페이지 마이그레이션 (Schedules, ScheduleForm, ScheduleCategory, ScheduleDict, ScheduleBots) - DatePicker 컴포넌트 버그 수정 (월 이동 및 연도 선택) - 일정 관리 라우트 경로 수정 (/admin/schedule) - 마이그레이션 문서 업데이트 Co-Authored-By: Claude Opus 4.5 --- docs/admin-migration.md | 8 +- docs/migration.md | 75 +- frontend-temp/src/App.jsx | 26 +- .../src/components/pc/admin/DatePicker.jsx | 156 +- .../src/components/pc/admin/Layout.jsx | 2 +- .../src/pages/pc/admin/albums/AlbumForm.jsx | 631 +++++++ .../src/pages/pc/admin/albums/AlbumPhotos.jsx | 1536 +++++++++++++++++ .../src/pages/pc/admin/albums/Albums.jsx | 231 +++ .../pc/admin/{ => dashboard}/Dashboard.jsx | 2 +- .../src/pages/pc/admin/{ => login}/Login.jsx | 0 .../pc/admin/{ => members}/MemberEdit.jsx | 1 + .../pages/pc/admin/{ => members}/Members.jsx | 0 .../pages/pc/admin/schedules/ScheduleBots.jsx | 507 ++++++ .../pc/admin/schedules/ScheduleCategory.jsx | 466 +++++ .../pages/pc/admin/schedules/ScheduleDict.jsx | 714 ++++++++ .../pages/pc/admin/schedules/ScheduleForm.jsx | 1046 +++++++++++ .../pages/pc/admin/schedules/Schedules.jsx | 1471 ++++++++++++++++ 17 files changed, 6773 insertions(+), 99 deletions(-) create mode 100644 frontend-temp/src/pages/pc/admin/albums/AlbumForm.jsx create mode 100644 frontend-temp/src/pages/pc/admin/albums/AlbumPhotos.jsx create mode 100644 frontend-temp/src/pages/pc/admin/albums/Albums.jsx rename frontend-temp/src/pages/pc/admin/{ => dashboard}/Dashboard.jsx (99%) rename frontend-temp/src/pages/pc/admin/{ => login}/Login.jsx (100%) rename frontend-temp/src/pages/pc/admin/{ => members}/MemberEdit.jsx (99%) rename frontend-temp/src/pages/pc/admin/{ => members}/Members.jsx (100%) create mode 100644 frontend-temp/src/pages/pc/admin/schedules/ScheduleBots.jsx create mode 100644 frontend-temp/src/pages/pc/admin/schedules/ScheduleCategory.jsx create mode 100644 frontend-temp/src/pages/pc/admin/schedules/ScheduleDict.jsx create mode 100644 frontend-temp/src/pages/pc/admin/schedules/ScheduleForm.jsx create mode 100644 frontend-temp/src/pages/pc/admin/schedules/Schedules.jsx diff --git a/docs/admin-migration.md b/docs/admin-migration.md index 23cc9b2..96bea01 100644 --- a/docs/admin-migration.md +++ b/docs/admin-migration.md @@ -492,10 +492,10 @@ frontend-temp/src/ - [x] AdminMemberEdit 마이그레이션 - [x] useToast 훅 추가 -### 4단계: 앨범 관리 -- [ ] AdminAlbums 마이그레이션 -- [ ] AdminAlbumForm 마이그레이션 -- [ ] AdminAlbumPhotos 마이그레이션 (SSE 처리 포함) +### 4단계: 앨범 관리 ✅ +- [x] AdminAlbums 마이그레이션 +- [x] AdminAlbumForm 마이그레이션 +- [x] AdminAlbumPhotos 마이그레이션 (SSE 처리 포함) ### 5단계: 일정 관리 - [ ] AdminSchedule 마이그레이션 diff --git a/docs/migration.md b/docs/migration.md index 7ff6850..6aee18f 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -219,14 +219,14 @@ function App() { - [x] schedules.js - [x] auth.js -#### 관리자 API (`api/admin/`) -- [ ] albums.js -- [ ] members.js -- [ ] schedules.js -- [ ] categories.js -- [ ] stats.js -- [ ] bots.js -- [ ] suggestions.js +#### 관리자 API (`api/pc/admin/`) +- [x] albums.js +- [x] members.js +- [x] schedules.js +- [x] categories.js +- [x] stats.js +- [x] bots.js +- [x] suggestions.js ### 훅 (hooks/) - [x] useAlbumData.js @@ -238,8 +238,9 @@ function App() { - [x] useMediaQuery.js - [x] useAdminAuth.js -### 관리자 훅 (hooks/) - 관리자 영역 마이그레이션 시 진행 -- [ ] useToast.js (관리자 페이지 전용) +### 관리자 훅 (hooks/pc/admin/, hooks/common/) +- [x] useAdminAuth.js (hooks/pc/admin/) +- [x] useToast.js (hooks/common/) ### 스토어 (stores/) - [x] useScheduleStore.js @@ -289,13 +290,13 @@ function App() { - [x] confetti.js (fireBirthdayConfetti) - [x] AdminScheduleCard.jsx -### 관리자 컴포넌트 (components/admin/) -- [ ] AdminLayout.jsx -- [ ] AdminHeader.jsx -- [ ] ConfirmDialog.jsx -- [ ] CustomDatePicker.jsx -- [ ] CustomTimePicker.jsx -- [ ] NumberPicker.jsx +### 관리자 컴포넌트 (components/pc/admin/) +- [x] Layout.jsx +- [x] Header.jsx +- [x] ConfirmDialog.jsx +- [x] CustomDatePicker.jsx +- [x] CustomTimePicker.jsx +- [x] NumberPicker.jsx ### 페이지 - Home (pages/home/) - [x] pc/Home.jsx @@ -332,22 +333,19 @@ function App() { - [x] pc/NotFound.jsx - [x] mobile/NotFound.jsx -### 페이지 - Admin (pages/admin/) - PC 전용 -- [ ] Login.jsx -- [ ] Dashboard.jsx -- [ ] members/List.jsx -- [ ] members/Edit.jsx -- [ ] albums/List.jsx -- [ ] albums/Form.jsx -- [ ] albums/Photos.jsx -- [ ] schedules/List.jsx -- [ ] schedules/Form.jsx -- [ ] schedules/YouTubeForm.jsx -- [ ] schedules/XForm.jsx -- [ ] schedules/YouTubeEditForm.jsx -- [ ] categories/List.jsx -- [ ] bots/Manager.jsx -- [ ] dict/Manager.jsx +### 페이지 - Admin (pages/pc/admin/) - PC 전용 +- [x] login/Login.jsx +- [x] dashboard/Dashboard.jsx +- [x] members/Members.jsx +- [x] albums/Albums.jsx +- [x] albums/AlbumForm.jsx +- [x] albums/AlbumPhotos.jsx +- [x] albums/AlbumTeasers.jsx +- [x] schedules/Schedules.jsx +- [x] schedules/ScheduleForm.jsx +- [x] schedules/ScheduleCategory.jsx +- [x] schedules/ScheduleDict.jsx +- [x] schedules/ScheduleBots.jsx ### 기타 - [x] App.jsx (BrowserView/MobileView 라우팅) @@ -443,11 +441,12 @@ import 'swiper/css'; #### 공개 영역 - ✅ 모두 완료 -#### 관리자 영역 (별도 요청 시 진행) -- [ ] 관리자 API 전체 (api/admin/) -- [ ] 관리자 컴포넌트 전체 (components/admin/) -- [ ] 관리자 페이지 전체 (pages/admin/) -- [ ] useToast 훅 (관리자 전용) +#### 관리자 영역 +- [x] 관리자 API 전체 (api/pc/admin/) +- [x] 관리자 컴포넌트 전체 (components/pc/admin/) +- [x] 관리자 페이지 전체 (pages/pc/admin/) +- [x] useToast 훅 (hooks/common/) +- [x] useAdminAuth 훅 (hooks/pc/admin/) ### 최종 검증 - [ ] 모든 라우트 동작 확인 diff --git a/frontend-temp/src/App.jsx b/frontend-temp/src/App.jsx index 4902e9e..22939ae 100644 --- a/frontend-temp/src/App.jsx +++ b/frontend-temp/src/App.jsx @@ -24,10 +24,18 @@ import PCAlbumGallery from '@/pages/pc/public/album/AlbumGallery'; import PCNotFound from '@/pages/pc/public/common/NotFound'; // PC 관리자 페이지 -import AdminLogin from '@/pages/pc/admin/Login'; -import AdminDashboard from '@/pages/pc/admin/Dashboard'; -import AdminMembers from '@/pages/pc/admin/Members'; -import AdminMemberEdit from '@/pages/pc/admin/MemberEdit'; +import AdminLogin from '@/pages/pc/admin/login/Login'; +import AdminDashboard from '@/pages/pc/admin/dashboard/Dashboard'; +import AdminMembers from '@/pages/pc/admin/members/Members'; +import AdminMemberEdit from '@/pages/pc/admin/members/MemberEdit'; +import AdminAlbums from '@/pages/pc/admin/albums/Albums'; +import AdminAlbumForm from '@/pages/pc/admin/albums/AlbumForm'; +import AdminAlbumPhotos from '@/pages/pc/admin/albums/AlbumPhotos'; +import AdminSchedules from '@/pages/pc/admin/schedules/Schedules'; +import AdminScheduleForm from '@/pages/pc/admin/schedules/ScheduleForm'; +import AdminScheduleCategory from '@/pages/pc/admin/schedules/ScheduleCategory'; +import AdminScheduleDict from '@/pages/pc/admin/schedules/ScheduleDict'; +import AdminScheduleBots from '@/pages/pc/admin/schedules/ScheduleBots'; // Mobile 페이지 import MobileHome from '@/pages/mobile/home/Home'; @@ -70,6 +78,16 @@ function App() { } /> } /> } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> {/* 일반 페이지 (레이아웃 포함) */} { @@ -26,6 +38,13 @@ function DatePicker({ value, onChange, placeholder = '날짜 선택', showDayOfW return () => document.removeEventListener('mousedown', handleClickOutside); }, []); + // value가 변경되면 viewDate도 업데이트 + useEffect(() => { + if (value) { + setViewDate(new Date(value)); + } + }, [value]); + const year = viewDate.getFullYear(); const month = viewDate.getMonth(); @@ -40,16 +59,38 @@ function DatePicker({ value, onChange, placeholder = '날짜 선택', showDayOfW days.push(i); } - const MIN_YEAR = 2025; - const startYear = Math.max(MIN_YEAR, Math.floor(year / 12) * 12 - 1); + // 연도 범위 계산 (minYear 기준으로 12개씩 그룹) + const groupIndex = Math.floor((year - minYear) / 12); + const startYear = minYear + groupIndex * 12; const years = Array.from({ length: 12 }, (_, i) => startYear + i); - const canGoPrevYearRange = startYear > MIN_YEAR; + const canGoPrevYearRange = startYear > minYear; - const prevMonth = () => setViewDate(new Date(year, month - 1, 1)); - const nextMonth = () => setViewDate(new Date(year, month + 1, 1)); - const prevYearRange = () => - canGoPrevYearRange && setViewDate(new Date(Math.max(MIN_YEAR, year - 12), month, 1)); - const nextYearRange = () => setViewDate(new Date(year + 12, month, 1)); + const handleButtonClick = (e, callback) => { + e.preventDefault(); + e.stopPropagation(); + callback(); + }; + + const prevMonth = () => { + const newDate = new Date(year, month - 1, 1); + if (newDate.getFullYear() >= minYear) { + setViewDate(newDate); + } + }; + + const nextMonth = () => { + setViewDate(new Date(year, month + 1, 1)); + }; + + const prevYearRange = () => { + if (canGoPrevYearRange) { + setViewDate(new Date(startYear - 12, month, 1)); + } + }; + + const nextYearRange = () => { + setViewDate(new Date(startYear + 12, month, 1)); + }; const selectDate = (day) => { const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; @@ -67,7 +108,6 @@ function DatePicker({ value, onChange, placeholder = '날짜 선택', showDayOfW setViewMode('days'); }; - // 날짜 표시 포맷 (요일 포함 옵션) const formatDisplayDate = (dateStr) => { if (!dateStr) return ''; const [y, m, d] = dateStr.split('-'); @@ -92,11 +132,10 @@ function DatePicker({ value, onChange, placeholder = '날짜 선택', showDayOfW return today.getFullYear() === year && today.getMonth() === month && today.getDate() === day; }; - const isCurrentYear = (y) => new Date().getFullYear() === y; - const isCurrentMonth = (m) => { - const today = new Date(); - return today.getFullYear() === year && today.getMonth() === m; - }; + const currentYear = new Date().getFullYear(); + const currentMonth = new Date().getMonth(); + const isCurrentYear = (y) => currentYear === y; + const isCurrentMonth = (m) => currentYear === year && currentMonth === m; const monthNames = [ '1월', @@ -113,11 +152,33 @@ function DatePicker({ value, onChange, placeholder = '날짜 선택', showDayOfW '12월', ]; + // 연도 버튼 클래스 + const getYearButtonClass = (y) => { + if (year === y) { + return 'bg-primary text-white'; + } + if (isCurrentYear(y)) { + return 'text-primary font-medium hover:bg-gray-100'; + } + return 'text-gray-700 hover:bg-gray-100'; + }; + + // 월 버튼 클래스 + const getMonthButtonClass = (m) => { + if (month === m) { + return 'bg-primary text-white'; + } + if (isCurrentMonth(m)) { + return 'text-primary font-medium hover:bg-gray-100'; + } + return 'text-gray-700 hover:bg-gray-100'; + }; + return (
@@ -194,32 +270,8 @@ function DatePicker({ value, onChange, placeholder = '날짜 선택', showDayOfW - ))} -
- - )} - - {viewMode === 'months' && ( - -
월 선택
-
- {monthNames.map((m, i) => ( - @@ -240,7 +292,9 @@ function DatePicker({ value, onChange, placeholder = '날짜 선택', showDayOfW {['일', '월', '화', '수', '목', '금', '토'].map((d, i) => (
{d}
@@ -254,7 +308,7 @@ function DatePicker({ value, onChange, placeholder = '날짜 선택', showDayOfW key={i} type="button" disabled={!day} - onClick={() => day && selectDate(day)} + onClick={(e) => day && handleButtonClick(e, () => selectDate(day))} className={`aspect-square rounded-full text-sm font-medium flex items-center justify-center transition-all ${!day ? '' : 'hover:bg-gray-100'} ${isSelected(day) ? 'bg-primary text-white hover:bg-primary' : ''} diff --git a/frontend-temp/src/components/pc/admin/Layout.jsx b/frontend-temp/src/components/pc/admin/Layout.jsx index 99aebba..8a3fe64 100644 --- a/frontend-temp/src/components/pc/admin/Layout.jsx +++ b/frontend-temp/src/components/pc/admin/Layout.jsx @@ -10,7 +10,7 @@ function AdminLayout({ user, children }) { const location = useLocation(); // 일정 관리 페이지는 내부 스크롤 처리 - const isSchedulePage = location.pathname.includes('/admin/schedules'); + const isSchedulePage = location.pathname.includes('/admin/schedule'); return (
diff --git a/frontend-temp/src/pages/pc/admin/albums/AlbumForm.jsx b/frontend-temp/src/pages/pc/admin/albums/AlbumForm.jsx new file mode 100644 index 0000000..0ef066d --- /dev/null +++ b/frontend-temp/src/pages/pc/admin/albums/AlbumForm.jsx @@ -0,0 +1,631 @@ +/** + * 관리자 앨범 추가/수정 페이지 + */ +import { useState, useRef, useEffect } from 'react'; +import { useNavigate, useParams, Link } from 'react-router-dom'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Save, Home, ChevronRight, Music, Trash2, Plus, Image, Star, ChevronDown } from 'lucide-react'; +import { Toast } from '@/components/common'; +import { AdminLayout, DatePicker } from '@/components/pc/admin'; +import { useAdminAuth } from '@/hooks/pc/admin'; +import { useToast } from '@/hooks/common'; +import { adminAlbumApi } from '@/api/pc/admin'; +import { fetchFormData } from '@/api/common/client'; + +// 커스텀 드롭다운 컴포넌트 +function CustomSelect({ value, onChange, options, placeholder }) { + const [isOpen, setIsOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + const handleClickOutside = (e) => { + if (ref.current && !ref.current.contains(e.target)) { + setIsOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + return ( +
+ + + + {isOpen && ( + + {options.map((option) => ( + + ))} + + )} + +
+ ); +} + +function AdminAlbumForm() { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { id } = useParams(); + const isEditMode = !!id; + const coverInputRef = useRef(null); + const { user, isAuthenticated } = useAdminAuth(); + + const [saving, setSaving] = useState(false); + const [coverPreview, setCoverPreview] = useState(null); + const [coverFile, setCoverFile] = useState(null); + const { toast, setToast } = useToast(); + + const [formData, setFormData] = useState({ + title: '', + album_type: '', + album_type_short: '', + release_date: '', + cover_original_url: '', + cover_medium_url: '', + cover_thumb_url: '', + folder_name: '', + description: '', + }); + + const [tracks, setTracks] = useState([]); + + // 수정 모드일 때 앨범 데이터 로드 + const { + data: albumData, + isLoading: loading, + error: albumError, + } = useQuery({ + queryKey: ['admin', 'album', id], + queryFn: () => adminAlbumApi.getAlbum(id), + enabled: isAuthenticated && isEditMode && !!id, + staleTime: 0, + }); + + // 앨범 데이터 로드 시 폼에 반영 + useEffect(() => { + if (albumData) { + setFormData({ + title: albumData.title || '', + album_type: albumData.album_type || '', + album_type_short: albumData.album_type_short || '', + release_date: albumData.release_date ? albumData.release_date.split('T')[0] : '', + cover_original_url: albumData.cover_original_url || '', + cover_medium_url: albumData.cover_medium_url || '', + cover_thumb_url: albumData.cover_thumb_url || '', + folder_name: albumData.folder_name || '', + description: albumData.description || '', + }); + if (albumData.cover_medium_url || albumData.cover_original_url) { + setCoverPreview(albumData.cover_medium_url || albumData.cover_original_url); + } + setTracks(albumData.tracks || []); + } + }, [albumData]); + + // 에러 처리 + useEffect(() => { + if (albumError) { + console.error('앨범 로드 오류:', albumError); + setToast({ message: '앨범 로드 중 오류가 발생했습니다.', type: 'error' }); + } + }, [albumError, setToast]); + + const handleInputChange = (e) => { + const { name, value } = e.target; + + // 앨범명 변경 시 RustFS 폴더명 자동 생성 + if (name === 'title') { + const folderName = value + .toLowerCase() + .replace(/[\s.]+/g, '-') + .replace(/[^a-z0-9가-힣-]/g, '') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); + setFormData((prev) => ({ ...prev, title: value, folder_name: folderName })); + } else { + setFormData((prev) => ({ ...prev, [name]: value })); + } + }; + + const handleCoverChange = (e) => { + const file = e.target.files[0]; + if (file) { + setCoverFile(file); + const reader = new FileReader(); + reader.onloadend = () => { + setCoverPreview(reader.result); + }; + reader.readAsDataURL(file); + } + }; + + const addTrack = () => { + setTracks((prev) => [ + ...prev, + { + track_number: prev.length + 1, + title: '', + is_title_track: false, + duration: '', + }, + ]); + }; + + const removeTrack = (index) => { + setTracks((prev) => + prev + .filter((_, i) => i !== index) + .map((track, i) => ({ + ...track, + track_number: i + 1, + })) + ); + }; + + const updateTrack = (index, field, value) => { + // 작사/작곡/편곡 필드에서 '|' (전각 세로 막대)를 ', '로 자동 변환 + let processedValue = value; + if (['lyricist', 'composer', 'arranger'].includes(field)) { + processedValue = value.replace(/[||]/g, ', '); + } + + setTracks((prev) => + prev.map((track, i) => (i === index ? { ...track, [field]: processedValue } : track)) + ); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + + // 커스텀 검증 + if (!formData.title.trim()) { + setToast({ message: '앨범명을 입력해주세요.', type: 'warning' }); + return; + } + if (!formData.folder_name.trim()) { + setToast({ message: 'RustFS 폴더명을 입력해주세요.', type: 'warning' }); + return; + } + if (!formData.album_type_short) { + setToast({ message: '앨범 타입을 선택해주세요.', type: 'warning' }); + return; + } + if (!formData.release_date) { + setToast({ message: '발매일을 선택해주세요.', type: 'warning' }); + return; + } + if (!formData.album_type.trim()) { + setToast({ message: '앨범 유형을 입력해주세요.', type: 'warning' }); + return; + } + + setSaving(true); + + try { + const form = new FormData(); + form.append('data', JSON.stringify({ ...formData, tracks })); + if (coverFile) { + form.append('cover', coverFile); + } + + const url = isEditMode ? `/albums/${id}` : '/albums'; + const method = isEditMode ? 'PUT' : 'POST'; + + await fetchFormData(url, form, method); + + // 앨범 목록 캐시 무효화 + queryClient.invalidateQueries({ queryKey: ['admin', 'albums'] }); + navigate('/admin/albums'); + } catch (error) { + console.error('저장 오류:', error); + setToast({ message: '저장 중 오류가 발생했습니다.', type: 'error' }); + } finally { + setSaving(false); + } + }; + + const albumTypes = ['정규', '미니', '싱글']; + + return ( + + setToast(null)} /> + +
+ {/* 브레드크럼 */} +
+ + + + + + 앨범 관리 + + + {isEditMode ? '앨범 수정' : '새 앨범 추가'} +
+ + {/* 타이틀 */} +
+

+ {isEditMode ? '앨범 수정' : '새 앨범 추가'} +

+

앨범 정보와 트랙을 입력하세요

+
+ + {loading ? ( +
+ ) : ( + + {/* 앨범 기본 정보 */} +
+

앨범 정보

+ +
+ {/* 커버 이미지 */} +
+ +
+
coverInputRef.current?.click()} + className="w-40 h-40 rounded-xl border-2 border-dashed border-gray-200 flex items-center justify-center cursor-pointer hover:border-primary hover:bg-primary/5 transition-colors overflow-hidden" + > + {coverPreview ? ( + 커버 미리보기 + ) : ( +
+ +

클릭하여 업로드

+
+ )} +
+ +
+

권장 크기: 1000x1000px

+

지원 형식: JPG, PNG, WebP

+ {coverPreview && ( + + )} +
+
+
+ + {/* 앨범명 */} +
+ + +
+ + {/* 폴더명 */} +
+ +
+ fromis-9/album/ + +
+

영문 소문자, 숫자, 하이픈만 사용

+
+ + {/* 앨범 타입 */} +
+ + setFormData((prev) => ({ ...prev, album_type_short: val }))} + options={albumTypes} + placeholder="타입 선택" + /> +
+ + {/* 앨범 유형 */} +
+ + +
+ + {/* 발매일 */} +
+ + setFormData((prev) => ({ ...prev, release_date: val }))} + minYear={2017} + /> +
+ + {/* 설명 */} +
+ +