diff --git a/api/doc.json b/api/doc.json new file mode 100644 index 0000000..8ab25de --- /dev/null +++ b/api/doc.json @@ -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" + } + } + } + } +} \ No newline at end of file diff --git a/package.json b/package.json index 8678029..40dc46c 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "final-project", + "name": "frontend-web", "version": "1.0.0", "description": "2025 Golang Final Project", "main": "index.js", diff --git a/tests/cookies.txt b/tests/cookies.txt new file mode 100644 index 0000000..ac42b8b --- /dev/null +++ b/tests/cookies.txt @@ -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 diff --git a/tests/ping-get-aliases.bash b/tests/ping-get-aliases.bash new file mode 100755 index 0000000..36896d0 --- /dev/null +++ b/tests/ping-get-aliases.bash @@ -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." diff --git a/tests/ping-post-image.bash b/tests/ping-post-image.bash new file mode 100755 index 0000000..bc9a903 --- /dev/null +++ b/tests/ping-post-image.bash @@ -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." diff --git a/tests/test.log b/tests/test.log new file mode 100644 index 0000000..53b6986 --- /dev/null +++ b/tests/test.log @@ -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] + 100 19 100 19 0 0 6438 0 --:--:-- --:--:-- --:--:-- 9500 +* Connection #0 to host localhost left intact +404 page not found diff --git a/webpage/src/App.tsx b/webpage/src/App.tsx index df61364..a372f6d 100644 --- a/webpage/src/App.tsx +++ b/webpage/src/App.tsx @@ -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([]); - const [allAliases, setAllAliases] = useState([]); + const [allAliases, setAllAliases] = useState([]); const [searchQuery, setSearchQuery] = useState(''); const [currentPage, setCurrentPage] = useState(1); const [selectedImage, setSelectedImage] = useState(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 ; + } + // 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([]); -// const [allAliases, setAllAliases] = useState([]); -// const [searchQuery, setSearchQuery] = useState(''); -// const [selectedAlias, setSelectedAlias] = useState(null); -// const [selectedImage, setSelectedImage] = useState(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 ( -//
-// setShowUploadModal(true)} -// /> -//
-// -//
-// -// {loading ? ( -//
-//
Loading...
-//
-// ) : ( -// -// )} -//
-//
- -// {selectedImage && ( -// setSelectedImage(null)} -// onSave={handleSaveImage} -// onDelete={handleDeleteImage} -// /> -// )} - -// {showUploadModal && ( -// setShowUploadModal(false)} -// onUpload={handleUpload} -// /> -// )} -//
-// ); -// } - -// export default App; \ No newline at end of file +export default App; \ No newline at end of file diff --git a/webpage/src/api.ts b/webpage/src/api.ts index 047a023..f4c9c94 100644 --- a/webpage/src/api.ts +++ b/webpage/src/api.ts @@ -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 { + 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 { 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 { - 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 { + 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 { - 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 { + 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 { - 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 { + 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 { - 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 { + 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 { - 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 { + 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 { - 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 { + 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 { - 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}`; } } diff --git a/webpage/src/components/ImageGrid.tsx b/webpage/src/components/ImageGrid.tsx index b347e1f..8583abc 100644 --- a/webpage/src/components/ImageGrid.tsx +++ b/webpage/src/components/ImageGrid.tsx @@ -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); - // 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 (
@@ -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" > {image.aliases.join(', diff --git a/webpage/src/components/ImageModal.tsx b/webpage/src/components/ImageModal.tsx index 983e3a1..dcb9eda 100644 --- a/webpage/src/components/ImageModal.tsx +++ b/webpage/src/components/ImageModal.tsx @@ -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; - onDelete: (imageId: string) => Promise; + onSave: (imageId: number, aliases: string[]) => Promise; + onDelete: (imageId: number) => Promise; } export default function ImageModal({ @@ -14,12 +15,20 @@ export default function ImageModal({ onSave, onDelete, }: ImageModalProps) { - const [aliases, setAliases] = useState(image.aliases); + const [aliases, setAliases] = useState([]); 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({
{image.aliases.join(',
+

+ Image ID: {image.id} +

Uploaded Time:{' '} - {new Date(image.uploaded_at).toLocaleString()} + {new Date(image.uploadedAt * 1000).toLocaleString()}

Uploaded By:{' '} - {image.uploaded_user_id} + {image.uploadedUserId}

diff --git a/webpage/src/components/Sidebar.tsx b/webpage/src/components/Sidebar.tsx index 9e3e1a7..ab9fb21 100644 --- a/webpage/src/components/Sidebar.tsx +++ b/webpage/src/components/Sidebar.tsx @@ -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({
    {aliases.map((alias) => ( -
  • +
  • - {alias} + #{alias.id} + {alias.name}
  • ))} diff --git a/webpage/src/components/UploadModal.tsx b/webpage/src/components/UploadModal.tsx index 7513e95..e40e40c 100644 --- a/webpage/src/components/UploadModal.tsx +++ b/webpage/src/components/UploadModal.tsx @@ -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 */}
))}
+ {/* Green "+" Button: Add new alias to the list */} + {/* Upload Button: Upload image with aliases to server */} + + )} +
+
+ + ); +} \ No newline at end of file diff --git a/webpage/src/types.ts b/webpage/src/types.ts index b90dc90..ee2fa71 100644 --- a/webpage/src/types.ts +++ b/webpage/src/types.ts @@ -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; } \ No newline at end of file diff --git a/webpage/vite.config.ts b/webpage/vite.config.ts index d45cae6..c78c228 100644 --- a/webpage/vite.config.ts +++ b/webpage/vite.config.ts @@ -6,4 +6,8 @@ export default defineConfig({ plugins: [ react(), ], + server: { + port: 48763, + strictPort: false, + }, })