finished uploading image

This commit is contained in:
Penguin-71630
2025-12-12 00:27:45 +08:00
parent 85faf9d2ec
commit 1081d6b07c
15 changed files with 734 additions and 219 deletions

View File

@@ -6,21 +6,52 @@ import ImageGrid from './components/ImageGrid';
import ImageModal from './components/ImageModal';
import UploadModal from './components/UploadModal';
import Pagination from './components/Pagination';
import { api, ALIASES_PER_PAGE } from './api-mock';
import type { Image } from './types';
import Login from './pages/Login';
import { api, ALIASES_PER_PAGE } from './api';
import type { Image, Alias } from './types';
function App() {
const [isLoginMode, setIsLoginMode] = useState(false);
const [images, setImages] = useState<Image[]>([]);
const [allAliases, setAllAliases] = useState<string[]>([]);
const [allAliases, setAllAliases] = useState<Alias[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [selectedImage, setSelectedImage] = useState<Image | null>(null);
const [showUploadModal, setShowUploadModal] = useState(false);
const [loading, setLoading] = useState(true);
console.log("Hello");
// Check if URL has a login token
useEffect(() => {
loadData();
}, [searchQuery]);
const urlParams = new URLSearchParams(window.location.search);
const token = urlParams.get('token');
if (token) {
console.log("Access token: ", token);
// Call the login API to set the refresh_token cookie
api.login(token)
.then(() => {
console.log("Login successful");
// Remove token from URL for security
window.history.replaceState({}, document.title, '/');
// Exit login mode and load data
setIsLoginMode(false);
loadData();
})
.catch((error) => {
console.error("Login failed:", error);
setIsLoginMode(true); // Show login page on error
});
} else {
loadData();
}
}, []);
useEffect(() => {
if (!isLoginMode) {
loadData();
}
}, [searchQuery, isLoginMode]);
useEffect(() => {
// Reset to page 1 when search query changes
@@ -30,15 +61,19 @@ function App() {
const loadData = async () => {
setLoading(true);
try {
const [imagesData, aliasesData] = await Promise.all([
api.getImages({
search: searchQuery || undefined,
}),
api.getAllAliases(),
]);
const aliasesData = await api.getAllAliases();
setAllAliases(aliasesData);
// Get images for all aliases or filtered aliases
const aliasIds = searchQuery
? aliasesData.filter(a => a.name.toLowerCase().includes(searchQuery.toLowerCase())).map(a => a.id)
: aliasesData.map(a => a.id);
const imagesData = aliasIds.length > 0
? await api.getImages({ aliasIds })
: [];
setImages(imagesData);
setAllAliases(aliasesData);
} catch (error) {
console.error('Failed to load data:', error);
} finally {
@@ -46,25 +81,41 @@ function App() {
}
};
const handleLoginSuccess = () => {
setIsLoginMode(false);
loadData();
};
const handleUpload = async (file: File, aliases: string[]) => {
await api.uploadImage(file, aliases);
console.log("Trying to upload image:");
console.log(file.name);
console.log(aliases);
const image = await api.uploadImage(file);
if (aliases.length > 0) {
await api.updateImageAliases(image.id, aliases);
}
await loadData();
};
const handleSaveImage = async (imageId: string, aliases: string[]) => {
const handleSaveImage = async (imageId: number, aliases: string[]) => {
await api.updateImageAliases(imageId, aliases);
await loadData();
};
const handleDeleteImage = async (imageId: string) => {
const handleDeleteImage = async (imageId: number) => {
await api.deleteImage(imageId);
await loadData();
};
// Show login page if in login mode
if (isLoginMode) {
return <Login onLoginSuccess={handleLoginSuccess} />;
}
// Filter aliases based on search
const filteredAliases = searchQuery
? allAliases.filter(alias =>
alias.toLowerCase().includes(searchQuery.toLowerCase())
alias.name.toLowerCase().includes(searchQuery.toLowerCase())
)
: allAliases;
@@ -75,9 +126,10 @@ function App() {
const currentPageAliases = filteredAliases.slice(startIndex, endIndex);
// Filter images to only show those with current page aliases
const currentPageAliasIds = currentPageAliases.map(a => a.id);
const currentPageImages = images.filter(img =>
img.aliases.some(alias => currentPageAliases.includes(alias)) ||
(img.aliases.length === 0 && currentPageAliases.length > 0)
img.aliasesIds.some(aliasId => currentPageAliasIds.includes(aliasId)) ||
(img.aliasesIds.length === 0 && currentPageAliases.length > 0)
);
const handlePageChange = (newPage: number) => {
@@ -154,115 +206,4 @@ function App() {
);
}
export default App;
// import { useState, useEffect } from 'react';
// import Navbar from './components/Navbar';
// import Sidebar from './components/Sidebar';
// import SearchBar from './components/SearchBar';
// import ImageGrid from './components/ImageGrid';
// import ImageModal from './components/ImageModal';
// import UploadModal from './components/UploadModal';
// import { api } from './api-mock';
// import type { Image } from './types';
// function App() {
// const [images, setImages] = useState<Image[]>([]);
// const [allAliases, setAllAliases] = useState<string[]>([]);
// const [searchQuery, setSearchQuery] = useState('');
// const [selectedAlias, setSelectedAlias] = useState<string | null>(null);
// const [selectedImage, setSelectedImage] = useState<Image | null>(null);
// const [showUploadModal, setShowUploadModal] = useState(false);
// const [loading, setLoading] = useState(true);
// useEffect(() => {
// loadData();
// }, [searchQuery, selectedAlias]);
// const loadData = async () => {
// setLoading(true);
// try {
// const [imagesData, aliasesData] = await Promise.all([
// api.getImages({
// search: searchQuery || undefined,
// null_alias: selectedAlias === null ? true : undefined,
// }),
// api.getAllAliases(),
// ]);
// let filteredImages = imagesData;
// if (selectedAlias) {
// filteredImages = imagesData.filter((img) =>
// img.aliases.includes(selectedAlias)
// );
// } else if (selectedAlias === null) {
// filteredImages = imagesData.filter((img) => img.aliases.length === 0);
// }
// setImages(filteredImages);
// setAllAliases(aliasesData);
// } catch (error) {
// console.error('Failed to load data:', error);
// } finally {
// setLoading(false);
// }
// };
// const handleUpload = async (file: File, aliases: string[]) => {
// await api.uploadImage(file, aliases);
// await loadData();
// };
// const handleSaveImage = async (imageId: string, aliases: string[]) => {
// await api.updateImageAliases(imageId, aliases);
// await loadData();
// };
// const handleDeleteImage = async (imageId: string) => {
// await api.deleteImage(imageId);
// await loadData();
// };
// return (
// <div className="h-screen flex flex-col">
// <Navbar
// onUploadClick={() => setShowUploadModal(true)}
// />
// <div className="flex-1 flex overflow-hidden">
// <Sidebar
// aliases={allAliases}
// selectedAlias={selectedAlias}
// onAliasClick={setSelectedAlias}
// />
// <main className="flex-1 overflow-y-auto">
// <SearchBar value={searchQuery} onChange={setSearchQuery} />
// {loading ? (
// <div className="flex items-center justify-center h-64">
// <div className="text-gray-500">Loading...</div>
// </div>
// ) : (
// <ImageGrid images={images} onImageClick={setSelectedImage} />
// )}
// </main>
// </div>
// {selectedImage && (
// <ImageModal
// image={selectedImage}
// onClose={() => setSelectedImage(null)}
// onSave={handleSaveImage}
// onDelete={handleDeleteImage}
// />
// )}
// {showUploadModal && (
// <UploadModal
// onClose={() => setShowUploadModal(false)}
// onUpload={handleUpload}
// />
// )}
// </div>
// );
// }
// export default App;
export default App;

View File

@@ -1,4 +1,4 @@
import type { Image } from './types';
import type { Image, Alias } from './types';
const API_BASE_URL = 'http://localhost:8080';
@@ -6,97 +6,136 @@ const API_BASE_URL = 'http://localhost:8080';
export const ALIASES_PER_PAGE = 5; // Number of aliases to show per page
class ApiService {
// Images
// Authentication
async login(token: string): Promise<void> {
console.log("Hello, someone is trying to access the web page.");
console.log("Token: ", token);
const response = await fetch(`${API_BASE_URL}/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include', // IMPORTANT: Allows cookies to be set
body: JSON.stringify({ token }),
});
if (!response.ok) {
const errorText = await response.text();
console.log("Login failed");
throw new Error(errorText || 'Login failed');
}
console.log("Login successful");
}
// GET /api/images?images=1,2,3&aliases=1,2,3
// Returns: Image[] with {id, uploadedUserId, uploadedAt, aliasesIds, extension}
async getImages(params?: {
search?: string;
null_alias?: boolean;
limit?: number;
page?: number;
imageIds?: number[];
aliasIds?: number[];
}): Promise<Image[]> {
const queryParams = new URLSearchParams();
if (params?.search) queryParams.append('search', params.search);
if (params?.null_alias) queryParams.append('null_alias', 'true');
if (params?.limit) queryParams.append('limit', params.limit.toString());
if (params?.page) queryParams.append('page', params.page.toString());
if (params?.imageIds && params.imageIds.length > 0) {
queryParams.append('images', params.imageIds.join(','));
}
if (params?.aliasIds && params.aliasIds.length > 0) {
queryParams.append('aliases', params.aliasIds.join(','));
}
const response = await fetch(
`${API_BASE_URL}/api/images?${queryParams}`
`${API_BASE_URL}/api/images?${queryParams}`,
{ credentials: 'include' }
);
if (!response.ok) throw new Error('Failed to fetch images');
const data = await response.json();
return data.images || [];
return data || [];
}
async getImage(id: string): Promise<Image> {
const response = await fetch(`${API_BASE_URL}/api/images/${id}`);
// GET /api/images?images={id}
// Returns single image by querying with image ID
async getImage(id: number): Promise<Image> {
const response = await fetch(`${API_BASE_URL}/api/images?images=${id}`, {
credentials: 'include',
});
if (!response.ok) throw new Error('Failed to fetch image');
return response.json();
const data = await response.json();
return data[0];
}
async uploadImage(file: File, aliases: string[]): Promise<Image> {
const formData = new FormData();
formData.append('imgfile', file);
aliases.forEach((alias) => formData.append('aliases', alias));
const response = await fetch(`${API_BASE_URL}/api/images`, {
// POST /api/image
// Content-Type: image/png, image/jpeg, image/gif
// Returns: {id, uploadedUserId, uploadedAt, extension}
async uploadImage(file: File): Promise<Image> {
const response = await fetch(`${API_BASE_URL}/api/image`, {
method: 'POST',
body: formData,
headers: {
'Content-Type': file.type,
},
credentials: 'include',
body: file,
});
if (!response.ok) throw new Error('Failed to upload image');
return response.json();
const result = await response.json();
// Convert uploadedAt from string to number if needed
return {
...result,
aliasesIds: [],
uploadedAt: typeof result.uploadedAt === 'string'
? new Date(result.uploadedAt).getTime() / 1000
: result.uploadedAt
};
}
async deleteImage(id: string): Promise<void> {
const response = await fetch(`${API_BASE_URL}/api/images/${id}`, {
// DELETE /api/image/{id}
// Deletes image along with the links
async deleteImage(id: number): Promise<void> {
const response = await fetch(`${API_BASE_URL}/api/image/${id}`, {
method: 'DELETE',
credentials: 'include',
});
if (!response.ok) throw new Error('Failed to delete image');
}
// Aliases
async getAllAliases(): Promise<string[]> {
const response = await fetch(`${API_BASE_URL}/api/aliases`);
if (!response.ok) throw new Error('Failed to fetch aliases');
// GET /api/aliases
// Returns: Alias[] with {id, name}
async getAllAliases(): Promise<Alias[]> {
const response = await fetch(`${API_BASE_URL}/api/aliases`, {
credentials: 'include',
});
if (!response.ok) {
console.log("Failed to fetch aliases");
throw new Error('Failed to fetch aliases');
}
const data = await response.json();
return data.aliases || [];
return data || [];
}
async updateImageAliases(id: string, aliases: string[]): Promise<Image> {
const response = await fetch(`${API_BASE_URL}/api/images/${id}/aliases`, {
// PUT /api/image/{id}/aliases
// Body: {aliases: string[]}
async updateImageAliases(id: number, aliasNames: string[]): Promise<void> {
const response = await fetch(`${API_BASE_URL}/api/image/${id}/aliases`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ aliases }),
credentials: 'include',
body: JSON.stringify({ aliases: aliasNames }),
});
if (!response.ok) throw new Error('Failed to update aliases');
return response.json();
}
async addImageAlias(id: string, alias: string): Promise<Image> {
const response = await fetch(`${API_BASE_URL}/api/images/${id}/aliases`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ alias }),
// DELETE /api/alias/{id}
// Deletes alias along with the links
async deleteAlias(id: number): Promise<void> {
const response = await fetch(`${API_BASE_URL}/api/alias/${id}`, {
method: 'DELETE',
credentials: 'include',
});
if (!response.ok) throw new Error('Failed to add alias');
return response.json();
if (!response.ok) throw new Error('Failed to delete alias');
}
async removeImageAlias(id: string, alias: string): Promise<void> {
const response = await fetch(
`${API_BASE_URL}/api/images/${id}/aliases?alias=${encodeURIComponent(alias)}`,
{
method: 'DELETE',
}
);
if (!response.ok) throw new Error('Failed to remove alias');
}
getImageUrl(id: string): string {
return `${API_BASE_URL}/api/images/${id}/file`;
// Construct image URL from image ID and extension
// Format: /img/{id}.{extension}
getImageUrl(id: number, extension: string): string {
return `${API_BASE_URL}/img/${id}.${extension}`;
}
}

View File

@@ -1,30 +1,32 @@
import type { Image } from '../types';
import type { Image, Alias } from '../types';
import { api } from '../api';
interface ImageGridProps {
images: Image[];
aliases: string[];
aliases: Alias[];
onImageClick: (image: Image) => void;
}
export default function ImageGrid({ images, aliases, onImageClick }: ImageGridProps) {
// Create a map of alias ID to alias name
const aliasMap = new Map(aliases.map(a => [a.id, a.name]));
// Group images by alias
const groupedImages = images.reduce((acc, image) => {
if (image.aliases.length === 0) {
if (image.aliasesIds.length === 0) {
if (!acc['__no_alias__']) acc['__no_alias__'] = [];
acc['__no_alias__'].push(image);
} else {
image.aliases.forEach((alias) => {
if (!acc[alias]) acc[alias] = [];
acc[alias].push(image);
image.aliasesIds.forEach((aliasId) => {
const aliasName = aliasMap.get(aliasId) || `Alias #${aliasId}`;
if (!acc[aliasName]) acc[aliasName] = [];
acc[aliasName].push(image);
});
}
return acc;
}, {} as Record<string, Image[]>);
// Filter to only show aliases on current page
const filteredGroups = Object.entries(groupedImages).filter(([alias]) =>
aliases.includes(alias) || alias === '__no_alias__'
);
const filteredGroups = Object.entries(groupedImages);
return (
<div className="p-6 space-y-8">
@@ -41,8 +43,8 @@ export default function ImageGrid({ images, aliases, onImageClick }: ImageGridPr
className="aspect-square rounded-lg overflow-hidden bg-gray-100 hover:ring-2 hover:ring-blue-500 transition-all"
>
<img
src={`http://localhost:8080${image.url}`}
alt={image.aliases.join(', ')}
src={api.getImageUrl(image.id, image.extension)}
alt={`Image ${image.id}`}
className="w-full h-full object-cover"
/>
</button>

View File

@@ -1,11 +1,12 @@
import { useState, useEffect } from 'react';
import type { Image } from '../types';
import { api } from '../api';
interface ImageModalProps {
image: Image;
onClose: () => void;
onSave: (imageId: string, aliases: string[]) => Promise<void>;
onDelete: (imageId: string) => Promise<void>;
onSave: (imageId: number, aliases: string[]) => Promise<void>;
onDelete: (imageId: number) => Promise<void>;
}
export default function ImageModal({
@@ -14,12 +15,20 @@ export default function ImageModal({
onSave,
onDelete,
}: ImageModalProps) {
const [aliases, setAliases] = useState<string[]>(image.aliases);
const [aliases, setAliases] = useState<string[]>([]);
const [newAlias, setNewAlias] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [allAliases, setAllAliases] = useState<{id: number, name: string}[]>([]);
useEffect(() => {
setAliases(image.aliases);
// Load all aliases and map image's aliasesIds to names
api.getAllAliases().then(allAliasesData => {
setAllAliases(allAliasesData);
const aliasNames = image.aliasesIds
.map(id => allAliasesData.find(a => a.id === id)?.name)
.filter((name): name is string => name !== undefined);
setAliases(aliasNames);
});
}, [image]);
const handleAddAlias = () => {
@@ -68,21 +77,24 @@ export default function ImageModal({
<div className="p-6">
<div className="mb-6 rounded-lg overflow-hidden bg-gray-100">
<img
src={`http://localhost:8080${image.url}`}
alt={image.aliases.join(', ')}
src={api.getImageUrl(image.id, image.extension)}
alt={`Image ${image.id}`}
className="w-full h-auto"
/>
</div>
<div className="space-y-4 mb-6">
<div className="text-sm text-gray-600">
<p>
<span className="font-medium">Image ID:</span> {image.id}
</p>
<p>
<span className="font-medium">Uploaded Time:</span>{' '}
{new Date(image.uploaded_at).toLocaleString()}
{new Date(image.uploadedAt * 1000).toLocaleString()}
</p>
<p>
<span className="font-medium">Uploaded By:</span>{' '}
{image.uploaded_user_id}
{image.uploadedUserId}
</p>
</div>

View File

@@ -1,5 +1,7 @@
import type { Alias } from '../types';
interface SidebarProps {
aliases: string[];
aliases: Alias[];
currentPage: number;
totalPages: number;
}
@@ -23,9 +25,10 @@ export default function Sidebar({
</div>
<ul className="space-y-1">
{aliases.map((alias) => (
<li key={alias}>
<li key={alias.id}>
<div className="w-full text-left px-3 py-2 rounded-lg bg-blue-50 text-blue-700">
{alias}
<span className="text-xs text-blue-500 mr-2">#{alias.id}</span>
{alias.name}
</div>
</li>
))}

View File

@@ -57,6 +57,10 @@ export default function UploadModal({ onClose, onUpload }: UploadModalProps) {
alert('Please select a file');
return;
}
if (aliases.length === 0) {
alert('Please add at least one alias');
return;
}
setIsUploading(true);
try {
await onUpload(file, aliases);
@@ -100,6 +104,7 @@ export default function UploadModal({ onClose, onUpload }: UploadModalProps) {
alt="Preview"
className="max-h-64 mx-auto rounded-lg"
/>
{/* Red-Rounded "X" Button at Top-Right Corner of Image Window: Remove selected image and clear preview */}
<button
onClick={() => {
setFile(null);
@@ -166,6 +171,7 @@ export default function UploadModal({ onClose, onUpload }: UploadModalProps) {
<div className="space-y-2">
{aliases.map((alias) => (
<div key={alias} className="flex items-center gap-2">
{/* Red "X" Button: Remove this alias from the list */}
<button
onClick={() => handleRemoveAlias(alias)}
className="text-red-600 hover:text-red-700"
@@ -193,6 +199,7 @@ export default function UploadModal({ onClose, onUpload }: UploadModalProps) {
</div>
))}
<div className="flex items-center gap-2">
{/* Green "+" Button: Add new alias to the list */}
<button
onClick={handleAddAlias}
className="text-green-600 hover:text-green-700"
@@ -224,15 +231,17 @@ export default function UploadModal({ onClose, onUpload }: UploadModalProps) {
</div>
<div className="flex justify-end gap-2 pt-4 border-t border-gray-200">
{/* Cancel Button: Close modal without uploading */}
<button
onClick={onClose}
className="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors font-medium"
>
Cancel
</button>
{/* Upload Button: Upload image with aliases to server */}
<button
onClick={handleUpload}
disabled={!file || isUploading}
disabled={!file || aliases.length === 0 || isUploading}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium disabled:opacity-50"
>
{isUploading ? 'Uploading...' : 'Upload'}

View File

@@ -0,0 +1,89 @@
// src/pages/Login.tsx
import { useEffect, useState } from 'react';
interface LoginProps {
onLoginSuccess: () => void;
}
export default function Login({ onLoginSuccess }: LoginProps) {
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
const [errorMessage, setErrorMessage] = useState('');
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const token = urlParams.get('token');
if (!token) {
setStatus('error');
setErrorMessage('No token provided in URL');
return;
}
// Call backend login endpoint
fetch('http://localhost:8080/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include', // IMPORTANT: This allows cookies to be set
body: JSON.stringify({ token }),
})
.then(async (res) => {
if (res.ok) {
setStatus('success');
// Remove token from URL for security
window.history.replaceState({}, document.title, '/');
// Wait a moment then notify parent
setTimeout(() => {
onLoginSuccess();
}, 1000);
} else {
const error = await res.text();
setStatus('error');
setErrorMessage(error || 'Login failed');
}
})
.catch((err) => {
setStatus('error');
setErrorMessage(err.message);
});
}, [onLoginSuccess]);
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full bg-white rounded-lg shadow-lg p-8">
<div className="text-center">
{status === 'loading' && (
<>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"></div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">Logging in...</h1>
<p className="text-gray-600">Please wait while we authenticate you.</p>
</>
)}
{status === 'success' && (
<>
<div className="text-green-500 text-5xl mb-4"></div>
<h1 className="text-2xl font-bold text-green-600 mb-2">Login Successful!</h1>
<p className="text-gray-600">Redirecting to the app...</p>
</>
)}
{status === 'error' && (
<>
<div className="text-red-500 text-5xl mb-4"></div>
<h1 className="text-2xl font-bold text-red-600 mb-2">Login Failed</h1>
<p className="text-gray-600 mb-4">{errorMessage}</p>
<button
onClick={() => window.location.href = '/'}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition"
>
Go to Home
</button>
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,12 +1,12 @@
export interface Image {
id: string;
uploaded_user_id: string;
uploaded_at: string;
aliases: string[];
url: string;
export interface Alias {
id: number;
name: string;
}
export interface User {
id: string;
username: string;
export interface Image {
id: number;
uploadedUserId: string;
uploadedAt: number;
aliasesIds: number[];
extension: string;
}