Skip to content

Commit

Permalink
Story editor implementation (#27)
Browse files Browse the repository at this point in the history
Login working in editor-implementation branch, but not in main?? Needs checking

---------

Co-authored-by: 박세준 <[email protected]>
Co-authored-by: Sejun Park <[email protected]>
Co-authored-by: IceCandle <[email protected]>
  • Loading branch information
4 people authored Feb 1, 2025
1 parent a548544 commit 4a190fe
Show file tree
Hide file tree
Showing 44 changed files with 1,038 additions and 2,352 deletions.
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

0 comments on commit 4a190fe

Please sign in to comment.