From 6d5ebc39452af9702ed52d6efe16f1bb3bd45eee Mon Sep 17 00:00:00 2001 From: Jino Bae Date: Fri, 16 Jan 2026 23:38:38 +0900 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20cover=20image=20upload=20feat?= =?UTF-8?q?ure=20for=20user=20profiles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apps/remotes/src/components/App.tsx | 3 +- .../src/components/remotes/Author/Heatmap.tsx | 11 +- .../SettingsApp/components/SettingsLayout.tsx | 2 +- .../pages/ProfileSetting/ProfileSetting.tsx | 90 +++++++++++++++- .../apps/remotes/src/lib/api/settings.ts | 8 ++ .../ui/src/components/Charts/Heatmap.tsx | 6 +- .../ui/src/components/LoadingState.tsx | 20 +--- backend/src/board/admin/post.py | 3 +- backend/src/board/admin/user.py | 3 +- .../board/author/author_overview.html | 97 +---------------- .../board/author/author_reader_overview.html | 101 +----------------- .../author/components/activity_section.html | 63 +++++++++++ .../author/components/author_header.html | 18 +++- .../author/components/readme_section.html | 30 ++++++ backend/src/board/tests/api/test_setting.py | 24 +++++ backend/src/board/views/api/v1/setting.py | 9 ++ package.json | 4 +- 17 files changed, 259 insertions(+), 233 deletions(-) create mode 100644 backend/src/board/templates/board/author/components/activity_section.html create mode 100644 backend/src/board/templates/board/author/components/readme_section.html diff --git a/backend/islands/apps/remotes/src/components/App.tsx b/backend/islands/apps/remotes/src/components/App.tsx index 9f409fee..8abc4877 100644 --- a/backend/islands/apps/remotes/src/components/App.tsx +++ b/backend/islands/apps/remotes/src/components/App.tsx @@ -1,6 +1,5 @@ import { lazy, Suspense } from 'react'; import { ConfirmProvider } from '~/contexts/ConfirmContext'; -import { LoadingState } from '@blex/ui'; interface AppProps { __name: keyof typeof LazyComponents; @@ -32,7 +31,7 @@ const App = ({ __name, ...props }: AppProps) => { return ( - }> + {/* @ts-expect-error - 동적 컴포넌트 props 타입 처리를 위한 임시 방법 */} diff --git a/backend/islands/apps/remotes/src/components/remotes/Author/Heatmap.tsx b/backend/islands/apps/remotes/src/components/remotes/Author/Heatmap.tsx index bbd2f7ac..e6134ac4 100644 --- a/backend/islands/apps/remotes/src/components/remotes/Author/Heatmap.tsx +++ b/backend/islands/apps/remotes/src/components/remotes/Author/Heatmap.tsx @@ -33,7 +33,7 @@ const Heatmap = ({ username }: HeatmapProps) => { } }); - if (isLoading) { + if (!heatmapData || isLoading) { return (
@@ -41,14 +41,6 @@ const Heatmap = ({ username }: HeatmapProps) => { ); } - if (!heatmapData || Object.keys(heatmapData).length === 0) { - return ( -
-

활동 데이터가 없습니다

-
- ); - } - const activityCount = Object.values(heatmapData).reduce((acc, cur) => acc + cur, 0); return ( @@ -62,6 +54,7 @@ const Heatmap = ({ username }: HeatmapProps) => { end: new Date() }} countLabel="활동" + className="mx-auto w-fit" />
); diff --git a/backend/islands/apps/remotes/src/components/remotes/SettingsApp/components/SettingsLayout.tsx b/backend/islands/apps/remotes/src/components/remotes/SettingsApp/components/SettingsLayout.tsx index c1cee75b..3e82e63f 100644 --- a/backend/islands/apps/remotes/src/components/remotes/SettingsApp/components/SettingsLayout.tsx +++ b/backend/islands/apps/remotes/src/components/remotes/SettingsApp/components/SettingsLayout.tsx @@ -15,7 +15,7 @@ export const SettingsLayout = () => { {/* Main Content */}
- }> + }>
diff --git a/backend/islands/apps/remotes/src/components/remotes/SettingsApp/pages/ProfileSetting/ProfileSetting.tsx b/backend/islands/apps/remotes/src/components/remotes/SettingsApp/pages/ProfileSetting/ProfileSetting.tsx index ba46139c..b9fca219 100644 --- a/backend/islands/apps/remotes/src/components/remotes/SettingsApp/pages/ProfileSetting/ProfileSetting.tsx +++ b/backend/islands/apps/remotes/src/components/remotes/SettingsApp/pages/ProfileSetting/ProfileSetting.tsx @@ -7,7 +7,7 @@ import { useSuspenseQuery } from '@tanstack/react-query'; import { useConfirm } from '~/contexts/ConfirmContext'; import { SettingsHeader } from '../../components'; import { Button, Input, Card } from '~/components/shared'; -import { getProfileSettings, updateProfileSettings, uploadAvatar } from '~/lib/api/settings'; +import { getProfileSettings, updateProfileSettings, uploadAvatar, uploadCover } from '~/lib/api/settings'; // Define Zod schema for profile form const profileSchema = z.object({ @@ -19,6 +19,7 @@ type ProfileFormInputs = z.infer; const ProfileSetting = () => { const [avatar, setAvatar] = useState('/resources/staticfiles/images/default-avatar.jpg'); + const [cover, setCover] = useState(null); const [isLoading, setIsLoading] = useState(false); const { confirm } = useConfirm(); @@ -38,6 +39,7 @@ const ProfileSetting = () => { useEffect(() => { if (profileData) { setAvatar(profileData.avatar || '/resources/staticfiles/images/default-avatar.jpg'); + setCover(profileData.cover || null); reset({ bio: profileData.bio || '', homepage: profileData.homepage || '' @@ -99,6 +101,38 @@ const ProfileSetting = () => { } }; + const handleCoverChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + const confirmed = await confirm({ + title: '커버 이미지 변경', + message: '커버 이미지를 변경하시겠습니까?', + confirmText: '변경' + }); + + if (!confirmed) { + e.target.value = ''; + return; + } + + try { + const { data } = await uploadCover(file); + + if (data.status === 'DONE') { + setCover(data.body.url); + toast.success('커버 이미지가 업데이트 되었습니다.'); + refetch(); + } else { + toast.error('커버 이미지 업데이트에 실패했습니다.'); + } + } catch { + toast.error('커버 이미지 업데이트에 실패했습니다.'); + } finally { + e.target.value = ''; + } + }; + return (
{
+ {/* Cover Image Section */} + +
+ {cover ? ( +
+
+ 커버 이미지 +
+
+ +
+ +
+ ) : ( + + )} +
+

프로필 페이지 상단에 표시되는 배너 이미지입니다.

+

권장 크기: 1200x514px (21:9 비율), 최대 5MB

+
+
+
+ {/* Profile Information Section */}
diff --git a/backend/islands/apps/remotes/src/lib/api/settings.ts b/backend/islands/apps/remotes/src/lib/api/settings.ts index b40d1820..ea033d6f 100644 --- a/backend/islands/apps/remotes/src/lib/api/settings.ts +++ b/backend/islands/apps/remotes/src/lib/api/settings.ts @@ -20,6 +20,7 @@ export interface ProfileData { bio: string; homepage: string; avatar: string; + cover: string | null; } export interface ProfileUpdateData { @@ -87,6 +88,13 @@ export const uploadAvatar = async (file: File) => { return http.post>('v1/setting/avatar', formData, { headers: { 'Content-Type': 'multipart/form-data' } }); }; +export const uploadCover = async (file: File) => { + const formData = new FormData(); + formData.append('cover', file); + + return http.post>('v1/setting/cover', formData, { headers: { 'Content-Type': 'multipart/form-data' } }); +}; + export const updateNotifyConfig = async (config: Record) => { return http.put>('v1/setting/notify-config', config, { headers: { 'Content-Type': 'application/json' } }); }; diff --git a/backend/islands/packages/ui/src/components/Charts/Heatmap.tsx b/backend/islands/packages/ui/src/components/Charts/Heatmap.tsx index 81daf34f..9b0be494 100644 --- a/backend/islands/packages/ui/src/components/Charts/Heatmap.tsx +++ b/backend/islands/packages/ui/src/components/Charts/Heatmap.tsx @@ -9,12 +9,14 @@ export interface HeatmapProps { }; countLabel?: string; colors?: string[]; + className?: string; } export const Heatmap = ({ data, countLabel = 'Contribution', - colors = ['#ebedf0', '#c6e48b', '#7bc96f', '#239a3b', '#196127'] + colors = ['#ebedf0', '#c6e48b', '#7bc96f', '#239a3b', '#196127'], + className = 'w-full' }: HeatmapProps) => { const chartRef = useRef(null); const chartInstance = useRef(null); @@ -39,5 +41,5 @@ export const Heatmap = ({ }, [data, countLabel, colors]); - return
; + return
; }; diff --git a/backend/islands/packages/ui/src/components/LoadingState.tsx b/backend/islands/packages/ui/src/components/LoadingState.tsx index 7445ac59..0ed8a28e 100644 --- a/backend/islands/packages/ui/src/components/LoadingState.tsx +++ b/backend/islands/packages/ui/src/components/LoadingState.tsx @@ -1,27 +1,9 @@ interface LoadingStateProps { rows?: number; - type?: 'card' | 'list' | 'form' | 'spinner'; + type?: 'list' | 'form' | 'spinner'; } const LoadingState = ({ rows = 3, type = 'form' }: LoadingStateProps) => { - if (type === 'card') { - return ( -
-
-
-
- {[...Array(rows)].map((_, i) => ( -
-
-
-
- ))} -
-
-
- ); - } - if (type === 'list') { return (
diff --git a/backend/src/board/admin/post.py b/backend/src/board/admin/post.py index c07ed7b0..677b8a44 100644 --- a/backend/src/board/admin/post.py +++ b/backend/src/board/admin/post.py @@ -248,7 +248,8 @@ def get_form(self, request: HttpRequest, obj: Optional[TempPosts] = None, **kwar @admin.register(PostLikes) class PostLikesAdmin(admin.ModelAdmin): - list_display = ['user', 'post', 'created_date'] + list_display = ['id', 'user', 'post', 'created_date'] + list_display_links = ['id'] def get_form(self, request: HttpRequest, obj: Optional[PostLikes] = None, **kwargs: Any) -> Any: kwargs['exclude'] = ['user', 'post'] diff --git a/backend/src/board/admin/user.py b/backend/src/board/admin/user.py index 50891a1c..8290527d 100644 --- a/backend/src/board/admin/user.py +++ b/backend/src/board/admin/user.py @@ -228,7 +228,8 @@ class ProfileAdmin(admin.ModelAdmin): """프로필 관리 페이지""" autocomplete_fields = ['user'] - list_display = ['user_link', 'role_badge', 'avatar_preview', 'analytics_status', 'post_count'] + list_display = ['id', 'user_link', 'role_badge', 'avatar_preview', 'analytics_status', 'post_count'] + list_display_links = ['id'] list_filter = ['role', ('user__date_joined', admin.DateFieldListFilter)] search_fields = ['user__username', 'user__email', 'bio', 'homepage'] list_per_page = LIST_PER_PAGE_DEFAULT diff --git a/backend/src/board/templates/board/author/author_overview.html b/backend/src/board/templates/board/author/author_overview.html index 592fe035..05f60c11 100644 --- a/backend/src/board/templates/board/author/author_overview.html +++ b/backend/src/board/templates/board/author/author_overview.html @@ -20,101 +20,12 @@
- - -
-
-
-

README

- .md -
- {% if about_html %} -
- {{ about_html|safe }} -
- {% else %} -
-
- -
-

아직 소개글이 없습니다

-
- {% endif %} - {% if user == author %} - - {% endif %} -
-
+ {% include 'board/author/components/readme_section.html' %} - -
-
-

최근 활동

- 베타 -
- - - -
+ {% with is_editor_view=True %} + {% include 'board/author/components/activity_section.html' %} + {% endwith %} {% if pinned_posts %} diff --git a/backend/src/board/templates/board/author/author_reader_overview.html b/backend/src/board/templates/board/author/author_reader_overview.html index 6aa913bc..b147c8b4 100644 --- a/backend/src/board/templates/board/author/author_reader_overview.html +++ b/backend/src/board/templates/board/author/author_reader_overview.html @@ -20,104 +20,11 @@
- -
-
-
-

README

- .md -
- {% if about_html %} -
- {{ about_html|safe }} -
- {% else %} -
-
- -
-

아직 소개글이 없습니다

-
- {% endif %} + {% include 'board/author/components/readme_section.html' %} - {% if user == author %} - - {% endif %} -
-
- - -
-
-

활동

- 베타 -
- - -
- -
- - - -
+ {% with is_editor_view=False %} + {% include 'board/author/components/activity_section.html' %} + {% endwith %}
diff --git a/backend/src/board/templates/board/author/components/activity_section.html b/backend/src/board/templates/board/author/components/activity_section.html new file mode 100644 index 00000000..ae69ab86 --- /dev/null +++ b/backend/src/board/templates/board/author/components/activity_section.html @@ -0,0 +1,63 @@ + +
+
+

{% if is_editor_view %}최근 활동{% else %}활동{% endif %}

+ 베타 +
+ + + +
diff --git a/backend/src/board/templates/board/author/components/author_header.html b/backend/src/board/templates/board/author/components/author_header.html index 08ab3dc3..8843be16 100644 --- a/backend/src/board/templates/board/author/components/author_header.html +++ b/backend/src/board/templates/board/author/components/author_header.html @@ -2,12 +2,20 @@ {% load island %} {% load resource %} -{% load static %} -{% load island %} -{% load resource %} -
-
+ {# Cover Image Section #} + {% if author.profile.cover %} +
+ {{ author.username }} cover +
+
+ {% endif %} + +
{% if author.profile.avatar %} {{ author.username }} diff --git a/backend/src/board/templates/board/author/components/readme_section.html b/backend/src/board/templates/board/author/components/readme_section.html new file mode 100644 index 00000000..f0b41032 --- /dev/null +++ b/backend/src/board/templates/board/author/components/readme_section.html @@ -0,0 +1,30 @@ + +
+
+
+

README

+ .md +
+ {% if about_html %} +
+ {{ about_html|safe }} +
+ {% else %} +
+
+ +
+

아직 소개글이 없습니다

+
+ {% endif %} + + {% if user == author %} + + {% endif %} +
+
diff --git a/backend/src/board/tests/api/test_setting.py b/backend/src/board/tests/api/test_setting.py index 7d55006b..f1716993 100644 --- a/backend/src/board/tests/api/test_setting.py +++ b/backend/src/board/tests/api/test_setting.py @@ -197,6 +197,29 @@ def test_upload_avatar(self): self.assertEqual(content['status'], 'DONE') self.assertIn('url', content['body']) + def test_upload_cover(self): + """커버 이미지 업로드 테스트""" + from io import BytesIO + from PIL import Image + + self.client.login(username='test', password='test') + + image = Image.new('RGB', (1200, 514), color='blue') + image_file = BytesIO() + image.save(image_file, 'PNG') + image_file.name = 'cover.png' + image_file.seek(0) + + response = self.client.post( + '/v1/setting/cover', + {'cover': image_file}, + format='multipart' + ) + self.assertEqual(response.status_code, 200) + content = json.loads(response.content) + self.assertEqual(content['status'], 'DONE') + self.assertIn('url', content['body']) + def test_get_setting_profile(self): """프로필 설정 조회 테스트""" self.client.login(username='test', password='test') @@ -206,6 +229,7 @@ def test_get_setting_profile(self): content = json.loads(response.content) self.assertEqual(content['status'], 'DONE') self.assertIn('avatar', content['body']) + self.assertIn('cover', content['body']) self.assertIn('bio', content['body']) self.assertIn('homepage', content['body']) diff --git a/backend/src/board/views/api/v1/setting.py b/backend/src/board/views/api/v1/setting.py index f1313d5c..9b0cee79 100644 --- a/backend/src/board/views/api/v1/setting.py +++ b/backend/src/board/views/api/v1/setting.py @@ -158,6 +158,7 @@ def setting(request, parameter): if parameter == 'profile': return StatusDone({ 'avatar': user.profile.get_thumbnail(), + 'cover': user.profile.cover.url if user.profile.cover else None, 'bio': user.profile.bio, 'homepage': user.profile.homepage, 'social': user.profile.collect_social(), @@ -345,6 +346,14 @@ def setting(request, parameter): 'url': profile.get_thumbnail(), }) + if parameter == 'cover': + profile = Profile.objects.get(user=user) + profile.cover = request.FILES['cover'] + profile.save() + return StatusDone({ + 'url': profile.cover.url if profile.cover else None, + }) + if request.method == 'PUT': # Try to parse JSON first, fallback to QueryDict try: diff --git a/package.json b/package.json index ff7280cf..bdce7619 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "description": "BLOG EXPRESS ME", "scripts": { - "install": "npm run server:setup && npm run island:setup", + "install": "npm run server:setup && npm run islands:setup", "server:setup": "./scripts/setup.sh", "server:dev": "./scripts/manage.sh runserver 0.0.0.0:8000", "server:test": "./scripts/manage.sh test -v 2", @@ -16,7 +16,7 @@ "islands:lint": "npm run lint --prefix backend/islands", "islands:build": "npm run build --prefix backend/islands", "islands:type-check": "npm run type-check --prefix backend/islands", - "dev": "concurrently \"npm run server:dev\" \"npm run island:dev\"" + "dev": "concurrently \"npm run server:dev\" \"npm run islands:dev\"" }, "repository": { "type": "git",