Implement image upload and viewer in v2
This commit is contained in:
@@ -5,11 +5,12 @@ import { MainLayout } from './layouts/MainLayout';
|
||||
import About from './components/About';
|
||||
import CreatePost from './components/CreatePost';
|
||||
import PostCard from './components/PostCard';
|
||||
import ImageViewer from './components/ImageViewer';
|
||||
import { fetchArticles, type Article } from './api';
|
||||
import { useLayout } from './context/LayoutContext';
|
||||
import './App.css';
|
||||
|
||||
const Home: React.FC = () => {
|
||||
const Home: React.FC<{ onPreviewImage: (src: string, alt?: string) => void }> = ({ onPreviewImage }) => {
|
||||
const { refreshTrigger } = useLayout();
|
||||
const { toasterId } = useLayout();
|
||||
const { dispatchToast } = useToastController(toasterId);
|
||||
@@ -113,6 +114,7 @@ const Home: React.FC = () => {
|
||||
content={article.content}
|
||||
upvotes={article.upvotes}
|
||||
downvotes={article.downvotes}
|
||||
onPreviewImage={onPreviewImage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -124,6 +126,7 @@ const Home: React.FC = () => {
|
||||
content={article.content}
|
||||
upvotes={article.upvotes}
|
||||
downvotes={article.downvotes}
|
||||
onPreviewImage={onPreviewImage}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -145,11 +148,29 @@ const Home: React.FC = () => {
|
||||
const NotFound = () => <h1>404 Not Found</h1>;
|
||||
|
||||
function App() {
|
||||
const [imageViewer, setImageViewer] = useState<{ open: boolean; src?: string; alt?: string }>({ open: false });
|
||||
const openImageViewer = (src?: string, alt?: string) => {
|
||||
if (!src) return;
|
||||
setImageViewer({ open: true, src, alt });
|
||||
};
|
||||
const closeImageViewer = () => setImageViewer({ open: false });
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<MainLayout />}>
|
||||
<Route index element={<Home />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<MainLayout
|
||||
imageViewer={
|
||||
imageViewer.open && imageViewer.src ? (
|
||||
<ImageViewer src={imageViewer.src!} alt={imageViewer.alt} onClose={closeImageViewer} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Route index element={<Home onPreviewImage={openImageViewer} />} />
|
||||
<Route path="create" element={<CreatePost />} />
|
||||
<Route path="about" element={<About />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
|
||||
@@ -290,3 +290,52 @@ export const postComment = async (commentData: PostCommentRequest): Promise<Post
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const uploadImage = async (file: File): Promise<string> => {
|
||||
try {
|
||||
const identity_token = await get_id_token();
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (identity_token) {
|
||||
formData.append('identity_token', identity_token);
|
||||
}
|
||||
const response = await fetch('/api/upload_pic', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const json = await response.json();
|
||||
if (json.code === 1001 && typeof json.data === 'string') {
|
||||
return json.data;
|
||||
}
|
||||
if (json.code === 2006) {
|
||||
throw new Error('UPLOAD_TOO_LARGE');
|
||||
}
|
||||
if (json.code === 2007) {
|
||||
throw new Error('UNSUPPORTED_FORMAT');
|
||||
}
|
||||
throw new Error(json.data || 'Upload failed');
|
||||
} catch (error) {
|
||||
console.error('Failed to upload image:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getFileName = async (path: string): Promise<string> => {
|
||||
try {
|
||||
const response = await fetch(`/api/file_name?path=${encodeURIComponent(path)}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const json = await response.json();
|
||||
if (json.code === 1000 && typeof json.data === 'string') {
|
||||
return json.data;
|
||||
}
|
||||
throw new Error('Invalid response code or missing data');
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch file name:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -16,7 +16,13 @@ import {
|
||||
Input,
|
||||
useToastController,
|
||||
Toast,
|
||||
ToastTitle
|
||||
ToastTitle,
|
||||
Dialog,
|
||||
DialogSurface,
|
||||
DialogBody,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions
|
||||
} from '@fluentui/react-components';
|
||||
import {
|
||||
NumberSymbol24Regular,
|
||||
@@ -25,7 +31,7 @@ import {
|
||||
} from '@fluentui/react-icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useLayout } from '../context/LayoutContext';
|
||||
import { saveDraft, getDraft, createPost } from '../api';
|
||||
import { saveDraft, getDraft, createPost, uploadImage } from '../api';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
container: {
|
||||
@@ -82,6 +88,10 @@ const useStyles = makeStyles({
|
||||
},
|
||||
tagButtonWrapper: {
|
||||
position: 'relative',
|
||||
},
|
||||
imageActions: {
|
||||
display: 'flex',
|
||||
gap: tokens.spacingHorizontalS,
|
||||
}
|
||||
});
|
||||
|
||||
@@ -128,9 +138,14 @@ const CreatePost: React.FC = () => {
|
||||
// 标签输入相关状态
|
||||
const [showTagInput, setShowTagInput] = useState(false);
|
||||
const [tagInputValue, setTagInputValue] = useState("");
|
||||
const [showImageDialog, setShowImageDialog] = useState(false);
|
||||
const [showUrlDialog, setShowUrlDialog] = useState(false);
|
||||
const [imageUrl, setImageUrl] = useState("");
|
||||
|
||||
const tagInputRef = useRef<HTMLDivElement>(null);
|
||||
const tagButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const editorApiRef = useRef<any>(null);
|
||||
const valueRef = useRef<string>("");
|
||||
const autoSaveIntervalRef = useRef<number | null>(null);
|
||||
const lastInputAtRef = useRef<number | null>(null);
|
||||
@@ -192,6 +207,74 @@ const CreatePost: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const insertImageMarkdown = (url: string, altText: string = 'image') => {
|
||||
const markdown = ``;
|
||||
const api = editorApiRef.current;
|
||||
if (api && typeof api.replaceSelection === 'function') {
|
||||
api.replaceSelection(markdown);
|
||||
} else {
|
||||
setValue(prev => `${prev || ''}\n${markdown}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLocalUpload = () => {
|
||||
setShowImageDialog(false);
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileChange: React.ChangeEventHandler<HTMLInputElement> = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const url = await uploadImage(file);
|
||||
insertImageMarkdown(url, file.name || 'image');
|
||||
} catch (error: any) {
|
||||
const msg = String(error?.message || '');
|
||||
if (msg.includes('UPLOAD_TOO_LARGE')) {
|
||||
dispatchToast(
|
||||
<Toast>
|
||||
<ToastTitle>上传的图片超出限制大小</ToastTitle>
|
||||
</Toast>,
|
||||
{ intent: 'error' }
|
||||
);
|
||||
} else if (msg.includes('UNSUPPORTED_FORMAT')) {
|
||||
dispatchToast(
|
||||
<Toast>
|
||||
<ToastTitle>上传的文件类型不支持</ToastTitle>
|
||||
</Toast>,
|
||||
{ intent: 'error' }
|
||||
);
|
||||
} else {
|
||||
dispatchToast(
|
||||
<Toast>
|
||||
<ToastTitle>图片上传失败</ToastTitle>
|
||||
</Toast>,
|
||||
{ intent: 'error' }
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleUrlConfirm = () => {
|
||||
const url = imageUrl.trim();
|
||||
if (!url) {
|
||||
dispatchToast(
|
||||
<Toast>
|
||||
<ToastTitle>请输入图片URL</ToastTitle>
|
||||
</Toast>,
|
||||
{ intent: 'error' }
|
||||
);
|
||||
return;
|
||||
}
|
||||
insertImageMarkdown(url, 'image');
|
||||
setImageUrl('');
|
||||
setShowUrlDialog(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
@@ -295,8 +378,31 @@ const CreatePost: React.FC = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const commands = React.useMemo(() => {
|
||||
const base = getCommands();
|
||||
return base.map((cmd: any) => {
|
||||
if (cmd?.name === 'image' || cmd?.keyCommand === 'image') {
|
||||
return {
|
||||
...cmd,
|
||||
execute: (_state: any, api: any) => {
|
||||
editorApiRef.current = api;
|
||||
setShowImageDialog(true);
|
||||
}
|
||||
};
|
||||
}
|
||||
return cmd;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<div className={styles.editorWrapper} data-color-mode={isDarkMode ? "dark" : "light"}>
|
||||
<MDEditor
|
||||
value={value}
|
||||
@@ -306,7 +412,7 @@ const CreatePost: React.FC = () => {
|
||||
textareaProps={{
|
||||
placeholder: "请在此输入投稿内容...",
|
||||
}}
|
||||
commands={getCommands()}
|
||||
commands={commands}
|
||||
extraCommands={getExtraCommands()}
|
||||
previewOptions={{
|
||||
remarkPlugins: [remarkTagPlugin],
|
||||
@@ -377,6 +483,41 @@ const CreatePost: React.FC = () => {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={showImageDialog} onOpenChange={(_, data) => setShowImageDialog(!!data.open)}>
|
||||
<DialogSurface>
|
||||
<DialogBody>
|
||||
<DialogTitle>插入图片</DialogTitle>
|
||||
<DialogContent>请选择图片来源</DialogContent>
|
||||
<DialogActions>
|
||||
<div className={styles.imageActions}>
|
||||
<Button appearance="secondary" onClick={() => {}}>网盘上传</Button>
|
||||
<Button appearance="secondary" onClick={() => { setShowImageDialog(false); setShowUrlDialog(true); }}>输入URL</Button>
|
||||
<Button appearance="primary" onClick={handleLocalUpload}>本地上传</Button>
|
||||
</div>
|
||||
</DialogActions>
|
||||
</DialogBody>
|
||||
</DialogSurface>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showUrlDialog} onOpenChange={(_, data) => setShowUrlDialog(!!data.open)}>
|
||||
<DialogSurface>
|
||||
<DialogBody>
|
||||
<DialogTitle>输入图片URL</DialogTitle>
|
||||
<DialogContent>
|
||||
<Input
|
||||
value={imageUrl}
|
||||
onChange={(_, d) => setImageUrl(d.value)}
|
||||
placeholder="https://example.com/image.png"
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button appearance="secondary" onClick={() => setShowUrlDialog(false)}>取消</Button>
|
||||
<Button appearance="primary" onClick={handleUrlConfirm}>插入</Button>
|
||||
</DialogActions>
|
||||
</DialogBody>
|
||||
</DialogSurface>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
181
front/src/components/ImageViewer.tsx
Normal file
181
front/src/components/ImageViewer.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import React from 'react';
|
||||
import { makeStyles, tokens, Button, shorthands, Text } from '@fluentui/react-components';
|
||||
import { ArrowDownload24Regular, Dismiss24Regular } from '@fluentui/react-icons';
|
||||
import { getFileName } from '../api';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
overlay: {
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
backdropFilter: 'blur(2px)',
|
||||
zIndex: 1000,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
viewerContainer: {
|
||||
position: 'relative',
|
||||
maxWidth: '90vw',
|
||||
maxHeight: '85vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'transparent',
|
||||
boxShadow: 'none',
|
||||
...shorthands.borderRadius(tokens.borderRadiusLarge),
|
||||
},
|
||||
topRightControls: {
|
||||
position: 'fixed',
|
||||
top: tokens.spacingVerticalM,
|
||||
right: tokens.spacingHorizontalM,
|
||||
display: 'flex',
|
||||
gap: tokens.spacingHorizontalS,
|
||||
},
|
||||
image: {
|
||||
maxWidth: '90vw',
|
||||
maxHeight: '80vh',
|
||||
userSelect: 'none',
|
||||
cursor: 'grab',
|
||||
display: 'block',
|
||||
...shorthands.borderRadius(tokens.borderRadiusMedium),
|
||||
boxShadow: tokens.shadow8,
|
||||
},
|
||||
filename: {
|
||||
position: 'fixed',
|
||||
bottom: tokens.spacingVerticalS,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
backgroundColor: 'rgba(0,0,0,0.4)',
|
||||
color: '#fff',
|
||||
...shorthands.padding(tokens.spacingVerticalXS, tokens.spacingHorizontalS),
|
||||
...shorthands.borderRadius(tokens.borderRadiusSmall),
|
||||
},
|
||||
});
|
||||
|
||||
export interface ImageViewerProps {
|
||||
src: string;
|
||||
alt?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const clamp = (val: number, min: number, max: number) => Math.min(max, Math.max(min, val));
|
||||
|
||||
const extractPath = (src: string): string | null => {
|
||||
try {
|
||||
const url = new URL(src, window.location.href);
|
||||
const parts = url.pathname.split('/').filter(Boolean);
|
||||
return parts.pop() || null;
|
||||
} catch {
|
||||
const parts = src.split('?')[0].split('/').filter(Boolean);
|
||||
return parts.pop() || null;
|
||||
}
|
||||
};
|
||||
|
||||
const ImageViewer: React.FC<ImageViewerProps> = ({ src, alt, onClose }) => {
|
||||
const styles = useStyles();
|
||||
const [scale, setScale] = React.useState(1);
|
||||
const [dragging, setDragging] = React.useState(false);
|
||||
const [offset, setOffset] = React.useState({ x: 0, y: 0 });
|
||||
const [filename, setFilename] = React.useState<string>('image');
|
||||
const startRef = React.useRef<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||
const offsetRef = React.useRef<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||
|
||||
React.useEffect(() => {
|
||||
setScale(1);
|
||||
setOffset({ x: 0, y: 0 });
|
||||
offsetRef.current = { x: 0, y: 0 };
|
||||
}, [src]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const path = extractPath(src);
|
||||
const fallback = path || 'image';
|
||||
setFilename(fallback);
|
||||
if (!path) return;
|
||||
getFileName(path)
|
||||
.then((name) => setFilename(name || fallback))
|
||||
.catch(() => setFilename(fallback));
|
||||
}, [src]);
|
||||
|
||||
const handleWheel: React.WheelEventHandler<HTMLImageElement> = (e) => {
|
||||
e.preventDefault();
|
||||
const next = clamp(scale + (e.deltaY < 0 ? 0.15 : -0.15), 0.5, 5);
|
||||
setScale(next);
|
||||
};
|
||||
|
||||
const handleMouseDown: React.MouseEventHandler<HTMLImageElement> = (e) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
startRef.current = { x: e.clientX, y: e.clientY };
|
||||
offsetRef.current = { ...offset };
|
||||
};
|
||||
|
||||
const handleMouseMove: React.MouseEventHandler<HTMLImageElement> = (e) => {
|
||||
if (!dragging) return;
|
||||
const dx = e.clientX - startRef.current.x;
|
||||
const dy = e.clientY - startRef.current.y;
|
||||
setOffset({ x: offsetRef.current.x + dx, y: offsetRef.current.y + dy });
|
||||
};
|
||||
|
||||
const handleMouseUpOrLeave: React.MouseEventHandler<HTMLImageElement> = () => {
|
||||
setDragging(false);
|
||||
};
|
||||
|
||||
const handleDoubleClick: React.MouseEventHandler<HTMLImageElement> = () => {
|
||||
setScale((s) => (s > 1 ? 1 : 2));
|
||||
setOffset({ x: 0, y: 0 });
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
try {
|
||||
const res = await fetch(src, { mode: 'cors' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const blob = await res.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = objectUrl;
|
||||
a.download = filename || 'image';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(objectUrl), 1500);
|
||||
} catch {
|
||||
const a = document.createElement('a');
|
||||
a.href = src;
|
||||
a.target = '_blank';
|
||||
a.rel = 'noopener';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.overlay} onClick={onClose}>
|
||||
<div className={styles.topRightControls}>
|
||||
<Button appearance="subtle" icon={<ArrowDownload24Regular />} aria-label="下载图片" onClick={handleDownload} />
|
||||
<Button appearance="subtle" icon={<Dismiss24Regular />} aria-label="关闭查看器" onClick={onClose} />
|
||||
</div>
|
||||
<div className={styles.viewerContainer} onClick={(e) => e.stopPropagation()}>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt || 'image'}
|
||||
className={styles.image}
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUpOrLeave}
|
||||
onMouseLeave={handleMouseUpOrLeave}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
style={{ transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale})` }}
|
||||
/>
|
||||
</div>
|
||||
<Text size={100} className={styles.filename}>{filename}</Text>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageViewer;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { makeStyles, tokens, FluentProvider, webLightTheme, webDarkTheme, Toaster, useId } from '@fluentui/react-components';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import Header from './components/Header';
|
||||
import Sidebar from './components/Sidebar';
|
||||
@@ -51,7 +52,7 @@ const useStyles = makeStyles({
|
||||
}
|
||||
});
|
||||
|
||||
const LayoutContent = ({ toasterId }: { toasterId: string }) => {
|
||||
const LayoutContent = ({ toasterId, imageViewer }: { toasterId: string; imageViewer?: ReactNode }) => {
|
||||
const styles = useStyles();
|
||||
const { isDarkMode } = useLayout();
|
||||
|
||||
@@ -70,16 +71,17 @@ const LayoutContent = ({ toasterId }: { toasterId: string }) => {
|
||||
</div>
|
||||
<Footer />
|
||||
<Toaster toasterId={toasterId} position="top-end" />
|
||||
{imageViewer}
|
||||
</div>
|
||||
</FluentProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const MainLayout = () => {
|
||||
export const MainLayout = ({ imageViewer }: { imageViewer?: ReactNode }) => {
|
||||
const toasterId = useId('toaster');
|
||||
return (
|
||||
<LayoutProvider toasterId={toasterId}>
|
||||
<LayoutContent toasterId={toasterId} />
|
||||
<LayoutContent toasterId={toasterId} imageViewer={imageViewer} />
|
||||
</LayoutProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -62,13 +62,13 @@ const Header = () => {
|
||||
appearance="transparent"
|
||||
icon={isDarkMode ? <WeatherSunny24Regular /> : <WeatherMoon24Regular />}
|
||||
onClick={toggleTheme}
|
||||
title="Toggle Theme"
|
||||
title="切换主题"
|
||||
/>
|
||||
{settings?.enableCodeIcon && (
|
||||
<Button
|
||||
appearance="transparent"
|
||||
icon={<Code24Regular />}
|
||||
title="Source Code"
|
||||
title="项目源代码"
|
||||
onClick={() => window.open(settings.repoUrl, '_blank', 'noopener,noreferrer')}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user