Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions backend/islands/apps/remotes/src/components/App.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -32,7 +31,7 @@ const App = ({ __name, ...props }: AppProps) => {

return (
<ConfirmProvider>
<Suspense fallback={<LoadingState type="spinner" />}>
<Suspense>
{/* @ts-expect-error - 동적 μ»΄ν¬λ„ŒνŠΈ props νƒ€μž… 처리λ₯Ό μœ„ν•œ μž„μ‹œ 방법 */}
<Component {...props} />
</Suspense>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,22 +33,14 @@ const Heatmap = ({ username }: HeatmapProps) => {
}
});

if (isLoading) {
if (!heatmapData || isLoading) {
return (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900" />
</div>
);
}

if (!heatmapData || Object.keys(heatmapData).length === 0) {
return (
<div className="text-center py-12">
<p className="text-gray-400">ν™œλ™ 데이터가 μ—†μŠ΅λ‹ˆλ‹€</p>
</div>
);
}

const activityCount = Object.values(heatmapData).reduce((acc, cur) => acc + cur, 0);

return (
Expand All @@ -62,6 +54,7 @@ const Heatmap = ({ username }: HeatmapProps) => {
end: new Date()
}}
countLabel="ν™œλ™"
className="mx-auto w-fit"
/>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export const SettingsLayout = () => {
<SettingsDesktopNavigation currentPath={currentPath} />
{/* Main Content */}
<main className="flex-1 min-w-0 py-6">
<Suspense fallback={<LoadingState type="list" rows={5} />}>
<Suspense fallback={<LoadingState type="form" />}>
<Outlet />
</Suspense>
</main>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -19,6 +19,7 @@ type ProfileFormInputs = z.infer<typeof profileSchema>;

const ProfileSetting = () => {
const [avatar, setAvatar] = useState('/resources/staticfiles/images/default-avatar.jpg');
const [cover, setCover] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const { confirm } = useConfirm();

Expand All @@ -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 || ''
Expand Down Expand Up @@ -99,6 +101,38 @@ const ProfileSetting = () => {
}
};

const handleCoverChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<div>
<SettingsHeader
Expand Down Expand Up @@ -139,6 +173,60 @@ const ProfileSetting = () => {
</div>
</Card>

{/* Cover Image Section */}
<Card title="컀버 이미지" className="mb-6">
<div className="space-y-4">
{cover ? (
<div className="relative group">
<div className="aspect-[21/9] w-full rounded-2xl overflow-hidden ring-1 ring-gray-900/5">
<img
src={cover}
alt="컀버 이미지"
className="w-full h-full object-cover"
/>
</div>
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-40 transition-all duration-200 rounded-2xl flex items-center justify-center opacity-0 group-hover:opacity-100">
<label
htmlFor="cover-input"
className="px-6 py-3 bg-white hover:bg-gray-50 rounded-xl text-sm font-semibold text-gray-900 cursor-pointer transition-colors shadow-lg">
이미지 λ³€κ²½
</label>
</div>
<input
id="cover-input"
type="file"
accept="image/*"
onChange={handleCoverChange}
className="hidden"
/>
</div>
) : (
<label htmlFor="cover-input-empty" className="block">
<div className="aspect-[21/9] w-full rounded-2xl border-2 border-dashed border-gray-200 hover:border-gray-300 hover:bg-gray-50 transition-all duration-200 cursor-pointer flex flex-col items-center justify-center gap-3 group">
<svg className="w-12 h-12 text-gray-300 group-hover:text-gray-400 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<div className="text-center">
<p className="text-sm font-semibold text-gray-600 mb-1">컀버 이미지 μΆ”κ°€</p>
<p className="text-xs text-gray-400">ν΄λ¦­ν•˜μ—¬ 이미지λ₯Ό μ—…λ‘œλ“œν•˜μ„Έμš”</p>
</div>
</div>
<input
id="cover-input-empty"
type="file"
accept="image/*"
onChange={handleCoverChange}
className="hidden"
/>
</label>
)}
<div>
<p className="text-sm text-gray-500 mb-1">ν”„λ‘œν•„ νŽ˜μ΄μ§€ 상단에 ν‘œμ‹œλ˜λŠ” λ°°λ„ˆ μ΄λ―Έμ§€μž…λ‹ˆλ‹€.</p>
<p className="text-xs text-gray-400">ꢌμž₯ 크기: 1200x514px (21:9 λΉ„μœ¨), μ΅œλŒ€ 5MB</p>
</div>
</div>
</Card>

{/* Profile Information Section */}
<Card title="κΈ°λ³Έ 정보" className="mb-6">
<div className="mb-6">
Expand Down
8 changes: 8 additions & 0 deletions backend/islands/apps/remotes/src/lib/api/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export interface ProfileData {
bio: string;
homepage: string;
avatar: string;
cover: string | null;
}

export interface ProfileUpdateData {
Expand Down Expand Up @@ -87,6 +88,13 @@ export const uploadAvatar = async (file: File) => {
return http.post<Response<{ url: string }>>('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<Response<{ url: string | null }>>('v1/setting/cover', formData, { headers: { 'Content-Type': 'multipart/form-data' } });
};

export const updateNotifyConfig = async (config: Record<string, boolean>) => {
return http.put<Response<{ success: boolean }>>('v1/setting/notify-config', config, { headers: { 'Content-Type': 'application/json' } });
};
Expand Down
6 changes: 4 additions & 2 deletions backend/islands/packages/ui/src/components/Charts/Heatmap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement>(null);
const chartInstance = useRef<Chart>(null);
Expand All @@ -39,5 +41,5 @@ export const Heatmap = ({

}, [data, countLabel, colors]);

return <div ref={chartRef} className="w-full" />;
return <div ref={chartRef} className={className} />;
};
20 changes: 1 addition & 19 deletions backend/islands/packages/ui/src/components/LoadingState.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="bg-white rounded-2xl border border-gray-200 shadow-sm p-6">
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-40 mb-6" />
<div className="space-y-4">
{[...Array(rows)].map((_, i) => (
<div key={i} className="bg-gray-50 rounded-2xl p-6">
<div className="h-4 bg-gray-200 rounded w-24 mb-3" />
<div className="h-10 bg-gray-200 rounded" />
</div>
))}
</div>
</div>
</div>
);
}

if (type === 'list') {
return (
<div className="space-y-3 animate-pulse">
Expand Down
3 changes: 2 additions & 1 deletion backend/src/board/admin/post.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down
3 changes: 2 additions & 1 deletion backend/src/board/admin/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
97 changes: 4 additions & 93 deletions backend/src/board/templates/board/author/author_overview.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,101 +20,12 @@
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8 mb-20">
<!-- Main Content Column -->
<div class="lg:col-span-8 space-y-10">

<!-- Introduction Section -->
<section>
<div class="bg-white rounded-2xl ring-1 ring-gray-900/5 p-8">
<div class="flex items-baseline gap-3 mb-6 pb-4 border-b border-gray-100">
<h2 class="text-2xl font-bold text-gray-900">README</h2>
<span class="text-sm font-mono text-gray-400">.md</span>
</div>
{% if about_html %}
<div class="prose blog-post-content">
{{ about_html|safe }}
</div>
{% else %}
<div class="text-center py-16">
<div class="inline-flex items-center justify-center w-14 h-14 rounded-xl bg-gray-50 mb-3">
<i class="fas fa-file-alt text-gray-300 text-lg"></i>
</div>
<p class="text-gray-400 font-medium">아직 μ†Œκ°œκΈ€μ΄ μ—†μŠ΅λ‹ˆλ‹€</p>
</div>
{% endif %}

{% if user == author %}
<div class="mt-6 pt-6 border-t border-gray-100">
<a href="{% url 'user_about_edit' author.username %}" class="flex items-center justify-center w-full px-4 py-2.5 bg-gray-900 hover:bg-gray-800 rounded-xl text-sm font-semibold text-white transition-colors">
<i class="fas fa-edit mr-2"></i>
μ†Œκ°œκΈ€ μˆ˜μ •
</a>
</div>
{% endif %}
</div>
</section>
{% include 'board/author/components/readme_section.html' %}

<!-- Activity Section -->
<section>
<div class="flex items-center justify-between mb-4">
<h2 class="text-2xl font-bold text-gray-900">졜근 ν™œλ™</h2>
<span class="px-2.5 py-1 rounded-full bg-blue-50 text-blue-600 text-xs font-semibold">베타</span>
</div>

<!-- Recent Activities -->
<div class="bg-white rounded-2xl ring-1 ring-gray-900/5 p-6 mb-6" style="min-height: 280px;">
<island-component name="Heatmap" props="{{ author_activity_props }}"></island-component>

{% if recent_activities %}
<div class="space-y-2 mt-6">
{% for activity in recent_activities %}
<a href="{{ activity.url }}" class="flex items-center gap-4 p-3 -mx-3 rounded-2xl hover:bg-gray-50 transition-all duration-150 active:scale-[0.99] group">
<div class="w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0
{% if activity.type == 'post' %}bg-blue-50 text-blue-600
{% elif activity.type == 'series' %}bg-indigo-50 text-indigo-600
{% elif activity.type == 'comment' %}bg-emerald-50 text-emerald-600
{% elif activity.type == 'like' %}bg-rose-50 text-rose-600
{% else %}bg-gray-100 text-gray-600{% endif %}">
{% if activity.type == 'post' %}
<i class="fas fa-pen-nib text-sm"></i>
{% elif activity.type == 'series' %}
<i class="fas fa-layer-group text-sm"></i>
{% elif activity.type == 'comment' %}
<i class="fas fa-comment-alt text-sm"></i>
{% elif activity.type == 'like' %}
<i class="fas fa-heart text-sm"></i>
{% else %}
<i class="fas fa-clock text-sm"></i>
{% endif %}
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between mb-0.5">
<span class="text-xs text-gray-500">
{% if activity.type == 'post' %}μƒˆ 글을 μž‘μ„±ν–ˆμ–΄μš”
{% elif activity.type == 'series' %}μ‹œλ¦¬μ¦ˆλ₯Ό λ§Œλ“€μ—ˆμ–΄μš”
{% elif activity.type == 'comment' %}λŒ“κΈ€μ„ λ‚¨κ²Όμ–΄μš”
{% elif activity.type == 'like' %}μ’‹μ•„μš”λ₯Ό λˆŒλ €μ–΄μš”
{% else %}ν™œλ™{% endif %}
</span>
<span class="text-[11px] text-gray-400 font-medium tabular-nums">{{ activity.date }}</span>
</div>
<h4 class="text-sm font-semibold text-gray-700 line-clamp-1 group-hover:text-blue-600 transition-colors">
{% if activity.type == 'comment' or activity.type == 'like' %}
{{ activity.postTitle }}
{% else %}
{{ activity.title }}
{% endif %}
</h4>
</div>
</a>
{% endfor %}
</div>
{% else %}
<div class="py-12 text-center">
<i class="fas fa-wind text-gray-300 text-2xl mb-3"></i>
<p class="text-sm text-gray-400">기둝된 ν™œλ™μ΄ μ—†μŠ΅λ‹ˆλ‹€</p>
</div>
{% endif %}
</div>
</section>
{% with is_editor_view=True %}
{% include 'board/author/components/activity_section.html' %}
{% endwith %}

<!-- Pinned Posts Section -->
{% if pinned_posts %}
Expand Down
Loading
Loading