diff --git a/package.json b/package.json index 0759eea..3a11cf5 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,6 @@ "postcss": "8.4.49", "react": "18.3.1", "react-dom": "18.3.1", - "react-draggable": "4.4.6", "react-intersection-observer": "9.15.1", "react-router-dom": "7.1.1", "tailwindcss": "3.4.17" diff --git a/src/App.tsx b/src/App.tsx index 23b6cf6..817f5e4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import './index.css'; import { createContext, useState } from 'react'; import { Navigate, Route, Routes } from 'react-router-dom'; +import StoryEditor from './components/story/Editor/StoryEditor'; import { useAuth } from './hooks/useAuth'; import ExplorePage from './pages/ExplorePage'; import FriendMapPage from './pages/FriendMapPage'; @@ -13,6 +14,7 @@ import PostDetailPage from './pages/PostDetailPage'; import ProfileEditPage from './pages/ProfileEditPage'; import ProfilePage from './pages/ProfilePage'; import RegisterPage from './pages/RegisterPage'; +import StoryPage from './pages/StoryPage'; import type { LoginContextType } from './types/auth'; import type { SearchContextType } from './types/search'; @@ -59,6 +61,26 @@ export const App = () => { path="/:username" element={auth.isLoggedIn ? : } /> + + ) : ( + + ) + } + /> + + ) : ( + + ) + } + /> { }, ); - // First check if we got a JSON response - const contentType = response.headers.get('content-type'); - if (contentType?.includes('application/json') === false) { - const text = await response.text(); - console.error('Non-JSON response:', text); - throw new Error('Server returned non-JSON response'); - } - if (response.ok) { - try { - const data = (await response.json()) as SignInResponse; - localStorage.setItem('access_token', data.access_token); - localStorage.setItem('refresh_token', data.refresh_token); - const profileResponse = await myProfile(data.access_token); - if (profileResponse === null) { - throw new Error('Failed to fetch user profile'); - } - return profileResponse; - } catch (err) { - console.error('Error parsing JSON response:', err); - throw new Error('Invalid response format from server'); - } + const data = (await response.json()) as SignInResponse; + localStorage.setItem('access_token', data.access_token); + localStorage.setItem('refresh_token', data.refresh_token); + + return await myProfile(data.access_token); } if (response.status === 401) { diff --git a/src/components/feed/Stories.tsx b/src/components/feed/Stories.tsx index 4020bb4..9c1dd85 100644 --- a/src/components/feed/Stories.tsx +++ b/src/components/feed/Stories.tsx @@ -1,4 +1,4 @@ -import { StoryList } from '../story/list/StoryList'; +import { StoryList } from '../story/StoryList'; export function Stories() { return ; diff --git a/src/components/layout/SideBar.tsx b/src/components/layout/SideBar.tsx index bd7bdce..3ddfe63 100644 --- a/src/components/layout/SideBar.tsx +++ b/src/components/layout/SideBar.tsx @@ -12,7 +12,6 @@ import { useContext, useEffect, useState } from 'react'; import { Link, useLocation } from 'react-router-dom'; import { LoginContext } from '../../App'; -import CreatePostModal from '../modals/CreatePostModal'; import { NavItem } from './NavItem'; interface SideBarProps { @@ -22,7 +21,7 @@ interface SideBarProps { const SideBar = ({ onSearchClick }: SideBarProps) => { const [isMenuOpen, setIsMenuOpen] = useState(false); const [activeItem, setActiveItem] = useState('home'); - const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [, setIsCreateModalOpen] = useState(false); const location = useLocation(); const context = useContext(LoginContext); @@ -101,14 +100,6 @@ const SideBar = ({ onSearchClick }: SideBarProps) => { handleCreateClick('create'); }} /> - {isCreateModalOpen && ( - { - setIsCreateModalOpen(false); - }} - /> - )} } diff --git a/src/components/shared/default-profile.svg b/src/components/shared/default-profile.svg new file mode 100644 index 0000000..106bc3d --- /dev/null +++ b/src/components/shared/default-profile.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/components/story/Editor/ImageProcessor.tsx b/src/components/story/Editor/ImageProcessor.tsx new file mode 100644 index 0000000..470c443 --- /dev/null +++ b/src/components/story/Editor/ImageProcessor.tsx @@ -0,0 +1,156 @@ +import { useEffect, useRef, useState } from 'react'; + +type Color = { + r: number; + g: number; + b: number; +}; + +const ImageProcessor = ({ + file, + onProcessed, +}: { + file: File | null; + onProcessed: (blob: Blob) => void; +}) => { + const [processedImage, setProcessedImage] = useState(null); + const [hasProcessed, setHasProcessed] = useState(false); + const canvasRef = useRef(null); + + const TARGET_WIDTH = 1080; + const TARGET_HEIGHT = 1920; + const TARGET_RATIO = TARGET_HEIGHT / TARGET_WIDTH; + + const extractColors = ( + ctx: CanvasRenderingContext2D | null, + width: number, + height: number, + ): [Color, Color] => { + if (ctx == null) + return [ + { r: 0, g: 0, b: 0 }, + { r: 0, g: 0, b: 0 }, + ]; + const imageData = ctx.getImageData(0, 0, width, height).data; + const colors: Color[] = []; + + // Sample pixels at regular intervals + const sampleSize = Math.floor(imageData.length / 1000); + for (let i = 0; i < imageData.length; i += sampleSize * 4) { + const r = imageData[i] ?? 0; + const g = imageData[i + 1] ?? 0; + const b = imageData[i + 2] ?? 0; + colors.push({ r, g, b }); + } + + // Get average color for muted background + const avg: Color = colors.reduce( + (acc, color) => ({ + r: acc.r + color.r / colors.length, + g: acc.g + color.g / colors.length, + b: acc.b + color.b / colors.length, + }), + { r: 0, g: 0, b: 0 }, + ); + + // Create slightly varied second color for gradient + const secondColor: Color = { + r: Math.min(255, avg.r * 0.8), + g: Math.min(255, avg.g * 0.8), + b: Math.min(255, avg.b * 0.8), + }; + + return [avg, secondColor]; + }; + + useEffect(() => { + const processImage = async (imageFile: Blob | MediaSource) => { + if (hasProcessed) return; + const img = new Image(); + img.src = URL.createObjectURL(imageFile); + + await new Promise((resolve) => { + img.onload = resolve; + }); + + const canvas = canvasRef.current as HTMLCanvasElement | null; + if (canvas == null) return; + const ctx = canvas.getContext('2d'); + if (ctx == null) return; + + // Set canvas to target dimensions + canvas.width = TARGET_WIDTH; + canvas.height = TARGET_HEIGHT; + + // Extract colors from original image + const tempCanvas = document.createElement('canvas'); + const tempCtx = tempCanvas.getContext('2d'); + if (tempCtx == null) return; + tempCanvas.width = img.width; + tempCanvas.height = img.height; + tempCtx.drawImage(img, 0, 0); + const [color1, color2] = extractColors(tempCtx, img.width, img.height); + + // Create gradient background + const gradient = ctx.createLinearGradient(0, 0, 0, TARGET_HEIGHT); + gradient.addColorStop( + 0, + `rgba(${color1.r}, ${color1.g}, ${color1.b}, 0.8)`, + ); + gradient.addColorStop( + 1, + `rgba(${color2.r}, ${color2.g}, ${color2.b}, 0.8)`, + ); + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, TARGET_WIDTH, TARGET_HEIGHT); + + // Calculate dimensions to maintain aspect ratio + let drawWidth = TARGET_WIDTH; + let drawHeight = TARGET_HEIGHT; + const imageRatio = img.height / img.width; + + if (imageRatio > TARGET_RATIO) { + drawWidth = TARGET_HEIGHT / imageRatio; + drawHeight = TARGET_HEIGHT; + } else { + drawWidth = TARGET_WIDTH; + drawHeight = TARGET_WIDTH * imageRatio; + } + + // Center the image + const x = (TARGET_WIDTH - drawWidth) / 2; + const y = (TARGET_HEIGHT - drawHeight) / 2; + + // Draw the image centered + ctx.drawImage(img, x, y, drawWidth, drawHeight); + + // Convert to blob and create URL + const blob = await new Promise((resolve) => { + canvas.toBlob(resolve, 'image/jpeg', 0.9); + }); + if (blob == null) return; + const processedUrl = URL.createObjectURL(blob); + setProcessedImage(processedUrl); + setHasProcessed(true); + onProcessed(blob); + }; + if (file != null && !hasProcessed) { + void processImage(file); + } + }, [TARGET_RATIO, file, hasProcessed, onProcessed, processedImage]); + + return ( +
+ + {processedImage != null && ( + Processed story + )} +
+ ); +}; + +export default ImageProcessor; diff --git a/src/components/story/Editor/StoryEditor.tsx b/src/components/story/Editor/StoryEditor.tsx new file mode 100644 index 0000000..23f8f7d --- /dev/null +++ b/src/components/story/Editor/StoryEditor.tsx @@ -0,0 +1,92 @@ +import { useCallback, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; + +import ImageProcessor from './ImageProcessor'; + +const StoryEditor = () => { + const [isProcessing, setIsProcessing] = useState(false); + const [error, setError] = useState(null); + const [isUploaded, setIsUploaded] = useState(false); + const [processedBlob, setProcessedBlob] = useState(null); + const navigate = useNavigate(); + const location = useLocation(); + const state = location.state as { file: File }; + + const handleProcessed = useCallback((blob: Blob) => { + setProcessedBlob(blob); + }, []); + + const handleShare = async () => { + if (isProcessing || isUploaded || processedBlob == null) return; + + setIsProcessing(true); + setError(null); + + try { + const formData = new FormData(); + formData.append('files', processedBlob, 'story.jpg'); + + const response = await fetch( + 'https://waffle-instaclone.kro.kr/api/story/', + { + method: 'POST', + headers: { + Authorization: `Bearer ${localStorage.getItem('access_token') ?? ''}`, + }, + body: formData, + }, + ); + + if (!response.ok) { + throw new Error('Failed to upload story'); + } + + setIsUploaded(true); + void navigate('/', { replace: true }); + } catch (err) { + setError( + err instanceof Error ? err.message : 'An unknown error occurred', + ); + } finally { + setIsProcessing(false); + } + }; + + return ( +
+
+

Edit Story

+ + + + {isProcessing && ( +
+ Processing and uploading your story... +
+ )} + + {error != null && ( +
{error}
+ )} + +
+ + +
+
+
+ ); +}; + +export default StoryEditor; diff --git a/src/components/story/StoryCreator.tsx b/src/components/story/StoryCreator.tsx new file mode 100644 index 0000000..f8fb7e3 --- /dev/null +++ b/src/components/story/StoryCreator.tsx @@ -0,0 +1,43 @@ +import { CirclePlus } from 'lucide-react'; +import { useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { useStoryUpload } from '../../hooks/useStoryUpload'; + +export function StoryCreator() { + const navigate = useNavigate(); + const { isUploading } = useStoryUpload(); + + const handleFileUpload = useCallback( + (event: React.ChangeEvent) => { + const files = event.target.files; + if (files == null) return; + + if (files[0] != null) { + void navigate('/stories/new', { + state: { file: files[0] }, + }); + } + }, + [navigate], + ); + + return ( +
+ +

Create Story

+
+ ); +} diff --git a/src/components/story/StoryItem.tsx b/src/components/story/StoryItem.tsx new file mode 100644 index 0000000..e96c068 --- /dev/null +++ b/src/components/story/StoryItem.tsx @@ -0,0 +1,73 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import type { Story } from '../../types/story'; + +interface StoryItemProps { + username: string; + profileImage?: string; + stories: Story[]; + onView: () => void; +} + +export function StoryItem({ + username, + profileImage, + stories, + onView, +}: StoryItemProps) { + const navigate = useNavigate(); + const [hasUnviewedStories, setHasUnviewedStories] = useState(false); + useEffect(() => { + const checkUnviewed = () => { + const hasUnviewed = stories.some((story) => { + const viewedAt = localStorage.getItem(`story-${story.story_id}-viewed`); + return viewedAt == null; + }); + setHasUnviewedStories(hasUnviewed); + }; + checkUnviewed(); + }, [stories]); + const handleClick = () => { + if (stories.length > 0) { + // Find first unviewed story + const firstUnviewed = stories.find((story) => { + const viewedAt = localStorage.getItem(`story-${story.story_id}-viewed`); + return viewedAt == null; + }); + + // If all stories are viewed, show the first story + const storyToShow = firstUnviewed ?? stories[0]; + + if (storyToShow != null) { + void navigate(`/stories/${username}/${storyToShow.story_id}`); + onView(); + } + } + }; + + return ( + + ); +} diff --git a/src/components/story/StoryList.tsx b/src/components/story/StoryList.tsx new file mode 100644 index 0000000..0b93489 --- /dev/null +++ b/src/components/story/StoryList.tsx @@ -0,0 +1,216 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import type { Story } from '../../types/story'; +import type { UserProfile } from '../../types/user'; +import { StoryCreator } from './StoryCreator'; +import { StoryItem } from './StoryItem'; +import StoryViewer from './StoryViewer/StoryViewer'; + +const API_BASE = 'https://waffle-instaclone.kro.kr'; + +export function StoryList() { + const navigate = useNavigate(); + const [selectedUserId, setSelectedUserId] = useState(null); + const [viewingStories, setViewingStories] = useState([]); + const [currentUserId, setCurrentUserId] = useState(null); + const [stories, setStories] = useState([]); + const [userProfiles, setUserProfiles] = useState>( + {}, + ); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Fetch user profile for a specific user ID + const fetchUserProfile = async (userId: number) => { + try { + const response = await fetch(`${API_BASE}/api/user/profile`, { + headers: { + Authorization: `Bearer ${localStorage.getItem('access_token') ?? ''}`, + }, + }); + + if (!response.ok) { + throw new Error('Failed to fetch user profile'); + } + + const userData = (await response.json()) as UserProfile; + setUserProfiles((prev) => ({ + ...prev, + [userId]: userData, + })); + } catch (err) { + console.error(`Error fetching profile for user ${userId}:`, err); + } + }; + + // Fetch current user profile + useEffect(() => { + // Fetch stories and associated user profiles + const fetchStories = async (userId: number) => { + try { + const token = localStorage.getItem('access_token'); + if (token == null) return; + + const response = await fetch(`${API_BASE}/api/story/list/${userId}`, { + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + }, + }); + + if (!response.ok) { + const errorData = (await response.json()) as { detail: string }; + throw new Error( + errorData.detail !== '' + ? errorData.detail + : 'Failed to fetch stories', + ); + } + + const data = (await response.json()) as Story[]; + setStories(data); + + // Fetch user profiles for all unique user IDs in stories + const uniqueUserIds = [...new Set(data.map((story) => story.user_id))]; + await Promise.all(uniqueUserIds.map((uid) => fetchUserProfile(uid))); + + setError(null); + } catch (err) { + console.error('Story fetch error:', err); + setError( + err instanceof Error ? err.message : 'Failed to fetch stories', + ); + } + }; + const fetchUserInfo = async () => { + try { + setLoading(true); + const token = localStorage.getItem('access_token'); + + if (token == null) { + localStorage.removeItem('isLoggedIn'); + void navigate('/'); + return; + } + + const response = await fetch(`${API_BASE}/api/user/profile`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + if (response.status === 401) { + localStorage.removeItem('access_token'); + localStorage.removeItem('isLoggedIn'); + void navigate('/'); + return; + } + throw new Error('Failed to fetch user info'); + } + + const userData = (await response.json()) as UserProfile; + if (userData?.user_id != null) { + setCurrentUserId(userData.user_id); + void fetchStories(userData.user_id); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error occurred'); + console.error('Error fetching user info:', err); + } finally { + setLoading(false); + } + }; + + void fetchUserInfo(); + }, [navigate]); + + const deleteStory = async (storyId: number) => { + try { + const response = await fetch(`${API_BASE}/api/story/${storyId}`, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${localStorage.getItem('access_token') ?? ''}`, + }, + }); + + if (!response.ok) { + throw new Error('Failed to delete story'); + } + + setStories(stories.filter((story) => story.story_id !== storyId)); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to delete story'); + throw err; + } + }; + + const handleViewStory = (userId: number, userStories: Story[]) => { + setSelectedUserId(userId); + setViewingStories(userStories); + }; + + const handleCloseViewer = () => { + setSelectedUserId(null); + setViewingStories([]); + }; + + if (error != null) { + return
{error}
; + } + + const storyGroups = stories.reduce>((acc, story) => { + if (acc[story.user_id] == null) { + acc[story.user_id] = []; + } + acc[story.user_id]?.push(story); + return acc; + }, {}); + + return ( +
+ + + {loading ? ( +
+
+
+
+
+ ) : ( + <> + {Object.entries(storyGroups).map(([userId, userStories]) => { + const userProfile = userProfiles[Number(userId)]; + return ( + { + handleViewStory(Number(userId), userStories); + }} + /> + ); + })} + + )} + + {viewingStories.length > 0 && ( + + )} +
+ ); +} diff --git a/src/components/story/StoryViewer/StoryControls.tsx b/src/components/story/StoryViewer/StoryControls.tsx new file mode 100644 index 0000000..036a6f0 --- /dev/null +++ b/src/components/story/StoryViewer/StoryControls.tsx @@ -0,0 +1,69 @@ +import { ChevronLeft, ChevronRight, Trash2, X } from 'lucide-react'; + +interface StoryControlsProps { + onNext: () => void; + onPrevious: () => void; + onClose: () => void; + onDelete?: () => void; + canGoNext: boolean; + canGoPrevious: boolean; + isOwner: boolean; +} + +export default function StoryControls({ + onNext, + onPrevious, + onClose, + onDelete, + canGoNext, + canGoPrevious, + isOwner, +}: StoryControlsProps) { + return ( +
+
+ + + + + + + {isOwner && onDelete != null && ( + + )} +
+
+ ); +} diff --git a/src/components/story/StoryViewer/StoryProgress.tsx b/src/components/story/StoryViewer/StoryProgress.tsx new file mode 100644 index 0000000..73b906c --- /dev/null +++ b/src/components/story/StoryViewer/StoryProgress.tsx @@ -0,0 +1,17 @@ +interface StoryProgressProps { + duration: number; + currentTime: number; +} + +export function StoryProgress({ duration, currentTime }: StoryProgressProps) { + const progress = (currentTime / duration) * 100; + + return ( +
+
+
+ ); +} diff --git a/src/components/story/StoryViewer/StoryUserInfo.tsx b/src/components/story/StoryViewer/StoryUserInfo.tsx new file mode 100644 index 0000000..f90a67a --- /dev/null +++ b/src/components/story/StoryViewer/StoryUserInfo.tsx @@ -0,0 +1,56 @@ +const formatTimeAgo = (dateString: string): string => { + const date = new Date(dateString); + const now = new Date(); + const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000); + + if (diffInSeconds < 60) { + return 'just now'; + } else if (diffInSeconds < 3600) { + const minutes = Math.floor(diffInSeconds / 60); + return `${minutes}m ago`; + } else if (diffInSeconds < 86400) { + const hours = Math.floor(diffInSeconds / 3600); + return `${hours}h ago`; + } else { + const days = Math.floor(diffInSeconds / 86400); + return `${days}d ago`; + } +}; + +interface StoryUserInfoProps { + username: string; + profileImage?: string; + creationDate: string; +} + +const StoryUserInfo = ({ + username, + profileImage, + creationDate, +}: StoryUserInfoProps) => { + return ( +
+
+
+ {username} { + const img = e.target as HTMLImageElement; + img.src = '/default-profile.svg'; + }} + /> +
+
+ {username} + + {formatTimeAgo(creationDate)} + +
+
+
+ ); +}; + +export default StoryUserInfo; diff --git a/src/components/story/StoryViewer/StoryViewer.tsx b/src/components/story/StoryViewer/StoryViewer.tsx new file mode 100644 index 0000000..7a15afd --- /dev/null +++ b/src/components/story/StoryViewer/StoryViewer.tsx @@ -0,0 +1,158 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import StoryControls from './StoryControls'; +import { StoryProgress } from './StoryProgress'; +import StoryUserInfo from './StoryUserInfo'; + +interface StoryViewerProps { + stories: Array<{ + story_id: number; + file_url: string[]; + creation_date: string; + user_id: number; + }>; + username: string; + profileImage?: string; + onClose: () => void; + onDelete?: (storyId: number) => Promise; + isOwner?: boolean; + initialIndex: number; +} + +const API_BASE_URL = 'https://waffle-instaclone.kro.kr'; + +export default function StoryViewer({ + stories, + username, + profileImage, + onClose, + onDelete, + isOwner = false, + initialIndex, +}: StoryViewerProps) { + const navigate = useNavigate(); + const [currentStoryIndex, setCurrentStoryIndex] = useState(initialIndex); + const [progress, setProgress] = useState(0); + const STORY_DURATION = 5000; + + useEffect(() => { + const currentStory = stories[currentStoryIndex]; + if (currentStory != null) { + localStorage.setItem( + `story-${currentStory.story_id}-viewed`, + new Date().toISOString(), + ); + } + const timer = setInterval(() => { + setProgress((prev) => { + if (prev >= STORY_DURATION) { + if (currentStoryIndex < stories.length - 1) { + setCurrentStoryIndex((index) => index + 1); + return 0; + } else { + onClose(); + return prev; + } + } + return prev + 100; + }); + }, 100); + + return () => { + clearInterval(timer); + }; + }, [currentStoryIndex, stories, onClose]); + + const handleDelete = async (storyId: number) => { + try { + if (onDelete != null) { + await onDelete(storyId); + void navigate('/', { replace: true }); + } + } catch (error) { + console.error('Failed to delete story:', error); + } + }; + + const handleNext = () => { + if (currentStoryIndex < stories.length - 1) { + setCurrentStoryIndex((prev) => prev + 1); + setProgress(0); + const nextStory = stories[currentStoryIndex + 1]; + if (nextStory != null) { + void navigate(`/stories/${username}/${nextStory.story_id}`, { + replace: true, + }); + } + } else { + onClose(); + } + }; + + const handlePrevious = () => { + if (currentStoryIndex > 0) { + setCurrentStoryIndex((prev) => prev - 1); + setProgress(0); + const prevStory = stories[currentStoryIndex - 1]; + if (prevStory != null) { + void navigate(`/stories/${username}/${prevStory.story_id}`, { + replace: true, + }); + } + } + }; + + const currentStory = stories[currentStoryIndex]; + if (currentStory == null) return null; + + // Convert relative URL to absolute URL + const getFullImageUrl = (url: string) => { + if (url.startsWith('http')) return url; + return `${API_BASE_URL}/${url.replace(/^\/+/, '')}`; + }; + + return ( +
+
+ + +
+ {`Story { + console.error('Image failed to load:', e); + const img = e.target as HTMLImageElement; + img.alt = 'Failed to load story image'; + }} + /> +
+ void handleDelete(currentStory.story_id) + : undefined + } + canGoNext={currentStoryIndex < stories.length - 1} + canGoPrevious={currentStoryIndex > 0} + isOwner={isOwner} + /> +
+
+
+
+ ); +} diff --git a/src/components/story/creation/StoryCreator.tsx b/src/components/story/creation/StoryCreator.tsx deleted file mode 100644 index c7e058f..0000000 --- a/src/components/story/creation/StoryCreator.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { CirclePlus } from 'lucide-react'; -import { useState } from 'react'; - -import { useStoryCreation } from '../../../hooks/story/useStoryCreation'; -import { useStoryUpload } from '../../../hooks/story/useStoryUpload'; -import { StoryEditor } from './StoryEditor'; - -interface StoryCreatorProps { - onFileSelect: (file: File) => Promise; -} - -export function StoryCreator({ onFileSelect }: StoryCreatorProps) { - const [showEditor, setShowEditor] = useState(false); - const { isUploading, uploadStory } = useStoryUpload(); - const { processMedia } = useStoryCreation(); - - const handleStorySubmit = async (file: File) => { - try { - // First process the media file - await processMedia(file); - - // Create a FileList-like object - const dataTransfer = new DataTransfer(); - dataTransfer.items.add(file); - const fileList = dataTransfer.files; - - // Upload the processed story - await uploadStory(fileList); - setShowEditor(false); - - // Trigger the file select callback - await onFileSelect(file); - - window.location.reload(); - } catch (error) { - console.error('Error uploading story:', error); - alert(error instanceof Error ? error.message : 'Failed to upload story'); - } - }; - - return ( - <> -
- -
- - {showEditor && ( - { - setShowEditor(false); - }} - onSubmit={handleStorySubmit} - /> - )} - - ); -} diff --git a/src/components/story/creation/StoryEditor/CanvasManager.tsx b/src/components/story/creation/StoryEditor/CanvasManager.tsx deleted file mode 100644 index 50153a9..0000000 --- a/src/components/story/creation/StoryEditor/CanvasManager.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import type { TextStyle } from '../../shared/types'; - -const CANVAS_MULTIPLIER = 2; // For higher resolution rendering - -export class CanvasManager { - private canvas: HTMLCanvasElement; - private ctx: CanvasRenderingContext2D; - private scale: number; - - constructor(canvas: HTMLCanvasElement) { - this.canvas = canvas; - const ctx = canvas.getContext('2d'); - if (ctx == null) throw new Error('Failed to get canvas context'); - this.ctx = ctx; - this.scale = 1; - - // Set up high-DPI canvas - const dpr = window.devicePixelRatio; - this.canvas.width = this.canvas.offsetWidth * dpr * CANVAS_MULTIPLIER; - this.canvas.height = this.canvas.offsetHeight * dpr * CANVAS_MULTIPLIER; - this.ctx.scale(dpr * CANVAS_MULTIPLIER, dpr * CANVAS_MULTIPLIER); - - // Set canvas CSS size - this.canvas.style.width = `${this.canvas.offsetWidth}px`; - this.canvas.style.height = `${this.canvas.offsetHeight}px`; - } - - drawImage(img: HTMLImageElement) { - // Calculate scaling to fit image while maintaining aspect ratio - const imgAspect = img.width / img.height; - const canvasAspect = this.canvas.width / this.canvas.height; - let drawWidth = this.canvas.width; - let drawHeight = this.canvas.height; - - if (imgAspect > canvasAspect) { - drawHeight = drawWidth / imgAspect; - } else { - drawWidth = drawHeight * imgAspect; - } - - // Center the image - const x = (this.canvas.width - drawWidth) / 2; - const y = (this.canvas.height - drawHeight) / 2; - - // Create blurred background - this.ctx.save(); - this.ctx.filter = 'blur(20px)'; - this.ctx.drawImage( - img, - -10, - -10, - this.canvas.width + 20, - this.canvas.height + 20, - ); - this.ctx.filter = 'none'; - - // Draw main image - this.ctx.drawImage(img, x, y, drawWidth, drawHeight); - this.ctx.restore(); - - // Store scale for text positioning - this.scale = drawWidth / img.width; - } - - addText(text: string, x: number, y: number, style: TextStyle) { - const scaledFontSize = style.fontSize * this.scale; - this.ctx.font = `${scaledFontSize}px ${style.fontFamily}`; - this.ctx.fillStyle = style.color; - this.ctx.textAlign = 'center'; - this.ctx.textBaseline = 'middle'; - - // Add text shadow - this.ctx.shadowColor = 'rgba(0,0,0,0.5)'; - this.ctx.shadowBlur = 4; - this.ctx.shadowOffsetX = 2; - this.ctx.shadowOffsetY = 2; - - if (style.backgroundColor != null) { - const metrics = this.ctx.measureText(text); - const padding = scaledFontSize * 0.2; - this.ctx.fillStyle = style.backgroundColor; - this.ctx.fillRect( - x - metrics.width / 2 - padding, - y - scaledFontSize / 2 - padding, - metrics.width + padding * 2, - scaledFontSize + padding * 2, - ); - } - - this.ctx.fillStyle = style.color; - this.ctx.fillText(text, x, y); - this.ctx.shadowColor = 'transparent'; - } - - getScale() { - return this.scale; - } -} diff --git a/src/components/story/creation/StoryEditor/Controls.tsx b/src/components/story/creation/StoryEditor/Controls.tsx deleted file mode 100644 index 1977bea..0000000 --- a/src/components/story/creation/StoryEditor/Controls.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { Music, Palette, PenTool, Sticker, Type } from 'lucide-react'; -import { useState } from 'react'; - -interface ControlsProps { - activeTab: string; - onTabChange: (tab: string) => void; -} - -export const Controls: React.FC = ({ - activeTab, - onTabChange, -}) => { - const [showTooltip, setShowTooltip] = useState(false); - const [tooltipMessage, setTooltipMessage] = useState(''); - - const handleFeatureClick = (id: string) => { - if (id === 'stickers' || id === 'music') { - setTooltipMessage( - `${id === 'stickers' ? 'Stickers' : 'Music'} feature coming soon!`, - ); - setShowTooltip(true); - setTimeout(() => { - setShowTooltip(false); - }, 2000); - return; - } - onTabChange(id); - }; - const controls = [ - { id: 'draw', Icon: PenTool }, - { id: 'text', Icon: Type }, - { id: 'stickers', Icon: Sticker }, - { id: 'music', Icon: Music }, - { id: 'filters', Icon: Palette }, - ]; - - return ( -
- {controls.map(({ id, Icon }) => ( - - ))} - {showTooltip && ( -
- {tooltipMessage} -
- )} -
- ); -}; diff --git a/src/components/story/creation/StoryEditor/DrawingTool.tsx b/src/components/story/creation/StoryEditor/DrawingTool.tsx deleted file mode 100644 index a093551..0000000 --- a/src/components/story/creation/StoryEditor/DrawingTool.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import React, { useEffect, useRef, useState } from 'react'; - -interface DrawingToolProps { - onDrawingComplete: (canvas: HTMLCanvasElement) => void; - width: number; - height: number; -} - -export const DrawingTool: React.FC = ({ - onDrawingComplete, - width, - height, -}) => { - const canvasRef = useRef(null); - const [isDrawing, setIsDrawing] = useState(false); - const [color, setColor] = useState('#ffffff'); - const [lineWidth, setLineWidth] = useState(5); - const lastPos = useRef({ x: 0, y: 0 }); - - useEffect(() => { - const canvas = canvasRef.current; - if (canvas == null) return; - - const ctx = canvas.getContext('2d'); - if (ctx == null) return; - - // Set up canvas - canvas.width = width; - canvas.height = height; - ctx.lineCap = 'round'; - ctx.lineJoin = 'round'; - }, [width, height]); - - const startDrawing = (e: React.MouseEvent | React.TouchEvent) => { - setIsDrawing(true); - const pos = getEventPosition(e); - lastPos.current = pos; - - const ctx = canvasRef.current?.getContext('2d'); - if (ctx == null) return; - - ctx.beginPath(); - ctx.strokeStyle = color; - ctx.lineWidth = lineWidth; - ctx.moveTo(pos.x, pos.y); - }; - - const draw = (e: React.MouseEvent | React.TouchEvent) => { - if (!isDrawing) return; - - const ctx = canvasRef.current?.getContext('2d'); - if (ctx == null) return; - - const pos = getEventPosition(e); - - ctx.beginPath(); - ctx.moveTo(lastPos.current.x, lastPos.current.y); - ctx.lineTo(pos.x, pos.y); - ctx.stroke(); - - lastPos.current = pos; - }; - - const stopDrawing = () => { - setIsDrawing(false); - if (canvasRef.current != null) { - onDrawingComplete(canvasRef.current); - } - }; - - const getEventPosition = (e: React.MouseEvent | React.TouchEvent) => { - const canvas = canvasRef.current; - if (canvas == null) return { x: 0, y: 0 }; - - const rect = canvas.getBoundingClientRect(); - const x = - (e as React.TouchEvent).touches[0]?.clientX ?? - (e as React.MouseEvent).clientX - rect.left; - const y = - (e as React.TouchEvent).touches[0]?.clientY ?? - (e as React.MouseEvent).clientY - rect.top; - - return { - x: (x * canvas.width) / rect.width, - y: (y * canvas.height) / rect.height, - }; - }; - - return ( -
- - -
- { - setColor(e.target.value); - }} - className="w-10 h-10 rounded cursor-pointer" - /> - { - setLineWidth(Number(e.target.value)); - }} - className="w-32" - /> -
-
-
-
-
- ); -}; diff --git a/src/components/story/creation/StoryEditor/Filters.tsx b/src/components/story/creation/StoryEditor/Filters.tsx deleted file mode 100644 index 2792ab9..0000000 --- a/src/components/story/creation/StoryEditor/Filters.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import React from 'react'; - -const FILTERS = [ - { id: 'normal', name: 'Normal', filter: '' }, - { id: 'grayscale', name: 'B&W', filter: 'grayscale(100%)' }, - { id: 'sepia', name: 'Sepia', filter: 'sepia(100%)' }, - { id: 'saturate', name: 'Vivid', filter: 'saturate(200%)' }, - { id: 'contrast', name: 'Contrast', filter: 'contrast(150%)' }, - { id: 'brightness', name: 'Bright', filter: 'brightness(150%)' }, - { id: 'blur', name: 'Blur', filter: 'blur(5px)' }, - { id: 'warm', name: 'Warm', filter: 'sepia(50%) saturate(150%)' }, - { id: 'cool', name: 'Cool', filter: 'hue-rotate(180deg) saturate(120%)' }, -] as const; - -interface FiltersProps { - onFilterSelect: (filter: string) => void; - currentFilter: string; - previewUrl: string; -} - -export const Filters: React.FC = ({ - onFilterSelect, - currentFilter, - previewUrl, -}) => { - return ( -
-
-
- {FILTERS.map(({ id, name, filter }) => ( - - ))} -
-
-
- ); -}; diff --git a/src/components/story/creation/StoryEditor/TextEditor.tsx b/src/components/story/creation/StoryEditor/TextEditor.tsx deleted file mode 100644 index 5ad00e8..0000000 --- a/src/components/story/creation/StoryEditor/TextEditor.tsx +++ /dev/null @@ -1,177 +0,0 @@ -import React, { useState } from 'react'; -import Draggable, { - type DraggableData, - type DraggableEvent, -} from 'react-draggable'; - -interface TextLayer { - id: string; - text: string; - x: number; - y: number; - style: TextStyle; -} - -interface TextStyle { - fontSize: number; - color: string; - backgroundColor: string | null; - fontFamily: string; -} - -interface TextEditorProps { - onTextAdd: (text: string, style: TextStyle, x: number, y: number) => void; - onTextUpdate: ( - id: string, - text: string, - style: TextStyle, - x: number, - y: number, - ) => void; - onTextDelete: (id: string) => void; - layers: TextLayer[]; -} - -export const TextEditor: React.FC = ({ - onTextAdd, - onTextUpdate, - onTextDelete, - layers, -}) => { - const [text, setText] = useState(''); - const [editingLayer, setEditingLayer] = useState(null); - const [style, setStyle] = useState({ - fontSize: 32, - color: '#ffffff', - backgroundColor: null, - fontFamily: 'Arial', - }); - - const handleAddText = () => { - if (text.length === 0) return; - onTextAdd(text, style, window.innerWidth / 2, window.innerHeight / 2); - setText(''); - }; - - const handleLayerClick = (layer: TextLayer) => { - setEditingLayer(layer.id); - setText(layer.text); - setStyle(layer.style); - }; - - const handleDragStop = ( - id: string, - _e: DraggableEvent, - data: DraggableData, - ) => { - const layer = layers.find((l) => l.id === id); - if (layer != null) { - onTextUpdate(id, layer.text, layer.style, data.x, data.y); - } - }; - - return ( -
- {/* Text Layers */} - {layers.map((layer) => ( - { - handleDragStop(layer.id, e, data); - }} - > -
{ - handleLayerClick(layer); - }} - > -
- {layer.text} -
- {editingLayer === layer.id && ( -
- -
- )} -
-
- ))} - - {/* Text Input Controls */} -
-
- { - setText(e.target.value); - }} - placeholder="Type something..." - className="w-full p-2 bg-transparent border border-white/30 rounded text-white text-center mb-4" - style={{ - fontSize: `${style.fontSize}px`, - fontFamily: style.fontFamily, - }} - /> - -
- - - { - setStyle({ ...style, color: e.target.value }); - }} - className="w-10 h-10 rounded cursor-pointer" - /> - -
-
-
-
- ); -}; diff --git a/src/components/story/creation/StoryEditor/index.tsx b/src/components/story/creation/StoryEditor/index.tsx deleted file mode 100644 index 80d7329..0000000 --- a/src/components/story/creation/StoryEditor/index.tsx +++ /dev/null @@ -1,398 +0,0 @@ -import { Image, X } from 'lucide-react'; -import React, { useRef, useState } from 'react'; - -import { STORY_CONSTANTS } from '../../shared/constants'; -import type { StoryEditorProps } from '../../shared/types'; -import { - processImage, - processVideo, - validateStoryMedia, -} from '../../shared/utils'; -import { CanvasManager } from './CanvasManager'; -import { Controls } from './Controls'; -import { DrawingTool } from './DrawingTool'; -import { Filters } from './Filters'; -import { TextEditor } from './TextEditor'; - -interface TextLayer { - id: string; - text: string; - x: number; - y: number; - style: { - fontSize: number; - color: string; - backgroundColor: string | null; - fontFamily: string; - }; -} - -interface MediaState { - url: string; - type: 'image' | 'video'; - file: File; - videoElement?: HTMLVideoElement; -} - -export const StoryEditor: React.FC = ({ - onClose, - onSubmit, -}) => { - const [media, setMedia] = useState(null); - const [activeTab, setActiveTab] = useState('text'); - const [currentFilter, setCurrentFilter] = useState(''); - const canvasRef = useRef(null); - const canvasManagerRef = useRef(null); - const [textLayers, setTextLayers] = useState([]); - const [drawingLayer, setDrawingLayer] = useState( - null, - ); - const [isProcessing, setIsProcessing] = useState(false); - - const handleFileUpload = async ( - event: React.ChangeEvent, - ) => { - const file = event.target.files?.[0]; - if (file == null) return; - - try { - setIsProcessing(true); - await validateStoryMedia(file); - const isVideo = file.type.startsWith('video/'); - - const processed = isVideo - ? await processVideo(file) - : await processImage(file); - - if (isVideo) { - const videoElement = document.createElement('video'); - videoElement.src = processed; - videoElement.loop = true; - videoElement.muted = true; - videoElement.playsInline = true; - - // Wait for video to be loaded - await new Promise((resolve) => { - videoElement.onloadedmetadata = () => { - resolve(); - }; - videoElement.load(); - }); - - setMedia({ - url: processed, - type: 'video', - file, - videoElement, - }); - } else { - // Handle image - const img = document.createElement('img'); - img.onload = () => { - if (canvasRef.current != null) { - canvasManagerRef.current = new CanvasManager(canvasRef.current); - canvasManagerRef.current.drawImage(img); - } - }; - img.src = processed; - setMedia({ - url: processed, - type: 'image', - file, - }); - } - } catch (error) { - alert(error instanceof Error ? error.message : 'Failed to process media'); - } finally { - setIsProcessing(false); - } - }; - - const handleTextAdd = ( - text: string, - style: TextLayer['style'], - x: number, - y: number, - ) => { - const newLayer: TextLayer = { - id: `text-${Date.now()}`, - text, - x, - y, - style, - }; - setTextLayers([...textLayers, newLayer]); - }; - - const handleTextUpdate = ( - id: string, - text: string, - style: TextLayer['style'], - x: number, - y: number, - ) => { - setTextLayers((layers) => - layers.map((layer) => - layer.id === id ? { ...layer, text, style, x, y } : layer, - ), - ); - }; - - const handleTextDelete = (id: string) => { - setTextLayers((layers) => layers.filter((layer) => layer.id !== id)); - }; - - const handleDrawingComplete = (drawingCanvas: HTMLCanvasElement) => { - setDrawingLayer(drawingCanvas); - }; - - const createFinalCanvas = async () => { - const finalCanvas = document.createElement('canvas'); - finalCanvas.width = STORY_CONSTANTS.MAX_WIDTH; - finalCanvas.height = STORY_CONSTANTS.MAX_HEIGHT; - const ctx = finalCanvas.getContext('2d'); - if (ctx == null) throw new Error('Could not get canvas context'); - - // Apply filter globally if any - ctx.filter = currentFilter; - - if (media?.type === 'video' && media.videoElement != null) { - // Set up MediaRecorder for video - const stream = finalCanvas.captureStream(); - const mediaRecorder = new MediaRecorder(stream, { - mimeType: 'video/webm', - videoBitsPerSecond: 2500000, // 2.5 Mbps - }); - - const chunks: Blob[] = []; - mediaRecorder.ondataavailable = (e) => { - if (e.data.size > 0) chunks.push(e.data); - }; - - return new Promise((resolve) => { - mediaRecorder.onstop = () => { - const blob = new Blob(chunks, { type: 'video/webm' }); - resolve(new File([blob], 'story.webm', { type: 'video/webm' })); - }; - - // Start recording and processing frames - mediaRecorder.start(); - - const processFrame = () => { - // Draw current video frame - if (media.videoElement != null) { - ctx.drawImage( - media.videoElement, - 0, - 0, - finalCanvas.width, - finalCanvas.height, - ); - } - - // Draw drawing layer if exists - if (drawingLayer != null) { - ctx.globalAlpha = 0.8; - ctx.drawImage(drawingLayer, 0, 0); - ctx.globalAlpha = 1; - } - - // Draw text layers - textLayers.forEach((layer) => { - ctx.font = `${layer.style.fontSize}px ${layer.style.fontFamily}`; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - - if (layer.style.backgroundColor != null) { - const metrics = ctx.measureText(layer.text); - const padding = 5; - ctx.fillStyle = layer.style.backgroundColor; - ctx.fillRect( - layer.x - metrics.width / 2 - padding, - layer.y - layer.style.fontSize / 2 - padding, - metrics.width + padding * 2, - layer.style.fontSize + padding * 2, - ); - } - - ctx.fillStyle = layer.style.color; - ctx.fillText(layer.text, layer.x, layer.y); - }); - - if ( - media.videoElement?.paused === false && - !media.videoElement.ended - ) { - requestAnimationFrame(processFrame); - } else { - mediaRecorder.stop(); - } - }; - - // Start playback and processing - void media.videoElement?.play().then(() => { - processFrame(); - }); - }); - } else { - // For images, draw once - if (canvasRef.current != null) { - ctx.drawImage(canvasRef.current, 0, 0); - } - - if (drawingLayer != null) { - ctx.globalAlpha = 0.8; - ctx.drawImage(drawingLayer, 0, 0); - ctx.globalAlpha = 1; - } - - textLayers.forEach((layer) => { - ctx.font = `${layer.style.fontSize}px ${layer.style.fontFamily}`; - ctx.fillStyle = layer.style.color; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - - if (layer.style.backgroundColor != null) { - const metrics = ctx.measureText(layer.text); - const padding = 5; - ctx.fillStyle = layer.style.backgroundColor; - ctx.fillRect( - layer.x - metrics.width / 2 - padding, - layer.y - layer.style.fontSize / 2 - padding, - metrics.width + padding * 2, - layer.style.fontSize + padding * 2, - ); - } - - ctx.fillStyle = layer.style.color; - ctx.fillText(layer.text, layer.x, layer.y); - }); - - return new Promise((resolve) => { - finalCanvas.toBlob( - (blob) => { - if (blob != null) { - resolve(new File([blob], 'story.jpg', { type: 'image/jpeg' })); - } - }, - 'image/jpeg', - 0.9, - ); - }); - } - }; - - const handleShare = async () => { - if (media == null) return; - - try { - setIsProcessing(true); - const finalFile = await createFinalCanvas(); - await onSubmit(finalFile); - } catch (error) { - console.error('Error saving story:', error); - alert('Failed to save story. Please try again.'); - } finally { - setIsProcessing(false); - } - }; - - const handleFilterChange = (filter: string) => { - setCurrentFilter(filter); - if (canvasRef.current != null) { - canvasRef.current.style.filter = filter; - } - }; - - return ( -
-
- {media != null ? ( - <> - {media.type === 'video' ? ( -
-
- ); -}; diff --git a/src/components/story/list/StoryItem.tsx b/src/components/story/list/StoryItem.tsx deleted file mode 100644 index a0247e8..0000000 --- a/src/components/story/list/StoryItem.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import type { Story } from '../../../types/story'; - -interface StoryItemProps { - username: string; - profileImage?: string; - stories: Story[]; - onView: () => void; -} - -export function StoryItem({ - username, - profileImage, - stories, - onView, -}: StoryItemProps) { - const hasActiveStory = stories.length > 0; - - const handleClick = () => { - if (hasActiveStory) { - onView(); - } - }; - - return ( - - ); -} diff --git a/src/components/story/list/StoryList.tsx b/src/components/story/list/StoryList.tsx deleted file mode 100644 index 81ea05f..0000000 --- a/src/components/story/list/StoryList.tsx +++ /dev/null @@ -1,179 +0,0 @@ -import { useEffect, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; - -import { useStories } from '../../../hooks/story/useStories'; -import { useStoryProcessing } from '../../../hooks/story/useStoryProcessing'; -import type { Story } from '../../../types/story'; -import type { UserProfile } from '../../../types/user'; -import { authenticatedFetch } from '../../../utils/auth'; -import { StoryCreator } from '../creation/StoryCreator'; -import { StoryViewer } from '../viewer/StoryViewer/index'; -import { StoryItem } from './StoryItem'; - -export function StoryList() { - const navigate = useNavigate(); - const [selectedUserId, setSelectedUserId] = useState(null); - const [viewingStories, setViewingStories] = useState([]); - const [currentStoryIndex, setCurrentStoryIndex] = useState(0); - const [currentUserId, setCurrentUserId] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const { - stories, - error: storiesError, - deleteStory, - } = useStories(currentUserId); - - const { - processing, - error: processingError, - processMedia, - } = useStoryProcessing(); - - // Handle file processing - const handleStoryCreate = async (file: File) => { - try { - const processedMedia = await processMedia(file); - if (processedMedia === null) { - throw new Error('Failed to process media'); - } - - // Add processed media to form data - const formData = new FormData(); - formData.append('files', processedMedia.file); - - // Upload processed story - const response = await fetch( - 'https://waffle-instaclone.kro.kr/api/story/', - { - method: 'POST', - headers: { - Authorization: `Bearer ${localStorage.getItem('access_token') ?? ''}`, - }, - body: formData, - }, - ); - - if (!response.ok) { - throw new Error('Failed to upload story'); - } - - // Refresh stories - window.location.reload(); - } catch (err) { - console.error('Error creating story:', err); - setError(err instanceof Error ? err.message : 'Failed to create story'); - } - }; - - useEffect(() => { - const fetchUserInfo = async () => { - try { - setLoading(true); - const token = localStorage.getItem('access_token'); - - if (token == null) { - localStorage.removeItem('isLoggedIn'); - void navigate('/'); - throw new Error('No access token found'); - } - - const response = await authenticatedFetch( - 'http://waffle-instaclone.kro.kr/api/user/profile', - { - headers: { - Authorization: `Bearer ${token}`, - }, - credentials: 'include', - }, - ); - - if (!response.ok) { - throw new Error('Failed to fetch user info'); - } - - const userData = (await response.json()) as UserProfile; - setCurrentUserId(userData != null ? userData.user_id : null); - setError(null); - } catch (err) { - setError(err instanceof Error ? err.message : 'Unknown error occurred'); - console.error('Error fetching user info:', err); - } finally { - setLoading(false); - } - }; - - void fetchUserInfo(); - }, [navigate]); - - const handleViewStory = (userId: number, userStories: Story[]) => { - setSelectedUserId(userId); - setViewingStories(userStories); - setCurrentStoryIndex(0); - }; - - const handleCloseViewer = () => { - setSelectedUserId(null); - setViewingStories([]); - setCurrentStoryIndex(0); - }; - - if (loading || processing) { - return ( -
-
-
-
-
-
-
- ); - } - - if (error != null || storiesError != null || processingError != null) { - console.error({ - mainError: error, - storiesError, - processingError, - }); - return null; - } - - // Group stories by user - const storiesByUser = stories.reduce>( - (acc, story: Story) => { - if (acc[story.user_id] == null) { - acc[story.user_id] = []; - } - acc[story.user_id]?.push(story); - return acc; - }, - {}, - ); - - return ( -
- - {Object.entries(storiesByUser).map(([userId, userStories]) => ( - { - handleViewStory(Number(userId), userStories); - }} - /> - ))} - {viewingStories.length > 0 && ( - - )} -
- ); -} diff --git a/src/components/story/shared/constants.ts b/src/components/story/shared/constants.ts deleted file mode 100644 index 648d5c1..0000000 --- a/src/components/story/shared/constants.ts +++ /dev/null @@ -1,9 +0,0 @@ -export const STORY_CONSTANTS = { - MAX_WIDTH: 1080, - MAX_HEIGHT: 1920, - MAX_VIDEO_DURATION: 15, // seconds - ASPECT_RATIO: 9 / 16, - MAX_FILE_SIZE: 100 * 1024 * 1024, // 100MB - SUPPORTED_IMAGE_TYPES: ['image/jpeg', 'image/png', 'image/webp'], - SUPPORTED_VIDEO_TYPES: ['video/mp4', 'video/quicktime'], -}; diff --git a/src/components/story/shared/types.ts b/src/components/story/shared/types.ts deleted file mode 100644 index b302af4..0000000 --- a/src/components/story/shared/types.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { Story } from '../../../types/story'; - -export interface StoryMediaFile { - file: File; - type: 'image' | 'video'; - preview: string; -} - -export interface StoryEditorProps { - onClose: () => void; - onSubmit: (media: File) => Promise; -} - -export interface StoryViewerProps { - stories: Story[]; - currentIndex: number; - isOwner: boolean; - onClose: () => void; - onDelete?: (storyId: number) => Promise; -} - -/* export interface StoryItemProps { - username: string; - profileImage?: string; - stories: Story[]; - onView: () => void; -} */ - -export interface TextStyle { - fontSize: number; - fontFamily: string; - color: string; - backgroundColor?: string; -} diff --git a/src/components/story/shared/utils/imageprocessing.ts b/src/components/story/shared/utils/imageprocessing.ts deleted file mode 100644 index 9198964..0000000 --- a/src/components/story/shared/utils/imageprocessing.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { STORY_CONSTANTS } from '../constants'; - -export const processImage = async (file: File): Promise => { - const img = new Image(); - - return new Promise((resolve, reject) => { - img.onload = () => { - const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d'); - - if (ctx == null) { - reject(new Error('Failed to get canvas context')); - return; - } - - canvas.width = STORY_CONSTANTS.MAX_WIDTH; - canvas.height = STORY_CONSTANTS.MAX_HEIGHT; - - // Calculate scaling and positioning for center fit - const scale = Math.min( - STORY_CONSTANTS.MAX_WIDTH / img.width, - STORY_CONSTANTS.MAX_HEIGHT / img.height, - ); - const scaledWidth = img.width * scale; - const scaledHeight = img.height * scale; - const x = (STORY_CONSTANTS.MAX_WIDTH - scaledWidth) / 2; - const y = (STORY_CONSTANTS.MAX_HEIGHT - scaledHeight) / 2; - - // Clear canvas - ctx.clearRect(0, 0, canvas.width, canvas.height); - - // Draw blurred background by scaling the original image - ctx.filter = 'blur(20px)'; - // Extend background beyond edges to prevent white borders during blur - ctx.drawImage( - img, - -20, - -20, - STORY_CONSTANTS.MAX_WIDTH + 40, - STORY_CONSTANTS.MAX_HEIGHT + 40, - ); - ctx.filter = 'none'; - - // Draw the actual image in the center - ctx.drawImage(img, x, y, scaledWidth, scaledHeight); - - resolve(canvas.toDataURL('image/jpeg', 0.9)); - }; - - img.onerror = () => { - reject(new Error('Failed to load image')); - }; - img.src = URL.createObjectURL(file); - }); -}; diff --git a/src/components/story/shared/utils/index.ts b/src/components/story/shared/utils/index.ts deleted file mode 100644 index 9b7362e..0000000 --- a/src/components/story/shared/utils/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { processImage } from './imageprocessing'; -export { processVideo } from './videoprocessing'; -export { validateStoryMedia } from './validation'; diff --git a/src/components/story/shared/utils/validation.ts b/src/components/story/shared/utils/validation.ts deleted file mode 100644 index ba3bde8..0000000 --- a/src/components/story/shared/utils/validation.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { STORY_CONSTANTS } from '../constants'; - -export const validateStoryMedia = async (file: File): Promise => { - if ( - !STORY_CONSTANTS.SUPPORTED_IMAGE_TYPES.includes(file.type) && - !STORY_CONSTANTS.SUPPORTED_VIDEO_TYPES.includes(file.type) - ) { - throw new Error('Unsupported file type'); - } - - if (file.size > STORY_CONSTANTS.MAX_FILE_SIZE) { - throw new Error('File size too large'); - } - - if (file.type.startsWith('video/')) { - const video = document.createElement('video'); - await new Promise((resolve, reject) => { - video.onloadedmetadata = () => { - if (video.duration > STORY_CONSTANTS.MAX_VIDEO_DURATION) { - reject( - new Error( - `Video must be ${STORY_CONSTANTS.MAX_VIDEO_DURATION} seconds or less`, - ), - ); - } - resolve(); - }; - video.onerror = () => { - reject(new Error('Invalid video file')); - }; - video.src = URL.createObjectURL(file); - }); - } -}; diff --git a/src/components/story/shared/utils/videoprocessing.ts b/src/components/story/shared/utils/videoprocessing.ts deleted file mode 100644 index fabbe41..0000000 --- a/src/components/story/shared/utils/videoprocessing.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { STORY_CONSTANTS } from '../constants'; - -const createOffscreenCanvas = (width: number, height: number) => { - const canvas = document.createElement('canvas'); - canvas.width = width; - canvas.height = height; - return canvas; -}; - -const calculateDimensions = (videoWidth: number, videoHeight: number) => { - const targetAspectRatio = - STORY_CONSTANTS.MAX_WIDTH / STORY_CONSTANTS.MAX_HEIGHT; - const videoAspectRatio = videoWidth / videoHeight; - - let width = STORY_CONSTANTS.MAX_WIDTH; - let height = STORY_CONSTANTS.MAX_HEIGHT; - - if (videoAspectRatio > targetAspectRatio) { - // Video is wider than target - scale based on height - height = STORY_CONSTANTS.MAX_HEIGHT; - width = height * videoAspectRatio; - } else { - // Video is taller than target - scale based on width - width = STORY_CONSTANTS.MAX_WIDTH; - height = width / videoAspectRatio; - } - - return { - width, - height, - x: (STORY_CONSTANTS.MAX_WIDTH - width) / 2, - y: (STORY_CONSTANTS.MAX_HEIGHT - height) / 2, - }; -}; - -export const processVideo = async (file: File): Promise => { - const video = document.createElement('video'); - video.playsInline = true; - video.muted = true; - - return new Promise((resolve, reject) => { - video.onloadedmetadata = async () => { - try { - if (video.duration > STORY_CONSTANTS.MAX_VIDEO_DURATION) { - throw new Error( - `Video must be ${STORY_CONSTANTS.MAX_VIDEO_DURATION} seconds or less`, - ); - } - - const canvas = createOffscreenCanvas( - STORY_CONSTANTS.MAX_WIDTH, - STORY_CONSTANTS.MAX_HEIGHT, - ); - const ctx = canvas.getContext('2d'); - if (ctx == null) { - throw new Error('Failed to get canvas context'); - } - - // Start playing the video to capture the first frame - await video.play(); - - const { width, height, x, y } = calculateDimensions( - video.videoWidth, - video.videoHeight, - ); - - // Draw first frame with background blur - ctx.filter = 'blur(20px)'; - ctx.fillStyle = '#000'; - ctx.fillRect(0, 0, canvas.width, canvas.height); - ctx.drawImage(video, -20, -20, canvas.width + 40, canvas.height + 40); - ctx.filter = 'none'; - - // Draw main video frame - ctx.drawImage(video, x, y, width, height); - - // Create a MediaStream from the canvas - const stream = canvas.captureStream(); - - // Set up MediaRecorder with optimal settings for stories - const mediaRecorder = new MediaRecorder(stream, { - mimeType: 'video/webm;codecs=vp9', - videoBitsPerSecond: 2500000, // 2.5 Mbps - }); - - const chunks: Blob[] = []; - mediaRecorder.ondataavailable = (e) => { - if (e.data.size > 0) { - chunks.push(e.data); - } - }; - - mediaRecorder.onstop = () => { - const processedBlob = new Blob(chunks, { type: 'video/webm' }); - resolve(URL.createObjectURL(processedBlob)); - video.pause(); - }; - - // Process frames at 30fps - const frameInterval = 1000 / 30; - let startTime = performance.now(); - - const processFrame = () => { - const now = performance.now(); - const elapsed = now - startTime; - - if (elapsed >= frameInterval) { - // Draw blur background - ctx.filter = 'blur(20px)'; - ctx.drawImage( - video, - -20, - -20, - canvas.width + 40, - canvas.height + 40, - ); - ctx.filter = 'none'; - - // Draw main video frame - ctx.drawImage(video, x, y, width, height); - startTime = now - (elapsed % frameInterval); - } - - if (!video.ended && !video.paused) { - requestAnimationFrame(processFrame); - } else { - mediaRecorder.stop(); - } - }; - - mediaRecorder.start(); - requestAnimationFrame(processFrame); - } catch (error) { - reject(error instanceof Error ? error : new Error(String(error))); - } - }; - - video.onerror = () => { - reject(new Error('Failed to load video')); - }; - - video.src = URL.createObjectURL(file); - }); -}; diff --git a/src/components/story/viewer/StoryViewer/Controls.tsx b/src/components/story/viewer/StoryViewer/Controls.tsx deleted file mode 100644 index c783289..0000000 --- a/src/components/story/viewer/StoryViewer/Controls.tsx +++ /dev/null @@ -1,205 +0,0 @@ -import { - Download, - Flag, - Heart, - MessageCircle, - MoreVertical, - Send, - Share2, - X, -} from 'lucide-react'; -import { useState } from 'react'; - -interface ControlsProps { - onNext: () => void; - onPrevious: () => void; - onClose: () => void; - onDelete?: () => Promise; - canGoNext: boolean; - canGoPrevious: boolean; - isOwner: boolean; - username: string; - timestamp: string; - storyId: string; - storyUrl: string; -} - -export const Controls: React.FC = ({ - onNext, - onPrevious, - onClose, - onDelete, - canGoNext, - canGoPrevious, - isOwner, - username, - timestamp, - storyId, - storyUrl, -}) => { - const [showMenu, setShowMenu] = useState(false); - const [showShareMenu, setShowShareMenu] = useState(false); - - const handleShare = async () => { - try { - await navigator.share({ - title: `${username}'s Story`, - text: `Check out ${username}'s story on Instagram`, - url: window.location.href, - }); - } catch { - // If Web Share API is not supported, show share menu - setShowShareMenu(true); - } - }; - - const handleReport = async () => { - try { - await fetch('https://waffle-instaclone.kro.kr/api/report/story', { - method: 'POST', - headers: { - Authorization: `Bearer ${localStorage.getItem('access_token') ?? ''}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - story_id: storyId, - reason: 'inappropriate', - }), - }); - setShowMenu(false); - alert('Story reported successfully'); - } catch (error) { - console.error('Error reporting story:', error); - alert('Failed to report story'); - } - }; - - const handleDownload = async () => { - try { - const response = await fetch(storyUrl); - const blob = await response.blob(); - const url = window.URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = `story-${username}-${new Date().getTime()}.jpg`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - window.URL.revokeObjectURL(url); - } catch (error) { - console.error('Error downloading story:', error); - alert('Failed to download story'); - } - }; - - return ( -
- {/* Navigation touch areas */} - - {showMenu && ( -
-
- {isOwner && onDelete != null && ( - - )} - - - {!isOwner && ( - - )} -
-
- )} - -
-
- - {/* Share menu */} - {showShareMenu && ( -
-
- - - -
-
- )} -
- ); -}; diff --git a/src/components/story/viewer/StoryViewer/Progress.tsx b/src/components/story/viewer/StoryViewer/Progress.tsx deleted file mode 100644 index 0c710f5..0000000 --- a/src/components/story/viewer/StoryViewer/Progress.tsx +++ /dev/null @@ -1,29 +0,0 @@ -interface ProgressProps { - duration: number; - currentTime: number; - total: number; - current: number; -} - -export const Progress: React.FC = ({ - duration, - currentTime, - total, - current, -}) => { - return ( -
- {Array.from({ length: total }, (_, i) => ( -
- {i === current && ( -
- )} - {i < current &&
} -
- ))} -
- ); -}; diff --git a/src/components/story/viewer/StoryViewer/ReactionBar.tsx b/src/components/story/viewer/StoryViewer/ReactionBar.tsx deleted file mode 100644 index 995f838..0000000 --- a/src/components/story/viewer/StoryViewer/ReactionBar.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { Heart, Send } from 'lucide-react'; -import { useState } from 'react'; - -interface ReactionBarProps { - storyId: number; - userId: number; -} - -export const ReactionBar: React.FC = ({ - storyId, - userId, -}) => { - const [message, setMessage] = useState(''); - const [isLiked, setIsLiked] = useState(false); - const [isSending, setIsSending] = useState(false); - - const handleReact = async () => { - try { - const endpoint = `https://waffle-instaclone.kro.kr/api/story/${storyId}/${isLiked ? 'unlike' : 'like'}`; - const response = await fetch(endpoint, { - method: 'POST', - headers: { - Authorization: `Bearer ${localStorage.getItem('access_token') ?? ''}`, - 'Content-Type': 'application/json', - }, - }); - - if (!response.ok) { - throw new Error('Failed to react to story'); - } - - setIsLiked(!isLiked); - } catch (error) { - console.error('Error reacting to story:', error); - } - }; - - const handleSendMessage = async () => { - if (message.trim().length === 0) return; - - try { - setIsSending(true); - const response = await fetch( - 'https://waffle-instaclone.kro.kr/api/story/comment/', - { - method: 'POST', - headers: { - Authorization: `Bearer ${localStorage.getItem('access_token') ?? ''}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - comment_text: message, - story_id: storyId, - user_id: userId, - }), - }, - ); - - if (!response.ok) { - throw new Error('Failed to send message'); - } - - setMessage(''); - } catch (error) { - console.error('Error sending message:', error); - } finally { - setIsSending(false); - } - }; - - return ( -
-
- { - setMessage(e.target.value); - }} - placeholder="Send message..." - className="flex-1 bg-transparent border border-white/30 rounded-full px-4 py-2 text-white placeholder:text-white/70" - onKeyPress={(e) => { - if (e.key === 'Enter') { - void handleSendMessage(); - } - }} - /> - - -
-
- ); -}; diff --git a/src/components/story/viewer/StoryViewer/UserHeader.tsx b/src/components/story/viewer/StoryViewer/UserHeader.tsx deleted file mode 100644 index c3560fb..0000000 --- a/src/components/story/viewer/StoryViewer/UserHeader.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import { Flag, MoreHorizontal, Share2, User } from 'lucide-react'; -import { useState } from 'react'; - -interface UserHeaderProps { - username: string; - profileImage: string; - timestamp: string; - isOwner: boolean; - onDelete?: () => Promise; -} - -export const UserHeader: React.FC = ({ - username, - profileImage, - timestamp, - isOwner, - onDelete, -}) => { - const [showOptions, setShowOptions] = useState(false); - - const handleCopyLink = async () => { - try { - await navigator.clipboard.writeText(window.location.href); - alert('Link copied to clipboard!'); - setShowOptions(false); - } catch (error) { - console.error('Failed to copy link:', error); - } - }; - - const handleViewProfile = () => { - window.location.href = `/${username}`; - }; - - return ( -
- - -
- - - {showOptions && ( -
- {isOwner ? ( - <> - {onDelete != null && ( - - )} - - - ) : ( - <> - - - - - )} -
- )} -
-
- ); -}; diff --git a/src/components/story/viewer/StoryViewer/index.tsx b/src/components/story/viewer/StoryViewer/index.tsx deleted file mode 100644 index 3e88b61..0000000 --- a/src/components/story/viewer/StoryViewer/index.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import { useEffect } from 'react'; - -import { useStoryNavigation } from '../../../../hooks/story/useStoryNavigation'; -import { useStoryViewer } from '../../../../hooks/story/useStoryViewer'; -import type { StoryViewerProps } from '../../shared/types'; -import { Controls } from './Controls'; -import { Progress } from './Progress'; -import { ReactionBar } from './ReactionBar'; -import { UserHeader } from './UserHeader'; - -export const StoryViewer: React.FC = ({ - stories, - currentIndex: initialIndex, - isOwner, - onClose, - onDelete, -}) => { - const navigation = useStoryNavigation(stories, initialIndex); - const { - progress, - isPaused, - setIsPaused, - resetProgress, - currentIndex, - goToNext, - //goToPrevious, - //canGoNext, - //canGoPrevious, - isVisible, - setIsVisible, - } = useStoryViewer(stories); - - useEffect(() => { - resetProgress(); - }, [currentIndex, resetProgress]); - - useEffect(() => { - if (!isPaused && progress >= 5000) { - goToNext(); - } - }, [progress, isPaused, goToNext]); - - useEffect(() => { - if (currentIndex >= stories.length) { - setIsVisible(false); - onClose(); - } - }, [currentIndex, stories.length, onClose, setIsVisible]); - - const currentStory = stories[currentIndex]; - if (currentStory == null || !isVisible) return null; - - return ( -
-
- - - { - await onDelete(currentStory.story_id); - setIsVisible(false); - } - : undefined - } - /> - - { - setIsVisible(false); - onClose(); - }} - onDelete={ - onDelete != null - ? async () => { - await onDelete(currentStory.story_id); - setIsVisible(false); - } - : undefined - } - canGoNext={navigation.canGoNext} - canGoPrevious={navigation.canGoPrevious} - isOwner={isOwner} - username={`user${currentStory.user_id}`} - timestamp={new Date(currentStory.creation_date).toLocaleString()} - /> - -
{ - setIsPaused(true); - }} - onTouchEnd={() => { - setIsPaused(false); - }} - onMouseDown={() => { - setIsPaused(true); - }} - onMouseUp={() => { - setIsPaused(false); - }} - > - {`Story -
- - -
-
- ); -}; diff --git a/src/hooks/story/useStoryCreation.ts b/src/hooks/story/useStoryCreation.ts deleted file mode 100644 index d2ab18c..0000000 --- a/src/hooks/story/useStoryCreation.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { useState } from 'react'; - -import type { StoryMediaFile } from '../../components/story/shared/types'; -import { processImage } from '../../components/story/shared/utils/imageprocessing'; -import { validateStoryMedia } from '../../components/story/shared/utils/validation'; -import { processVideo } from '../../components/story/shared/utils/videoprocessing'; - -export const useStoryCreation = () => { - const [media, setMedia] = useState(null); - const [isProcessing, setIsProcessing] = useState(false); - const [error, setError] = useState(null); - - const processMedia = async (file: File) => { - setIsProcessing(true); - setError(null); - - try { - await validateStoryMedia(file); - const isVideo = file.type.startsWith('video/'); - - const processedUrl = isVideo - ? await processVideo(file) - : await processImage(file); - - setMedia({ - file, - type: isVideo ? 'video' : 'image', - preview: processedUrl, - }); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to process media'); - setMedia(null); - } finally { - setIsProcessing(false); - } - }; - - return { - media, - isProcessing, - error, - processMedia, - resetMedia: () => { - setMedia(null); - }, - }; -}; diff --git a/src/hooks/story/useStoryNavigation.ts b/src/hooks/story/useStoryNavigation.ts deleted file mode 100644 index ebf3aa5..0000000 --- a/src/hooks/story/useStoryNavigation.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { useCallback, useState } from 'react'; - -import type { Story } from '../../types/story'; - -export const useStoryNavigation = ( - stories: Story[], - initialIndex: number = 0, -) => { - const [currentIndex, setCurrentIndex] = useState(initialIndex); - const [isPaused, setIsPaused] = useState(false); - - const goToNext = useCallback(() => { - if (currentIndex < stories.length - 1) { - setCurrentIndex((prev) => prev + 1); - return true; - } - return false; - }, [currentIndex, stories.length]); - - const goToPrevious = useCallback(() => { - if (currentIndex > 0) { - setCurrentIndex((prev) => prev - 1); - return true; - } - return false; - }, [currentIndex]); - - return { - currentIndex, - isPaused, - setIsPaused, - goToNext, - goToPrevious, - canGoNext: currentIndex < stories.length - 1, - canGoPrevious: currentIndex > 0, - }; -}; diff --git a/src/hooks/story/useStoryProcessing.ts b/src/hooks/story/useStoryProcessing.ts deleted file mode 100644 index 5a6b091..0000000 --- a/src/hooks/story/useStoryProcessing.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { useState } from 'react'; - -import type { StoryMediaFile } from '../../components/story/shared/types'; -import { - processImage, - processVideo, -} from '../../components/story/shared/utils'; - -export const useStoryProcessing = () => { - const [processing, setProcessing] = useState(false); - const [error, setError] = useState(null); - - const processMedia = async (file: File): Promise => { - setProcessing(true); - setError(null); - - try { - const isVideo = file.type.startsWith('video/'); - const processed = isVideo - ? await processVideo(file) - : await processImage(file); - - return { - file, - type: isVideo ? 'video' : 'image', - preview: processed, - }; - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to process media'); - return null; - } finally { - setProcessing(false); - } - }; - - return { - processing, - error, - processMedia, - }; -}; diff --git a/src/hooks/story/useStoryViewer.ts b/src/hooks/story/useStoryViewer.ts deleted file mode 100644 index 937d5d0..0000000 --- a/src/hooks/story/useStoryViewer.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { useEffect, useState } from 'react'; - -import type { Story } from '../../types/story'; -import { useStoryNavigation } from './useStoryNavigation'; - -const STORY_DURATION = 5000; // 5 seconds - -export const useStoryViewer = (stories: Story[]) => { - const [isVisible, setIsVisible] = useState(false); - const [progress, setProgress] = useState(0); - const navigation = useStoryNavigation(stories); - - const resetProgress = () => { - setProgress(0); - }; - - useEffect(() => { - if (!isVisible || navigation.isPaused) return; - - const timer = setInterval(() => { - setProgress((prev) => { - if (prev >= STORY_DURATION) { - const hasNext = navigation.goToNext(); - if (!hasNext) { - setIsVisible(false); - } - return 0; - } - return prev + 100; - }); - }, 100); - - return () => { - clearInterval(timer); - }; - }, [isVisible, navigation, navigation.isPaused]); - - useEffect(() => { - resetProgress(); - }, [navigation.currentIndex]); - - return { - isVisible, - progress, - resetProgress, - setIsVisible, - ...navigation, - }; -}; diff --git a/src/hooks/story/useStories.ts b/src/hooks/useStories.ts similarity index 95% rename from src/hooks/story/useStories.ts rename to src/hooks/useStories.ts index 43915b3..7f01759 100644 --- a/src/hooks/story/useStories.ts +++ b/src/hooks/useStories.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; -import type { Story } from '../../types/story'; +import type { Story } from '../types/story'; export function useStories(userId: number | null) { const [stories, setStories] = useState([]); @@ -9,7 +9,7 @@ export function useStories(userId: number | null) { useEffect(() => { const fetchStories = async () => { - if (userId == null || userId === 0) { + if (userId == null) { setLoading(false); return; } diff --git a/src/hooks/story/useStoryUpload.ts b/src/hooks/useStoryUpload.ts similarity index 76% rename from src/hooks/story/useStoryUpload.ts rename to src/hooks/useStoryUpload.ts index 46071f8..4b69448 100644 --- a/src/hooks/story/useStoryUpload.ts +++ b/src/hooks/useStoryUpload.ts @@ -1,9 +1,11 @@ import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; -import type { Story } from '../../types/story'; +import type { Story } from '../types/story'; export function useStoryUpload() { const [isUploading, setIsUploading] = useState(false); + const navigate = useNavigate(); const uploadStory = async (files: FileList): Promise => { setIsUploading(true); @@ -25,7 +27,9 @@ export function useStoryUpload() { ); if (!response.ok) throw new Error('Failed to upload story'); - return await (response.json() as Promise); + const result = (await response.json()) as Story; + void navigate('/', { replace: true }); + return result; } finally { setIsUploading(false); } diff --git a/src/pages/MainPage.tsx b/src/pages/MainPage.tsx index 97f1f5f..fbe2944 100644 --- a/src/pages/MainPage.tsx +++ b/src/pages/MainPage.tsx @@ -7,7 +7,6 @@ import EmptyFeed from '../components/feed/EmptyFeed'; import Posts from '../components/feed/Posts'; import { Stories } from '../components/feed/Stories'; import MobileBar from '../components/layout/MobileBar'; -import MobileHeader from '../components/layout/MobileHeader'; import SideBar from '../components/layout/SideBar'; import SearchModal from '../components/modals/SearchModal'; import { useSearch } from '../hooks/useSearch'; @@ -82,7 +81,6 @@ const MainPage = () => { />
- {loading ? (
Loading posts...
diff --git a/src/pages/StoryPage.tsx b/src/pages/StoryPage.tsx new file mode 100644 index 0000000..a57b233 --- /dev/null +++ b/src/pages/StoryPage.tsx @@ -0,0 +1,116 @@ +import { useContext, useEffect, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; + +import { LoginContext } from '../App'; +import StoryViewer from '../components/story/StoryViewer/StoryViewer'; +import type { Story } from '../types/story'; +import type { UserProfile } from '../types/user'; + +export default function StoryPage() { + const { username, storyId } = useParams<{ + username: string; + storyId: string; + }>(); + const [stories, setStories] = useState([]); + const [currentIndex, setCurrentIndex] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const navigate = useNavigate(); + const context = useContext(LoginContext); + + useEffect(() => { + const fetchStories = async () => { + if (username == null) return; + + try { + setLoading(true); + // First fetch user info to get user_id + const userResponse = await fetch( + `https://waffle-instaclone.kro.kr/api/user/${username}`, + ); + if (!userResponse.ok) throw new Error('User not found'); + const userData = (await userResponse.json()) as UserProfile; + if (userData?.user_id == null) throw new Error('Invalid user data'); + + // Then fetch stories for that user + const storiesResponse = await fetch( + `https://waffle-instaclone.kro.kr/api/story/list/${userData.user_id}`, + { + headers: { + Authorization: `Bearer ${localStorage.getItem('access_token') ?? ''}`, + }, + }, + ); + if (!storiesResponse.ok) throw new Error('Failed to fetch stories'); + const storiesData = (await storiesResponse.json()) as Story[]; + + setStories(storiesData); + + // Find the index of the requested story + const index = storiesData.findIndex( + (story) => story.story_id.toString() === storyId, + ); + if (index === -1) throw new Error('Story not found'); + setCurrentIndex(index); + } catch (err) { + setError(err instanceof Error ? err.message : 'An error occurred'); + } finally { + setLoading(false); + } + }; + + void fetchStories(); + }, [username, storyId]); + + if (loading) { + return ( +
+ Loading... +
+ ); + } + + if (error != null || stories.length === 0 || username == null) { + return ( +
+ Story not found +
+ ); + } + + const handleClose = () => { + void navigate('/', { replace: true }); + }; + + const handleDelete = async (storyToDeleteId: number) => { + try { + const response = await fetch( + `https://waffle-instaclone.kro.kr/api/story/${storyToDeleteId}`, + { + method: 'DELETE', + headers: { + Authorization: `Bearer ${localStorage.getItem('access_token') ?? ''}`, + }, + }, + ); + + if (!response.ok) throw new Error('Failed to delete story'); + void navigate('/', { replace: true }); + } catch (err) { + console.error('Failed to delete story:', err); + } + }; + + const isOwner = context?.myProfile?.username === username; + + return ( + + ); +} diff --git a/yarn.lock b/yarn.lock index 898d2db..a2d71a4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1294,13 +1294,6 @@ __metadata: languageName: node linkType: hard -"clsx@npm:^1.1.1": - version: 1.2.1 - resolution: "clsx@npm:1.2.1" - checksum: 10c0/34dead8bee24f5e96f6e7937d711978380647e936a22e76380290e35486afd8634966ce300fc4b74a32f3762c7d4c0303f442c3e259f4ce02374eb0c82834f27 - languageName: node - linkType: hard - "color-convert@npm:^2.0.1": version: 2.0.1 resolution: "color-convert@npm:2.0.1" @@ -3490,19 +3483,6 @@ __metadata: languageName: node linkType: hard -"react-draggable@npm:4.4.6": - version: 4.4.6 - resolution: "react-draggable@npm:4.4.6" - dependencies: - clsx: "npm:^1.1.1" - prop-types: "npm:^15.8.1" - peerDependencies: - react: ">= 16.3.0" - react-dom: ">= 16.3.0" - checksum: 10c0/1e8cf47414a8554caa68447e5f27749bc40e1eabb4806e2dadcb39ab081d263f517d6aaec5231677e6b425603037c7e3386d1549898f9ffcc98a86cabafb2b9a - languageName: node - linkType: hard - "react-intersection-observer@npm:9.15.1": version: 9.15.1 resolution: "react-intersection-observer@npm:9.15.1" @@ -3808,7 +3788,6 @@ __metadata: prettier: "npm:3.3.3" react: "npm:18.3.1" react-dom: "npm:18.3.1" - react-draggable: "npm:4.4.6" react-intersection-observer: "npm:9.15.1" react-router-dom: "npm:7.1.1" tailwindcss: "npm:3.4.17"