Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Story editor implementation #25

Merged
merged 38 commits into from
Feb 1, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
1c158b5
feat: post api 연결 및 기본 로직 구현
Jan 10, 2025
695c534
fix: 클릭된 사이드바 항목 표시 오류 수정
Jan 13, 2025
9472eab
fix: 로그인, 회원가입 api 분리 및 오류 수정
Jan 14, 2025
7e9a8c8
feat: user api 연결 및 오류 수정
Jan 14, 2025
cc65452
fix: prettier
Jan 14, 2025
36fe8f2
fix: myProfile fetch 개선
Jan 18, 2025
2445191
feat: follow api 연결
Jan 18, 2025
d660a31
feat: profile edit page 추가 및 기타 수정
Jan 18, 2025
abc9398
feat: profile edit api 연결
Jan 19, 2025
ab5f269
fix: prettier
Jan 19, 2025
bc14d81
fix: 기타 수정
Jan 19, 2025
b49d85c
fix: 기타 수정
Jan 19, 2025
2b5543e
feat: explore api 연결
Jan 21, 2025
baf0793
feat: post detail page 구현
Jan 23, 2025
c2e3069
feat: 좋아요, 댓글 api 연결 및 기능 구현
Jan 24, 2025
b67a25c
fix: 프로필 편집, post 생성 수정 보완
Jan 25, 2025
0abcfc9
fix: api 파일명 개선
Jan 25, 2025
dd11c29
fix: 게시물 속 유저 정보 반영 & post detail page에도 기능 연결
Jan 25, 2025
72d652b
feat: 댓글 작성 api 연결 및 post detail page 컴포넌트 분리 관리
Jan 26, 2025
dc04a9a
feat: 게시물 수정 api 연결
Jan 26, 2025
4442db7
feat: 댓글 삭제 api 연결
Jan 26, 2025
69290fb
feat: 검색 api 연결
Jan 27, 2025
3a0dd5f
feat: 유저 프로필 클릭시 해당 유저 프로필로 이동
Jan 27, 2025
dd25b48
Merge branch 'main' into feat/post
LikeACloud7 Jan 27, 2025
ef739af
fix: prettier
Jan 27, 2025
52f0492
fix: unused파일 삭제
Jan 27, 2025
6d4876d
fix: url 수정
Jan 27, 2025
ec39cba
username, grey circle, cleaner buttons
Jan 31, 2025
f93a0b5
StoryEditor URL
Jan 31, 2025
3e6de12
Story View similar to instagram
Feb 1, 2025
677492a
Enlargen clickable parts of buttons
Feb 1, 2025
0b00646
Clone of story-editor until working storyviewer
Feb 1, 2025
4c3a59a
repomix was also included, removed
Feb 1, 2025
7eb5d6f
Merge branch 'main' into story-editor-implementation
Feb 1, 2025
0571302
PR automatic file omissions fixed
Feb 1, 2025
6c7d996
errorData.detail fixed
Feb 1, 2025
19cd859
prettier
Feb 1, 2025
e2b5fed
remove react-draggable (needed for text dragging)
Feb 1, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
22 changes: 22 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';

Expand Down Expand Up @@ -59,6 +61,26 @@ export const App = () => {
path="/:username"
element={auth.isLoggedIn ? <ProfilePage /> : <Navigate to="/" />}
/>
<Route
path="/stories/:username/:storyId"
element={
auth.isLoggedIn ? (
<StoryPage />
) : (
<LoginPage handleIsLoggedIn={auth.handleIsLoggedIn} />
)
}
/>
<Route
path="/stories/new"
element={
auth.isLoggedIn ? (
<StoryEditor />
) : (
<LoginPage handleIsLoggedIn={auth.handleIsLoggedIn} />
)
}
/>
<Route
path="/accounts/edit"
element={
Expand Down
26 changes: 5 additions & 21 deletions src/api/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,28 +46,12 @@ export const signin = async (username: string, password: string) => {
},
);

// 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) {
Expand Down
2 changes: 1 addition & 1 deletion src/components/feed/Stories.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { StoryList } from '../story/list/StoryList';
import { StoryList } from '../story/StoryList';

export function Stories() {
return <StoryList />;
Expand Down
11 changes: 1 addition & 10 deletions src/components/layout/SideBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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);
Expand Down Expand Up @@ -101,14 +100,6 @@ const SideBar = ({ onSearchClick }: SideBarProps) => {
handleCreateClick('create');
}}
/>
{isCreateModalOpen && (
<CreatePostModal
isOpen={isCreateModalOpen}
onClose={() => {
setIsCreateModalOpen(false);
}}
/>
)}
<Link to={`/${String(context.myProfile?.username)}`}>
<NavItem
icon={<User />}
Expand Down
5 changes: 5 additions & 0 deletions src/components/shared/default-profile.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
156 changes: 156 additions & 0 deletions src/components/story/Editor/ImageProcessor.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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<Blob | null>((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 (
<div className="w-full max-w-lg mx-auto">
<canvas ref={canvasRef} className="hidden" />
{processedImage != null && (
<img
src={processedImage}
alt="Processed story"
className="w-full h-auto rounded-lg shadow-lg"
/>
)}
</div>
);
};

export default ImageProcessor;
92 changes: 92 additions & 0 deletions src/components/story/Editor/StoryEditor.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(null);
const [isUploaded, setIsUploaded] = useState(false);
const [processedBlob, setProcessedBlob] = useState<Blob | null>(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 (
<div className="min-h-screen bg-gray-100 p-4">
<div className="max-w-lg mx-auto">
<h1 className="text-2xl font-bold mb-4">Edit Story</h1>

<ImageProcessor file={state.file} onProcessed={handleProcessed} />

{isProcessing && (
<div className="mt-4 text-center text-gray-600">
Processing and uploading your story...
</div>
)}

{error != null && (
<div className="mt-4 text-center text-red-500">{error}</div>
)}

<div className="mt-4 flex justify-end space-x-2">
<button
onClick={() => void navigate('/')}
className="px-4 py-2 bg-gray-200 rounded-lg hover:bg-gray-300 transition-colors"
>
Cancel
</button>
<button
onClick={() => void handleShare()}
disabled={isProcessing || isUploaded || processedBlob == null}
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Share Story
</button>
</div>
</div>
</div>
);
};

export default StoryEditor;
Loading
Loading