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

309
api/doc.json Normal file
View File

@@ -0,0 +1,309 @@
{
"schemes": [],
"swagger": "2.0",
"info": {
"description": "",
"title": "Golang 2025 Final Project",
"termsOfService": "http://swagger.io/terms",
"contact": {},
"license": {
"name": "0BSD"
},
"version": "0.0.1"
},
"host": "",
"basePath": "/",
"paths": {
"/api/alias/{id}": {
"delete": {
"description": "delete alias along with the links",
"summary": "Delete alias",
"parameters": [
{
"type": "integer",
"description": "Alias Id",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK"
},
"401": {
"description": "Unauthorized"
},
"404": {
"description": "Not Found"
}
}
}
},
"/api/aliases": {
"get": {
"description": "get alias ids and names",
"summary": "Get aliases",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/api.getAliasesOutputAlias"
}
}
},
"400": {
"description": "Bad Request"
},
"401": {
"description": "Unauthorized"
}
}
}
},
"/api/image": {
"post": {
"consumes": [
"image/png",
"image/jpeg",
"image/gif"
],
"parameters": [
{
"type": "string",
"description": "userinfo from /auth/gen-login-url",
"name": "userinfo",
"in": "header",
"required": true
}
],
"responses": {
"200": {
"description": "OK"
},
"401": {
"description": "Unauthorized"
}
}
}
},
"/api/image/{id}": {
"delete": {
"description": "delete image along with the links",
"summary": "Delete image",
"parameters": [
{
"type": "integer",
"description": "Image Id",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK"
},
"401": {
"description": "Unauthorized"
},
"404": {
"description": "Not Found"
}
}
}
},
"/api/image/{id}/aliases": {
"put": {
"parameters": [
{
"type": "integer",
"description": "Image Id",
"name": "id",
"in": "path",
"required": true
},
{
"description": "Payload",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/api.putImageAliasesInput"
}
}
],
"responses": {
"200": {
"description": "OK"
},
"401": {
"description": "Unauthorized"
},
"404": {
"description": "Not Found"
}
}
}
},
"/api/images": {
"get": {
"parameters": [
{
"type": "array",
"items": {
"type": "integer"
},
"collectionFormat": "csv",
"description": "Image Ids",
"name": "images",
"in": "query"
},
{
"type": "array",
"items": {
"type": "integer"
},
"collectionFormat": "csv",
"description": "Alias Ids",
"name": "aliases",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/api.getImagesOutputImage"
}
}
},
"400": {
"description": "Bad Request"
},
"401": {
"description": "Unauthorized"
}
}
}
},
"/auth/gen-login-url": {
"post": {
"parameters": [
{
"description": "Payload",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/auth.postGenLoginUrlInput"
}
}
],
"responses": {
"200": {
"description": "Payload",
"schema": {
"$ref": "#/definitions/auth.postGenLoginUrlOutput"
}
},
"400": {
"description": "Bad Request"
}
}
}
},
"/auth/login": {
"post": {
"parameters": [
{
"description": "payload",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/auth.postLoginInput"
}
}
],
"responses": {
"200": {
"description": "OK"
}
}
}
}
},
"definitions": {
"api.getAliasesOutputAlias": {
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"name": {
"type": "string"
}
}
},
"api.getImagesOutputImage": {
"type": "object",
"properties": {
"aliasesIds": {
"type": "array",
"items": {
"type": "integer"
}
},
"id": {
"type": "integer"
},
"uploadedAt": {
"type": "integer"
},
"uploadedUserId": {
"type": "string"
}
}
},
"api.putImageAliasesInput": {
"type": "object",
"properties": {
"aliases": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"auth.postGenLoginUrlInput": {
"type": "object",
"properties": {
"userId": {
"type": "string"
}
}
},
"auth.postGenLoginUrlOutput": {
"type": "object",
"properties": {
"loginUrl": {
"type": "string"
}
}
},
"auth.postLoginInput": {
"type": "object",
"properties": {
"token": {
"type": "string"
}
}
}
}
}

View File

@@ -1,5 +1,5 @@
{
"name": "final-project",
"name": "frontend-web",
"version": "1.0.0",
"description": "2025 Golang Final Project",
"main": "index.js",

5
tests/cookies.txt Normal file
View File

@@ -0,0 +1,5 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
localhost FALSE / FALSE 1765468235 refresh_token eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3NjU0NjgyMzUsImlhdCI6MTc2NTQ2NDYzNSwidXNlcl9pZCI6IjYyNzQ1OTI4MDA0MzM3NjY4OSJ9.lOYJVE1GSOmDozP55xLQwa2bFEpzJUHt4vzJKGdzNec

22
tests/ping-get-aliases.bash Executable file
View File

@@ -0,0 +1,22 @@
#!/bin/bash
# Check if TOKEN is provided
if [ -z "$TOKEN" ]; then
echo "Error: TOKEN environment variable is required"
echo "Usage: TOKEN=your_token_here ./ping-get-aliases.bash"
exit 1
fi
curl -X POST http://localhost:8080/auth/login \
-H "Content-Type: application/json" \
-d "{\"token\":\"$TOKEN\"}" \
-c cookies.txt \
-v > test.log 2>&1
# Parse refresh_token from cookies.txt
REFRESH_TOKEN=$(grep refresh_token cookies.txt | awk '{print $7}')
curl -b "refresh_token=$REFRESH_TOKEN" http://localhost:8080/api/aliases -v >> test.log 2>&1
# Display the result
echo "Test completed. Check test.log for details."

26
tests/ping-post-image.bash Executable file
View File

@@ -0,0 +1,26 @@
#!/bin/bash
# Check if TOKEN is provided
if [ -z "$TOKEN" ]; then
echo "Error: TOKEN environment variable is required"
echo "Usage: TOKEN=your_token_here ./ping-get-aliases.bash"
exit 1
fi
curl -X POST http://localhost:8080/auth/login \
-H "Content-Type: application/json" \
-d "{\"token\":\"$TOKEN\"}" \
-c cookies.txt \
-v > test.log 2>&1
# Parse refresh_token from cookies.txt
REFRESH_TOKEN=$(grep refresh_token cookies.txt | awk '{print $7}')
curl \
-X POST http://localhost:8080/api/image \
-b "refresh_token=$REFRESH_TOKEN" \
-v \
>> test.log 2>&1
# Display the result
echo "Test completed. Check test.log for details."

54
tests/test.log Normal file
View File

@@ -0,0 +1,54 @@
Note: Unnecessary use of -X or --request, POST is already inferred.
* Host localhost:8080 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0* Trying [::1]:8080...
* Connected to localhost (::1) port 8080
> POST /auth/login HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/8.7.1
> Accept: */*
> Content-Type: application/json
> Content-Length: 36
>
} [36 bytes data]
* upload completely sent off: 36 bytes
< HTTP/1.1 200 OK
< Access-Control-Allow-Credentials: true
< Access-Control-Allow-Origin: http://localhost:48763
* Added cookie refresh_token="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3NjU0NjgyMzUsImlhdCI6MTc2NTQ2NDYzNSwidXNlcl9pZCI6IjYyNzQ1OTI4MDA0MzM3NjY4OSJ9.lOYJVE1GSOmDozP55xLQwa2bFEpzJUHt4vzJKGdzNec" for domain localhost, path /, expire 1765468235
< Set-Cookie: refresh_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3NjU0NjgyMzUsImlhdCI6MTc2NTQ2NDYzNSwidXNlcl9pZCI6IjYyNzQ1OTI4MDA0MzM3NjY4OSJ9.lOYJVE1GSOmDozP55xLQwa2bFEpzJUHt4vzJKGdzNec; Path=/; Expires=Thu, 11 Dec 2025 15:50:35 GMT
< Date: Thu, 11 Dec 2025 14:50:35 GMT
< Content-Length: 35
< Content-Type: text/plain; charset=utf-8
<
{ [35 bytes data]
100 71 100 35 100 36 1724 1773 --:--:-- --:--:-- --:--:-- 3550
* Connection #0 to host localhost left intact
{"code":200, "message": "success"}
* Host localhost:8080 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0* Trying [::1]:8080...
* Connected to localhost (::1) port 8080
> POST /api/image HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/8.7.1
> Accept: */*
> Cookie: refresh_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3NjU0NjgyMzUsImlhdCI6MTc2NTQ2NDYzNSwidXNlcl9pZCI6IjYyNzQ1OTI4MDA0MzM3NjY4OSJ9.lOYJVE1GSOmDozP55xLQwa2bFEpzJUHt4vzJKGdzNec
>
* Request completely sent off
< HTTP/1.1 404 Not Found
< Content-Type: text/plain; charset=utf-8
< X-Content-Type-Options: nosniff
< Date: Thu, 11 Dec 2025 14:50:35 GMT
< Content-Length: 19
<
{ [19 bytes data]

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;
}

View File

@@ -6,4 +6,8 @@ export default defineConfig({
plugins: [
react(),
],
server: {
port: 48763,
strictPort: false,
},
})