diff --git a/backend/community/serializers.py b/backend/community/serializers.py index c07cc5d..a1f5724 100644 --- a/backend/community/serializers.py +++ b/backend/community/serializers.py @@ -29,13 +29,16 @@ class ActivitySerializer(serializers.ModelSerializer): return config class ActivitySignupSerializer(serializers.ModelSerializer): - activity_info = ActivitySerializer(source='activity', read_only=True) + activity_info = serializers.SerializerMethodField() class Meta: model = ActivitySignup fields = ['id', 'activity', 'activity_info', 'user', 'signup_time', 'status', 'signup_info'] read_only_fields = ['signup_time', 'status', 'user'] + def get_activity_info(self, obj): + return ActivitySerializer(obj.activity).data + class TopicMediaSerializer(serializers.ModelSerializer): url = serializers.SerializerMethodField() diff --git a/frontend/src/animation.js b/frontend/src/animation.js index 4e2db75..d083bf1 100644 --- a/frontend/src/animation.js +++ b/frontend/src/animation.js @@ -1,35 +1,53 @@ + +// Framer Motion Animation Variants + export const fadeInUp = { - initial: { opacity: 0, y: 30 }, - animate: { opacity: 1, y: 0, transition: { duration: 0.6, ease: "easeOut" } }, - exit: { opacity: 0, y: 20, transition: { duration: 0.4 } } + hidden: { opacity: 0, y: 30 }, + visible: (custom = 0) => ({ + opacity: 1, + y: 0, + transition: { + delay: custom * 0.08, + duration: 0.6, + ease: [0.22, 1, 0.36, 1], // Custom easing + }, + }), }; export const staggerContainer = { hidden: { opacity: 0 }, - show: { + visible: { opacity: 1, transition: { - staggerChildren: 0.08 - } - } + staggerChildren: 0.1, + }, + }, }; -export const cardHover = { +export const hoverScale = { hover: { scale: 1.03, - boxShadow: "0 20px 40px rgba(0,0,0,0.4)", - y: -5, - transition: { duration: 0.3, ease: "easeOut" } - } -}; - -export const buttonClick = { - tap: { scale: 0.95 } + boxShadow: "0px 10px 20px rgba(0, 0, 0, 0.2)", + transition: { duration: 0.3 }, + }, }; export const pageTransition = { - initial: { opacity: 0 }, - animate: { opacity: 1 }, - exit: { opacity: 0 }, - transition: { duration: 0.4 } + initial: { opacity: 0, x: 20 }, + animate: { opacity: 1, x: 0 }, + exit: { opacity: 0, x: -20 }, + transition: { duration: 0.3 }, +}; + +export const buttonTap = { + scale: 0.95, +}; + +export const imageFadeIn = { + hidden: { opacity: 0, scale: 1.1 }, + visible: { + opacity: 1, + scale: 1, + transition: { duration: 0.5 } + }, }; diff --git a/frontend/src/api.js b/frontend/src/api.js index 1076b5b..03ef2f8 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -67,5 +67,6 @@ export const getMyPaidItems = () => api.get('/users/paid-items/'); export const getAnnouncements = () => api.get('/community/announcements/'); export const getActivities = () => api.get('/community/activities/'); export const getActivityDetail = (id) => api.get(`/community/activities/${id}/`); +export const signUpActivity = (id) => api.post(`/community/activities/${id}/signup/`); export default api; diff --git a/frontend/src/components/activity/Activity.module.less b/frontend/src/components/activity/Activity.module.less index 3e15ff7..261ba22 100644 --- a/frontend/src/components/activity/Activity.module.less +++ b/frontend/src/components/activity/Activity.module.less @@ -1,244 +1,265 @@ -@import '../../styles/theme.less'; -.container { - padding: 24px 0; - max-width: 1200px; - margin: 0 auto; +@import '../../theme.module.less'; + +.activitySection { + padding: var(--spacing-lg) 0; + width: 100%; } -.sectionHeader { +.header { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 24px; - padding: 0 24px; + margin-bottom: var(--spacing-md); +} - .sectionTitle { - font-size: 24px; - font-weight: bold; - color: @text-color; - letter-spacing: 1px; - } - - .moreBtn { - display: flex; - align-items: center; - gap: 8px; - cursor: pointer; - color: @primary-color; - transition: all 0.3s; - - &:hover { - color: lighten(@primary-color, 10%); - transform: translateX(4px); - } +.sectionTitle { + font-size: 24px; + font-weight: 700; + color: var(--text-primary); + display: flex; + align-items: center; + gap: 8px; + + &::before { + content: ''; + display: block; + width: 4px; + height: 24px; + background: var(--primary-color); + border-radius: 2px; } } -.card { - position: relative; - border-radius: @border-radius-base; - overflow: hidden; - aspect-ratio: 16/9; - cursor: pointer; - background: @card-bg; - border: 1px solid rgba(255, 255, 255, 0.1); - +.controls { + display: flex; + gap: var(--spacing-sm); + @media (max-width: 768px) { - aspect-ratio: 3/4; + display: none; /* Hide carousel controls on mobile */ } +} - .image { +.navBtn { + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + color: var(--text-primary); + width: 32px; + height: 32px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.3s; + + &:hover { + background: var(--primary-color); + border-color: var(--primary-color); + } +} + +/* Desktop Carousel */ +.desktopCarousel { + position: relative; + width: 100%; + overflow: hidden; + + @media (max-width: 768px) { + display: none; + } +} + +.dots { + display: flex; + justify-content: center; + gap: 8px; + margin-top: var(--spacing-md); +} + +.dot { + width: 8px; + height: 8px; + background: rgba(255, 255, 255, 0.2); + border-radius: 50%; + cursor: pointer; + transition: all 0.3s; + + &.activeDot { + background: var(--primary-color); + transform: scale(1.2); + } +} + +/* Mobile List */ +.mobileList { + display: none; + flex-direction: column; + gap: var(--spacing-md); + + @media (max-width: 768px) { + display: flex; + } +} + +/* --- Card Styles --- */ +.activityCard { + position: relative; + width: 100%; + height: 400px; + border-radius: var(--border-radius-lg); + overflow: hidden; + cursor: pointer; + background: var(--background-card); + box-shadow: var(--box-shadow-base); + transition: all 0.3s ease; + + @media (max-width: 768px) { + height: 300px; + } +} + +.imageContainer { + width: 100%; + height: 100%; + position: relative; + + img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.5s ease; } +} - .overlay { - position: absolute; - bottom: 0; - left: 0; - width: 100%; - height: 60%; - background: linear-gradient(to top, rgba(0,0,0,0.9), transparent); - display: flex; - flex-direction: column; - justify-content: flex-end; - padding: 24px; - } +.overlay { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 60%; + background: linear-gradient(to top, rgba(0,0,0,0.9), transparent); + display: flex; + flex-direction: column; + justify-content: flex-end; + padding: var(--spacing-lg); + box-sizing: border-box; +} - .status { - display: inline-block; - padding: 4px 12px; - background: @primary-color; - color: #000; - font-size: 12px; - font-weight: bold; - border-radius: 4px; - margin-bottom: 8px; - width: fit-content; - } +.statusTag { + display: inline-block; + background: var(--primary-color); + color: #fff; + padding: 4px 12px; + border-radius: 4px; + font-size: 12px; + font-weight: 600; + margin-bottom: var(--spacing-sm); + width: fit-content; + text-transform: uppercase; +} - .title { - font-size: 24px; - font-weight: bold; - color: @text-color; - margin-bottom: 8px; - text-shadow: 0 2px 4px rgba(0,0,0,0.5); - } - - .time { - font-size: 14px; - color: @text-secondary; - } - - &:hover { - .image { - transform: scale(1.05); - } +.title { + color: var(--text-primary); + font-size: 24px; + font-weight: 700; + margin-bottom: var(--spacing-xs); + line-height: 1.3; + text-shadow: 0 2px 4px rgba(0,0,0,0.5); + + @media (max-width: 768px) { + font-size: 18px; } } -// Detail Page -.detailContainer { - min-height: 100vh; - background: @bg-color; - color: @text-color; - padding-bottom: 80px; +.time { + color: var(--text-secondary); + font-size: 14px; + display: flex; + align-items: center; + gap: 6px; } -.heroSection { +/* Detail Page Styles */ +.detailHeader { position: relative; height: 50vh; + min-height: 300px; width: 100%; overflow: hidden; - - .heroImage { - width: 100%; - height: 100%; - object-fit: cover; - } - - .backBtn { - position: absolute; - top: 24px; - left: 24px; - z-index: 10; - background: rgba(0,0,0,0.5); - border: none; - color: white; - width: 40px; - height: 40px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - backdrop-filter: blur(4px); - - &:hover { - background: rgba(0,0,0,0.7); - } - } } -.contentSection { - padding: 24px; +.detailImage { + width: 100%; + height: 100%; + object-fit: cover; +} + +.detailContent { max-width: 800px; - margin: 0 auto; - margin-top: -60px; + margin: -60px auto 0; position: relative; - z-index: 2; - background: rgba(30, 30, 30, 0.8); - backdrop-filter: blur(20px); - border-radius: 24px 24px 0 0; - border-top: 1px solid rgba(255,255,255,0.1); + z-index: 10; + padding: 0 var(--spacing-lg) 100px; /* Bottom padding for fixed footer */ } -.metaInfo { - margin-bottom: 32px; - - h1 { - font-size: 32px; - margin-bottom: 16px; - color: @text-color; - } -} - -.infoRow { - display: flex; - gap: 24px; - margin-bottom: 16px; - color: @text-secondary; - - .icon { - margin-right: 8px; - color: @primary-color; - } +.infoCard { + background: var(--background-card); + padding: var(--spacing-lg); + border-radius: var(--border-radius-lg); + box-shadow: var(--box-shadow-base); + margin-bottom: var(--spacing-lg); + border: 1px solid var(--border-color); } .richText { + color: var(--text-secondary); line-height: 1.8; - color: #ddd; font-size: 16px; img { max-width: 100%; - border-radius: 8px; - margin: 16px 0; + border-radius: var(--border-radius-base); + margin: var(--spacing-md) 0; } - h2, h3 { - color: @text-color; - margin-top: 24px; - margin-bottom: 16px; + h1, h2, h3 { + color: var(--text-primary); + margin-top: var(--spacing-lg); } } -.bottomBar { +.fixedFooter { position: fixed; bottom: 0; left: 0; width: 100%; - padding: 16px 24px; - background: rgba(20, 20, 20, 0.9); + background: rgba(31, 31, 31, 0.95); backdrop-filter: blur(10px); - border-top: 1px solid rgba(255,255,255,0.1); + padding: var(--spacing-md) var(--spacing-lg); + border-top: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; z-index: 100; + box-shadow: 0 -4px 12px rgba(0,0,0,0.2); +} - .actionBtn { - flex: 1; - margin-left: 16px; - height: 48px; - font-size: 16px; - font-weight: bold; - border: none; - background: @primary-color; - box-shadow: 0 4px 12px rgba(0, 185, 107, 0.3); - - &:hover { - background: lighten(@primary-color, 5%); - } - } +.actionBtn { + background: var(--primary-color); + color: #fff; + border: none; + padding: 12px 32px; + border-radius: 24px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + box-shadow: 0 4px 12px rgba(0, 185, 107, 0.3); + transition: all 0.3s; - .shareBtn { - width: 48px; - height: 48px; - border-radius: 50%; - border: 1px solid rgba(255,255,255,0.2); - display: flex; - align-items: center; - justify-content: center; - color: white; - background: transparent; - cursor: pointer; - - &:hover { - background: rgba(255,255,255,0.1); - } + &:disabled { + background: #555; + cursor: not-allowed; + box-shadow: none; } } diff --git a/frontend/src/components/activity/ActivityCard.jsx b/frontend/src/components/activity/ActivityCard.jsx index 2bf9b38..90f4662 100644 --- a/frontend/src/components/activity/ActivityCard.jsx +++ b/frontend/src/components/activity/ActivityCard.jsx @@ -1,37 +1,59 @@ + import React from 'react'; import { motion } from 'framer-motion'; -import { Typography } from 'antd'; -import { CalendarOutlined } from '@ant-design/icons'; -import { cardHover } from '../../animation'; +import { CalendarOutlined, RightOutlined } from '@ant-design/icons'; +import { useNavigate } from 'react-router-dom'; import styles from './activity.module.less'; +import { hoverScale, imageFadeIn } from '../../animation'; -const { Text } = Typography; +const ActivityCard = ({ activity, index }) => { + const navigate = useNavigate(); -const ActivityCard = ({ activity, onClick, layoutId }) => { - const { title, start_time, display_banner_url, banner_url, cover_image, status = '报名中' } = activity; - const imageUrl = display_banner_url || banner_url || cover_image || 'https://via.placeholder.com/600x300'; + const handleCardClick = () => { + navigate(`/activity/${activity.id}`); + }; + + const getStatus = (startTime) => { + const now = new Date(); + const start = new Date(startTime); + if (now < start) return '即将开始'; + // Simple logic, can be enhanced + return '报名中'; + }; + + const formatDate = (dateStr) => { + if (!dateStr) return 'TBD'; + const date = new Date(dateStr); + return date.toLocaleDateString('zh-CN', { month: 'long', day: 'numeric' }); + }; return ( - -
-
- {status} -

{title}

+
+ +
+
+ {activity.status || getStatus(activity.start_time)} +
+

{activity.title}

- - {start_time ? start_time.split('T')[0] : 'TBD'} + + {formatDate(activity.start_time)}
diff --git a/frontend/src/components/activity/ActivityList.jsx b/frontend/src/components/activity/ActivityList.jsx index 93254e4..2e33b6e 100644 --- a/frontend/src/components/activity/ActivityList.jsx +++ b/frontend/src/components/activity/ActivityList.jsx @@ -1,70 +1,103 @@ -import React from 'react'; + +import React, { useState, useEffect } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { Carousel } from 'antd'; -import { useNavigate } from 'react-router-dom'; -import { RightOutlined } from '@ant-design/icons'; +import { motion, AnimatePresence } from 'framer-motion'; +import { RightOutlined, LeftOutlined } from '@ant-design/icons'; import { getActivities } from '../../api'; import ActivityCard from './ActivityCard'; import styles from './activity.module.less'; -import { motion } from 'framer-motion'; -import { fadeInUp } from '../../animation'; +import { fadeInUp, staggerContainer } from '../../animation'; const ActivityList = () => { - const navigate = useNavigate(); - const { data: activities, isLoading } = useQuery({ + const { data: activities, isLoading, error } = useQuery({ queryKey: ['activities'], queryFn: async () => { - try { - const res = await getActivities(); - return Array.isArray(res) ? res : (res?.results || res?.data || []); - } catch (e) { - console.error("Failed to fetch activities", e); - return []; - } + const res = await getActivities(); + // Handle different response structures + return Array.isArray(res.data) ? res.data : (res.data?.results || []); }, - staleTime: 1000 * 60 * 5, // Cache for 5 mins + staleTime: 5 * 60 * 1000, // 5 minutes cache }); - if (isLoading || !activities || activities.length === 0) return null; + const [currentIndex, setCurrentIndex] = useState(0); - const goToDetail = (id) => { - navigate(`/activity/${id}`); + // Auto-play for desktop carousel + useEffect(() => { + if (!activities || activities.length <= 1) return; + const interval = setInterval(() => { + setCurrentIndex((prev) => (prev + 1) % activities.length); + }, 5000); + return () => clearInterval(interval); + }, [activities]); + + const nextSlide = () => { + if (!activities) return; + setCurrentIndex((prev) => (prev + 1) % activities.length); }; + const prevSlide = () => { + if (!activities) return; + setCurrentIndex((prev) => (prev - 1 + activities.length) % activities.length); + }; + + if (isLoading) return
Loading activities...
; + if (error) return null; // Or error state + if (!activities || activities.length === 0) return null; + return ( -
-
近期活动 / EVENTS
-
navigate('/activity/list')}> - MORE - +
+

+ 近期活动 / EVENTS +

+
+ +
- - - {activities.map(item => ( -
- goToDetail(item.id)} - layoutId={`activity-${item.id}`} - /> -
+ + {/* Desktop: Carousel (Show one prominent, but allows list structure if needed) + User said: "Activity only shows one, and in the form of a sliding page" + */} +
+ + + + + + +
+ {activities.map((_, idx) => ( + setCurrentIndex(idx)} + /> + ))} +
+
+ + {/* Mobile: Vertical List/Scroll as requested "Mobile vertical scroll" */} +
+ {activities.map((item, index) => ( + + + ))} - +
); }; diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 40bc055..b9a1a6d 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -1,15 +1,10 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import './index.css' import App from './App.jsx' -const queryClient = new QueryClient() - createRoot(document.getElementById('root')).render( - - - + , ) diff --git a/frontend/src/styles/theme.less b/frontend/src/styles/theme.less deleted file mode 100644 index 55c9736..0000000 --- a/frontend/src/styles/theme.less +++ /dev/null @@ -1,23 +0,0 @@ -@primary-color: #00b96b; -@secondary-color: #00f0ff; -@text-color: #ffffff; -@text-secondary: rgba(255, 255, 255, 0.65); -@bg-color: #000000; -@card-bg: rgba(255, 255, 255, 0.05); -@border-radius-base: 16px; -@box-shadow-base: 0 8px 32px rgba(0, 0, 0, 0.3); -@font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; - -// Mixins -.glass-effect() { - background: rgba(255, 255, 255, 0.05); - backdrop-filter: blur(10px); - -webkit-backdrop-filter: blur(10px); - border: 1px solid rgba(255, 255, 255, 0.1); -} - -.flex-center() { - display: flex; - justify-content: center; - align-items: center; -} diff --git a/frontend/src/theme.module.less b/frontend/src/theme.module.less new file mode 100644 index 0000000..dd7bd4a --- /dev/null +++ b/frontend/src/theme.module.less @@ -0,0 +1,69 @@ + +/* Global Theme Variables */ +:global { + :root { + /* Colors */ + --primary-color: #00b96b; + --secondary-color: #1890ff; + --background-dark: #1f1f1f; + --background-card: #2a2a2a; + --text-primary: #ffffff; + --text-secondary: rgba(255, 255, 255, 0.65); + --border-color: rgba(255, 255, 255, 0.1); + + /* Typography */ + --font-family-base: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + --font-size-base: 14px; + --font-size-lg: 16px; + --font-size-xl: 20px; + + /* Layout */ + --border-radius-base: 8px; + --border-radius-lg: 16px; + + /* Spacing */ + --spacing-xs: 4px; + --spacing-sm: 8px; + --spacing-md: 16px; + --spacing-lg: 24px; + + /* Shadows */ + --box-shadow-base: 0 2px 8px rgba(0, 0, 0, 0.15); + --box-shadow-hover: 0 4px 12px rgba(0, 0, 0, 0.25); + } +} + +/* Mixins (Less Variables for module usage if needed) */ +@primary-color: var(--primary-color); +@secondary-color: var(--secondary-color); +@background-dark: var(--background-dark); +@background-card: var(--background-card); +@text-primary: var(--text-primary); +@text-secondary: var(--text-secondary); +@border-radius-base: var(--border-radius-base); + +.glass-panel { + background: rgba(42, 42, 42, 0.6); + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: var(--border-radius-lg); +} + +.section-title { + font-size: 24px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: var(--spacing-lg); + display: flex; + align-items: center; + gap: var(--spacing-sm); + + &::before { + content: ''; + display: block; + width: 4px; + height: 24px; + background: var(--primary-color); + border-radius: 2px; + } +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 271439f..42ef5d0 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -1,24 +1,10 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' -import viteImagemin from 'vite-plugin-imagemin' +//123 // https://vite.dev/config/ export default defineConfig({ - plugins: [ - react(), - viteImagemin({ - gifsicle: { optimizationLevel: 7, interlaced: false }, - optipng: { optimizationLevel: 7 }, - mozjpeg: { quality: 20 }, - pngquant: { quality: [0.8, 0.9], speed: 4 }, - svgo: { - plugins: [ - { name: 'removeViewBox' }, - { name: 'removeEmptyAttrs', active: false }, - ], - }, - }), - ], + plugins: [react()], server: { host: '0.0.0.0', port: 5173,