vumichien commited on
Commit
c843383
·
1 Parent(s): 212dfc1

change layout

Browse files
Files changed (4) hide show
  1. app.py +331 -71
  2. templates/index.html +777 -148
  3. templates/login.html +108 -24
  4. templates/view.html +310 -54
app.py CHANGED
@@ -14,6 +14,7 @@ import secrets
14
  from starlette.middleware.sessions import SessionMiddleware
15
  from fastapi.security import OAuth2PasswordRequestForm
16
  from fastapi.responses import JSONResponse
 
17
 
18
  # Create FastAPI app
19
  app = FastAPI(title="Image Uploader")
@@ -28,12 +29,15 @@ app.add_middleware(
28
  UPLOAD_DIR = Path("static/uploads")
29
  UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
30
 
31
- # Ensure upload directory has proper permissions (critical for Docker containers)
32
- try:
33
- import os
34
- os.chmod(UPLOAD_DIR, 0o777) # Full permissions for uploads directory
35
- except Exception as e:
36
- print(f"Warning: Could not set permissions on uploads directory: {e}")
 
 
 
37
 
38
  # Mount static directory
39
  app.mount("/static", StaticFiles(directory="static"), name="static")
@@ -71,6 +75,42 @@ def verify_auth(request: Request):
71
  )
72
  return True
73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  @app.get("/login", response_class=HTMLResponse)
75
  async def login_page(request: Request):
76
  """Render the login page."""
@@ -102,33 +142,71 @@ async def logout(request: Request):
102
  return RedirectResponse(url="/login", status_code=status.HTTP_302_FOUND)
103
 
104
  @app.get("/", response_class=HTMLResponse)
105
- async def home(request: Request):
106
- """Render the home page with authentication check."""
107
  # Check if user is authenticated
108
  if not authenticate(request):
109
  return RedirectResponse(url="/login", status_code=status.HTTP_302_FOUND)
110
 
111
- # Get all uploaded images
112
  uploaded_images = []
 
113
 
114
  if UPLOAD_DIR.exists():
115
  for file in UPLOAD_DIR.iterdir():
116
  if is_valid_image(get_file_extension(file.name)):
117
  image_url = f"/static/uploads/{file.name}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  uploaded_images.append({
119
  "name": file.name,
120
  "url": image_url,
121
- "embed_url": f"{request.base_url}static/uploads/{file.name}"
 
 
 
122
  })
123
 
 
 
 
 
 
 
124
  return templates.TemplateResponse(
125
  "index.html",
126
- {"request": request, "uploaded_images": uploaded_images}
 
 
 
 
 
 
127
  )
128
 
129
  @app.post("/upload/")
130
- async def upload_image(request: Request, file: UploadFile = File(...)):
131
- """Handle image upload with authentication check."""
 
 
 
 
132
  # Check if user is authenticated
133
  if not authenticate(request):
134
  return JSONResponse(
@@ -136,67 +214,199 @@ async def upload_image(request: Request, file: UploadFile = File(...)):
136
  content={"detail": "Not authenticated"}
137
  )
138
 
139
- # Check if the file is an image
140
- extension = get_file_extension(file.filename)
141
- if not is_valid_image(extension):
142
- raise HTTPException(status_code=400, detail="Only image files are allowed")
 
143
 
144
- # Generate a unique filename to prevent overwrites
145
- unique_filename = f"{uuid.uuid4()}{extension}"
146
- file_path = UPLOAD_DIR / unique_filename
147
 
148
- # Save the file
149
- try:
150
- # Make sure parent directory exists with proper permissions
151
- file_path.parent.mkdir(parents=True, exist_ok=True)
152
- try:
153
- os.chmod(file_path.parent, 0o777)
154
- except Exception as e:
155
- print(f"Warning: Could not set permissions: {e}")
156
-
157
- # Save file contents
158
- with open(file_path, "wb") as buffer:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  shutil.copyfileobj(file.file, buffer)
160
-
161
- # Set permissions on the file itself
162
- try:
163
- os.chmod(file_path, 0o666) # Read/write for everyone
164
- except Exception as e:
165
- print(f"Warning: Could not set file permissions: {e}")
166
-
167
- except Exception as e:
168
- raise HTTPException(
169
- status_code=500,
170
- detail=f"Failed to save file: {str(e)}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
  )
172
 
173
- # Return the file URL and embed code
174
- file_url = f"/static/uploads/{unique_filename}"
175
-
176
- # For base64 encoding
177
- file.file.seek(0) # Reset file pointer to beginning
178
- contents = await file.read()
179
- base64_encoded = base64.b64encode(contents).decode("utf-8")
180
-
181
- # Determine MIME type
182
- mime_type = {
183
- '.jpg': 'image/jpeg',
184
- '.jpeg': 'image/jpeg',
185
- '.png': 'image/png',
186
- '.gif': 'image/gif',
187
- '.bmp': 'image/bmp',
188
- '.webp': 'image/webp'
189
- }.get(extension, 'application/octet-stream')
190
-
191
- return {
192
- "success": True,
193
- "file_name": unique_filename,
194
- "file_url": file_url,
195
- "full_url": f"{request.base_url}static/uploads/{unique_filename}",
196
- "embed_html": f'<img src="{request.base_url}static/uploads/{unique_filename}" alt="Uploaded Image" />',
197
- "base64_data": f"data:{mime_type};base64,{base64_encoded[:20]}...{base64_encoded[-20:]}",
198
- "base64_embed": f'<img src="data:{mime_type};base64,{base64_encoded}" alt="Embedded Image" />'
199
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
 
201
  @app.get("/view/{file_name}")
202
  async def view_image(request: Request, file_name: str):
@@ -210,19 +420,59 @@ async def view_image(request: Request, file_name: str):
210
  if not file_path.exists():
211
  raise HTTPException(status_code=404, detail="Image not found")
212
 
 
 
 
213
  image_url = f"/static/uploads/{file_name}"
214
  embed_url = f"{request.base_url}static/uploads/{file_name}"
215
 
 
 
 
 
 
 
 
 
 
216
  return templates.TemplateResponse(
217
  "view.html",
218
  {
219
  "request": request,
220
  "image_url": image_url,
221
  "file_name": file_name,
222
- "embed_url": embed_url
 
 
223
  }
224
  )
225
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
226
  @app.delete("/delete/{file_name}")
227
  async def delete_image(request: Request, file_name: str):
228
  """Delete an image with authentication check."""
@@ -238,8 +488,15 @@ async def delete_image(request: Request, file_name: str):
238
  if not file_path.exists():
239
  raise HTTPException(status_code=404, detail="Image not found")
240
 
 
241
  os.remove(file_path)
242
 
 
 
 
 
 
 
243
  return {"success": True, "message": f"Image {file_name} has been deleted"}
244
 
245
  # Health check endpoint for Hugging Face Spaces
@@ -249,4 +506,7 @@ async def health_check():
249
 
250
  if __name__ == "__main__":
251
  # For local development
252
- uvicorn.run("app:app", host="127.0.0.1", port=8000, reload=True)
 
 
 
 
14
  from starlette.middleware.sessions import SessionMiddleware
15
  from fastapi.security import OAuth2PasswordRequestForm
16
  from fastapi.responses import JSONResponse
17
+ import json
18
 
19
  # Create FastAPI app
20
  app = FastAPI(title="Image Uploader")
 
29
  UPLOAD_DIR = Path("static/uploads")
30
  UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
31
 
32
+ # Create metadata directory for storing hashtags
33
+ METADATA_DIR = Path("static/metadata")
34
+ METADATA_DIR.mkdir(parents=True, exist_ok=True)
35
+ METADATA_FILE = METADATA_DIR / "image_metadata.json"
36
+
37
+ # Initialize metadata file if it doesn't exist
38
+ if not METADATA_FILE.exists():
39
+ with open(METADATA_FILE, "w") as f:
40
+ json.dump({}, f)
41
 
42
  # Mount static directory
43
  app.mount("/static", StaticFiles(directory="static"), name="static")
 
75
  )
76
  return True
77
 
78
+ def get_image_metadata():
79
+ """Get all image metadata including hashtags."""
80
+ if METADATA_FILE.exists():
81
+ with open(METADATA_FILE, "r") as f:
82
+ return json.load(f)
83
+ return {}
84
+
85
+ def save_image_metadata(metadata):
86
+ """Save image metadata to the JSON file."""
87
+ with open(METADATA_FILE, "w") as f:
88
+ json.dump(metadata, f)
89
+
90
+ def add_hashtags_to_image(filename, hashtags, original_filename=None):
91
+ """Add hashtags to an image."""
92
+ metadata = get_image_metadata()
93
+
94
+ # If file exists in metadata, update its hashtags, otherwise create new entry
95
+ if filename in metadata:
96
+ metadata[filename]["hashtags"] = hashtags
97
+ if original_filename:
98
+ metadata[filename]["original_filename"] = original_filename
99
+ else:
100
+ metadata_entry = {"hashtags": hashtags, "is_new": True}
101
+ if original_filename:
102
+ metadata_entry["original_filename"] = original_filename
103
+ metadata[filename] = metadata_entry
104
+
105
+ save_image_metadata(metadata)
106
+
107
+ def mark_image_as_viewed(filename):
108
+ """Mark an image as viewed (not new)"""
109
+ metadata = get_image_metadata()
110
+ if filename in metadata:
111
+ metadata[filename]["is_new"] = False
112
+ save_image_metadata(metadata)
113
+
114
  @app.get("/login", response_class=HTMLResponse)
115
  async def login_page(request: Request):
116
  """Render the login page."""
 
142
  return RedirectResponse(url="/login", status_code=status.HTTP_302_FOUND)
143
 
144
  @app.get("/", response_class=HTMLResponse)
145
+ async def home(request: Request, search: Optional[str] = None, tag: Optional[str] = None):
146
+ """Render the home page with authentication check and optional search/filter."""
147
  # Check if user is authenticated
148
  if not authenticate(request):
149
  return RedirectResponse(url="/login", status_code=status.HTTP_302_FOUND)
150
 
151
+ # Get all uploaded images and their metadata
152
  uploaded_images = []
153
+ metadata = get_image_metadata()
154
 
155
  if UPLOAD_DIR.exists():
156
  for file in UPLOAD_DIR.iterdir():
157
  if is_valid_image(get_file_extension(file.name)):
158
  image_url = f"/static/uploads/{file.name}"
159
+
160
+ # Get hashtags from metadata if available
161
+ hashtags = []
162
+ is_new = False
163
+ original_filename = file.name
164
+
165
+ if file.name in metadata:
166
+ hashtags = metadata[file.name].get("hashtags", [])
167
+ is_new = metadata[file.name].get("is_new", False)
168
+ original_filename = metadata[file.name].get("original_filename", file.name)
169
+
170
+ # If searching/filtering, check if this image should be included
171
+ if search and search.lower() not in original_filename.lower() and not any(search.lower() in tag.lower() for tag in hashtags):
172
+ continue
173
+
174
+ if tag and tag not in hashtags:
175
+ continue
176
+
177
  uploaded_images.append({
178
  "name": file.name,
179
  "url": image_url,
180
+ "embed_url": f"{request.base_url}static/uploads/{file.name}",
181
+ "hashtags": hashtags,
182
+ "is_new": is_new,
183
+ "original_filename": original_filename
184
  })
185
 
186
+ # Get all unique hashtags for the filter dropdown
187
+ all_hashtags = set()
188
+ for img_data in metadata.values():
189
+ if "hashtags" in img_data:
190
+ all_hashtags.update(img_data["hashtags"])
191
+
192
  return templates.TemplateResponse(
193
  "index.html",
194
+ {
195
+ "request": request,
196
+ "uploaded_images": uploaded_images,
197
+ "all_hashtags": sorted(list(all_hashtags)),
198
+ "current_search": search,
199
+ "current_tag": tag
200
+ }
201
  )
202
 
203
  @app.post("/upload/")
204
+ async def upload_image(
205
+ request: Request,
206
+ files: List[UploadFile] = File(...),
207
+ hashtags: str = Form("")
208
+ ):
209
+ """Handle multiple image uploads with hashtags."""
210
  # Check if user is authenticated
211
  if not authenticate(request):
212
  return JSONResponse(
 
214
  content={"detail": "Not authenticated"}
215
  )
216
 
217
+ # Process hashtags into a list
218
+ hashtag_list = []
219
+ if hashtags:
220
+ # Split by spaces or commas and remove empty strings/whitespace
221
+ hashtag_list = [tag.strip() for tag in hashtags.replace(',', ' ').split() if tag.strip()]
222
 
223
+ results = []
224
+ duplicates = []
 
225
 
226
+ # First, check for duplicate filenames
227
+ metadata = get_image_metadata()
228
+ all_files = {}
229
+ if UPLOAD_DIR.exists():
230
+ for file in UPLOAD_DIR.iterdir():
231
+ if is_valid_image(get_file_extension(file.name)):
232
+ # Get original filename from metadata if available
233
+ original_name = file.name
234
+ if file.name in metadata and "original_filename" in metadata[file.name]:
235
+ original_name = metadata[file.name]["original_filename"]
236
+ all_files[original_name.lower()] = file.name
237
+
238
+ # Check for duplicates in current upload batch
239
+ for file in files:
240
+ file_lower = file.filename.lower()
241
+ if file_lower in all_files:
242
+ # Found a duplicate
243
+ duplicates.append({
244
+ "new_file": file.filename,
245
+ "existing_file": all_files[file_lower],
246
+ "original_name": file.filename
247
+ })
248
+
249
+ # If we found duplicates, return them to the frontend for confirmation
250
+ if duplicates:
251
+ return {
252
+ "success": False,
253
+ "duplicates": duplicates,
254
+ "message": "Duplicate filenames detected",
255
+ "action_required": "confirm_replace"
256
+ }
257
+
258
+ # No duplicates, proceed with upload
259
+ for file in files:
260
+ # Check if the file is an image
261
+ extension = get_file_extension(file.filename)
262
+ if not is_valid_image(extension):
263
+ continue # Skip non-image files
264
+
265
+ # Preserve original filename in metadata but make it safe for filesystem
266
+ original_filename = file.filename
267
+
268
+ # Generate a unique filename to prevent overwrites
269
+ unique_filename = f"{uuid.uuid4()}{extension}"
270
+ file_path = UPLOAD_DIR / unique_filename
271
+
272
+ # Save the file
273
+ with file_path.open("wb") as buffer:
274
  shutil.copyfileobj(file.file, buffer)
275
+
276
+ # Save hashtags and original filename
277
+ add_hashtags_to_image(unique_filename, hashtag_list, original_filename)
278
+
279
+ # For base64 encoding
280
+ file.file.seek(0) # Reset file pointer to beginning
281
+ contents = await file.read()
282
+ base64_encoded = base64.b64encode(contents).decode("utf-8")
283
+
284
+ # Determine MIME type
285
+ mime_type = {
286
+ '.jpg': 'image/jpeg',
287
+ '.jpeg': 'image/jpeg',
288
+ '.png': 'image/png',
289
+ '.gif': 'image/gif',
290
+ '.bmp': 'image/bmp',
291
+ '.webp': 'image/webp'
292
+ }.get(extension, 'application/octet-stream')
293
+
294
+ results.append({
295
+ "success": True,
296
+ "file_name": unique_filename,
297
+ "original_filename": original_filename,
298
+ "file_url": f"/static/uploads/{unique_filename}",
299
+ "full_url": f"{request.base_url}static/uploads/{unique_filename}",
300
+ "embed_html": f'<img src="{request.base_url}static/uploads/{unique_filename}" alt="{original_filename}" />',
301
+ "base64_data": f"data:{mime_type};base64,{base64_encoded[:20]}...{base64_encoded[-20:]}",
302
+ "base64_embed": f'<img src="data:{mime_type};base64,{base64_encoded}" alt="{original_filename}" />',
303
+ "hashtags": hashtag_list
304
+ })
305
+
306
+ if len(results) == 1:
307
+ return results[0]
308
+ else:
309
+ return {"success": True, "uploaded_count": len(results), "files": results}
310
+
311
+ @app.post("/upload-with-replace/")
312
+ async def upload_with_replace(
313
+ request: Request,
314
+ files: List[UploadFile] = File(...),
315
+ hashtags: str = Form(""),
316
+ replace_files: str = Form("")
317
+ ):
318
+ """Handle upload with replacement of duplicate files."""
319
+ # Check if user is authenticated
320
+ if not authenticate(request):
321
+ return JSONResponse(
322
+ status_code=status.HTTP_401_UNAUTHORIZED,
323
+ content={"detail": "Not authenticated"}
324
  )
325
 
326
+ # Process hashtags into a list
327
+ hashtag_list = []
328
+ if hashtags:
329
+ # Split by spaces or commas and remove empty strings/whitespace
330
+ hashtag_list = [tag.strip() for tag in hashtags.replace(',', ' ').split() if tag.strip()]
331
+
332
+ # Parse the replacement files JSON
333
+ files_to_replace = []
334
+ if replace_files:
335
+ try:
336
+ files_to_replace = json.loads(replace_files)
337
+ except json.JSONDecodeError:
338
+ files_to_replace = []
339
+
340
+ # Create a map of original names to replacement decisions
341
+ replace_map = {item["original_name"].lower(): item["existing_file"] for item in files_to_replace}
342
+
343
+ results = []
344
+
345
+ for file in files:
346
+ # Check if the file is an image
347
+ extension = get_file_extension(file.filename)
348
+ if not is_valid_image(extension):
349
+ continue # Skip non-image files
350
+
351
+ # Preserve original filename in metadata
352
+ original_filename = file.filename
353
+ file_lower = original_filename.lower()
354
+
355
+ # Check if this file should replace an existing one
356
+ if file_lower in replace_map:
357
+ # Delete the old file
358
+ old_file = UPLOAD_DIR / replace_map[file_lower]
359
+ if old_file.exists():
360
+ os.remove(old_file)
361
+
362
+ # Remove from metadata
363
+ metadata = get_image_metadata()
364
+ if replace_map[file_lower] in metadata:
365
+ del metadata[replace_map[file_lower]]
366
+ save_image_metadata(metadata)
367
+
368
+ # Generate a unique filename to prevent overwrites
369
+ unique_filename = f"{uuid.uuid4()}{extension}"
370
+ file_path = UPLOAD_DIR / unique_filename
371
+
372
+ # Save the file
373
+ with file_path.open("wb") as buffer:
374
+ shutil.copyfileobj(file.file, buffer)
375
+
376
+ # Save hashtags and original filename
377
+ add_hashtags_to_image(unique_filename, hashtag_list, original_filename)
378
+
379
+ # For base64 encoding
380
+ file.file.seek(0) # Reset file pointer to beginning
381
+ contents = await file.read()
382
+ base64_encoded = base64.b64encode(contents).decode("utf-8")
383
+
384
+ # Determine MIME type
385
+ mime_type = {
386
+ '.jpg': 'image/jpeg',
387
+ '.jpeg': 'image/jpeg',
388
+ '.png': 'image/png',
389
+ '.gif': 'image/gif',
390
+ '.bmp': 'image/bmp',
391
+ '.webp': 'image/webp'
392
+ }.get(extension, 'application/octet-stream')
393
+
394
+ results.append({
395
+ "success": True,
396
+ "file_name": unique_filename,
397
+ "original_filename": original_filename,
398
+ "file_url": f"/static/uploads/{unique_filename}",
399
+ "full_url": f"{request.base_url}static/uploads/{unique_filename}",
400
+ "embed_html": f'<img src="{request.base_url}static/uploads/{unique_filename}" alt="{original_filename}" />',
401
+ "base64_data": f"data:{mime_type};base64,{base64_encoded[:20]}...{base64_encoded[-20:]}",
402
+ "base64_embed": f'<img src="data:{mime_type};base64,{base64_encoded}" alt="{original_filename}" />',
403
+ "hashtags": hashtag_list
404
+ })
405
+
406
+ if len(results) == 1:
407
+ return results[0]
408
+ else:
409
+ return {"success": True, "uploaded_count": len(results), "files": results}
410
 
411
  @app.get("/view/{file_name}")
412
  async def view_image(request: Request, file_name: str):
 
420
  if not file_path.exists():
421
  raise HTTPException(status_code=404, detail="Image not found")
422
 
423
+ # Mark image as viewed (not new)
424
+ mark_image_as_viewed(file_name)
425
+
426
  image_url = f"/static/uploads/{file_name}"
427
  embed_url = f"{request.base_url}static/uploads/{file_name}"
428
 
429
+ # Get metadata
430
+ metadata = get_image_metadata()
431
+ hashtags = []
432
+ original_filename = file_name
433
+
434
+ if file_name in metadata:
435
+ hashtags = metadata[file_name].get("hashtags", [])
436
+ original_filename = metadata[file_name].get("original_filename", file_name)
437
+
438
  return templates.TemplateResponse(
439
  "view.html",
440
  {
441
  "request": request,
442
  "image_url": image_url,
443
  "file_name": file_name,
444
+ "original_filename": original_filename,
445
+ "embed_url": embed_url,
446
+ "hashtags": hashtags
447
  }
448
  )
449
 
450
+ @app.post("/update-hashtags/{file_name}")
451
+ async def update_hashtags(request: Request, file_name: str, hashtags: str = Form("")):
452
+ """Update hashtags for an image."""
453
+ # Check if user is authenticated
454
+ if not authenticate(request):
455
+ return JSONResponse(
456
+ status_code=status.HTTP_401_UNAUTHORIZED,
457
+ content={"detail": "Not authenticated"}
458
+ )
459
+
460
+ file_path = UPLOAD_DIR / file_name
461
+
462
+ if not file_path.exists():
463
+ raise HTTPException(status_code=404, detail="Image not found")
464
+
465
+ # Process hashtags
466
+ hashtag_list = []
467
+ if hashtags:
468
+ hashtag_list = [tag.strip() for tag in hashtags.replace(',', ' ').split() if tag.strip()]
469
+
470
+ # Update hashtags in metadata
471
+ add_hashtags_to_image(file_name, hashtag_list)
472
+
473
+ # Redirect back to the image view
474
+ return RedirectResponse(url=f"/view/{file_name}", status_code=status.HTTP_303_SEE_OTHER)
475
+
476
  @app.delete("/delete/{file_name}")
477
  async def delete_image(request: Request, file_name: str):
478
  """Delete an image with authentication check."""
 
488
  if not file_path.exists():
489
  raise HTTPException(status_code=404, detail="Image not found")
490
 
491
+ # Delete the file
492
  os.remove(file_path)
493
 
494
+ # Remove from metadata
495
+ metadata = get_image_metadata()
496
+ if file_name in metadata:
497
+ del metadata[file_name]
498
+ save_image_metadata(metadata)
499
+
500
  return {"success": True, "message": f"Image {file_name} has been deleted"}
501
 
502
  # Health check endpoint for Hugging Face Spaces
 
506
 
507
  if __name__ == "__main__":
508
  # For local development
509
+ uvicorn.run("app:app", host="127.0.0.1", port=8000, reload=True)
510
+
511
+ # For production/Hugging Face (uncomment when deploying)
512
+ # uvicorn.run("app:app", host="0.0.0.0", port=7860)
templates/index.html CHANGED
@@ -6,132 +6,464 @@
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
  <title>Image Uploader</title>
8
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
 
9
  <style>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  .upload-container {
11
- background-color: #f8f9fa;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  padding: 20px;
13
  border-radius: 8px;
14
- margin-bottom: 30px;
15
  }
 
16
  .image-card {
17
- margin-bottom: 20px;
18
- transition: transform 0.3s;
 
19
  }
 
20
  .image-card:hover {
21
  transform: translateY(-5px);
22
- box-shadow: 0 10px 20px rgba(0,0,0,0.1);
23
- }
24
- .copy-btn {
25
- cursor: pointer;
26
  }
 
27
  .image-preview {
28
  max-height: 200px;
29
  object-fit: cover;
30
  width: 100%;
 
31
  }
32
- .code-container {
33
- background-color: #f5f5f5;
34
- padding: 10px;
35
- border-radius: 4px;
36
- margin-top: 10px;
37
- font-family: monospace;
38
- font-size: 0.8rem;
39
- overflow-x: auto;
40
- white-space: nowrap;
41
  }
 
42
  .hidden {
43
  display: none;
44
  }
 
45
  #uploadProgress {
46
- margin-top: 10px;
47
- }
48
- .success-message {
49
- background-color: #d1e7dd;
50
- color: #0f5132;
51
- padding: 15px;
52
- border-radius: 4px;
53
  margin-top: 15px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  }
55
  </style>
56
  </head>
57
  <body>
58
- <div class="container py-5">
59
- <div class="d-flex justify-content-between align-items-center mb-4">
60
- <h1>🖼️ Image Uploader & Embed Link Generator</h1>
61
- <a href="/logout" class="btn btn-outline-danger">Logout</a>
 
 
 
 
 
 
 
62
  </div>
63
-
 
 
 
64
  <div class="upload-container">
65
- <h2>Upload New Image</h2>
66
  <form id="uploadForm" enctype="multipart/form-data">
67
- <div class="mb-3">
68
- <label for="file" class="form-label">Select an image to upload</label>
69
- <input class="form-control" type="file" id="file" name="file" accept="image/*">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  </div>
71
- <button type="submit" class="btn btn-primary">Upload & Generate Links</button>
 
 
72
  </form>
73
 
74
  <div id="uploadProgress" class="progress hidden">
75
  <div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 0%"></div>
76
  </div>
77
-
78
- <div id="uploadResult" class="hidden success-message">
79
- <h4>✅ Upload Successful!</h4>
80
- <div id="previewContainer" class="mt-3">
81
- <img id="imagePreview" class="img-fluid img-thumbnail mb-3" alt="Uploaded image preview">
 
 
 
 
 
 
 
 
 
 
82
  </div>
83
-
84
- <h5>Embed Options:</h5>
85
-
86
- <div class="mb-3">
87
- <h6>1. Direct URL (hosted on this server)</h6>
88
- <div class="input-group">
89
- <input type="text" id="directUrl" class="form-control" readonly>
90
- <button class="btn btn-outline-secondary copy-btn" data-target="directUrl">Copy</button>
 
 
91
  </div>
92
- </div>
93
-
94
- <div class="mb-3">
95
- <h6>2. HTML Embed Code</h6>
96
- <div class="code-container" id="htmlEmbed"></div>
97
- <button class="btn btn-sm btn-outline-secondary mt-2 copy-btn" data-target="htmlEmbed">Copy HTML</button>
98
- </div>
99
-
100
- <div class="mb-3">
101
- <h6>3. Base64 Embed (works anywhere without hosting)</h6>
102
- <div class="code-container" id="base64Embed">Base64 code is truncated for display. Use the copy button to get the full code.</div>
103
- <button class="btn btn-sm btn-outline-secondary mt-2 copy-btn" data-target="base64Embed">Copy Base64 HTML</button>
104
- </div>
105
-
106
- <p class="text-muted mt-3">
107
- <small>Tip: Base64 encoding embeds the image directly in your HTML, so you don't need to host the image separately.</small>
108
- </p>
109
  </div>
110
- </div>
111
-
112
- <div class="row">
113
- <div class="col-12">
114
- <h2 class="mb-4">Your Uploaded Images</h2>
115
-
116
  {% if not uploaded_images %}
117
- <div class="alert alert-info">
118
- No images have been uploaded yet. Upload your first image above!
 
 
119
  </div>
120
  {% else %}
121
- <div class="row">
 
122
  {% for image in uploaded_images %}
123
- <div class="col-md-4">
124
- <div class="card image-card">
125
- <img src="{{ image.url }}" class="card-img-top image-preview" alt="{{ image.name }}">
126
- <div class="card-body">
127
- <h5 class="card-title text-truncate">{{ image.name }}</h5>
128
- <div class="d-flex justify-content-between mt-3">
129
- <a href="/view/{{ image.name }}" class="btn btn-sm btn-primary">View Details</a>
130
- <button class="btn btn-sm btn-danger delete-btn" data-filename="{{ image.name }}">Delete</button>
 
 
 
 
 
 
 
 
 
 
 
 
131
  </div>
132
  </div>
133
  </div>
134
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  {% endfor %}
136
  </div>
137
  {% endif %}
@@ -139,26 +471,233 @@
139
  </div>
140
  </div>
141
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  <script>
143
  document.addEventListener('DOMContentLoaded', function() {
144
  const uploadForm = document.getElementById('uploadForm');
145
  const uploadProgress = document.getElementById('uploadProgress');
146
  const progressBar = uploadProgress.querySelector('.progress-bar');
147
- const uploadResult = document.getElementById('uploadResult');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
 
149
  // Handle form submission
150
  uploadForm.addEventListener('submit', function(e) {
151
  e.preventDefault();
152
 
153
- const fileInput = document.getElementById('file');
154
- if (!fileInput.files.length) {
155
- alert('Please select a file to upload.');
 
 
156
  return;
157
  }
158
 
159
- const file = fileInput.files[0];
 
 
 
160
  const formData = new FormData();
161
- formData.append('file', file);
 
 
 
 
 
 
 
162
 
163
  // Show progress
164
  uploadProgress.classList.remove('hidden');
@@ -174,36 +713,49 @@
174
  });
175
 
176
  xhr.addEventListener('load', function() {
 
 
 
177
  if (xhr.status === 200) {
178
  const response = JSON.parse(xhr.responseText);
179
 
180
- // Update preview
181
- document.getElementById('imagePreview').src = response.file_url;
182
- document.getElementById('directUrl').value = window.location.origin + response.file_url;
183
- document.getElementById('htmlEmbed').textContent = response.embed_html;
184
-
185
- // Store the full base64 embed, but display truncated version
186
- const base64EmbedEl = document.getElementById('base64Embed');
187
- base64EmbedEl.textContent = 'Base64 embed code (truncated for display): ' +
188
- response.base64_embed.substring(0, 50) + '...' +
189
- response.base64_embed.substring(response.base64_embed.length - 20);
190
- base64EmbedEl.dataset.fullCode = response.base64_embed;
191
-
192
- // Show results
193
- uploadResult.classList.remove('hidden');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
 
195
- // Reset form for next upload
196
- uploadForm.reset();
197
-
198
- // Refresh the page after 2 seconds to show the new image in the gallery
199
- setTimeout(() => {
200
- window.location.reload();
201
- }, 2000);
202
  } else {
203
  alert('Upload failed. Please try again.');
204
  }
205
-
206
- uploadProgress.classList.add('hidden');
207
  });
208
 
209
  xhr.addEventListener('error', function() {
@@ -215,54 +767,131 @@
215
  xhr.send(formData);
216
  });
217
 
218
- // Handle copy buttons
219
- document.querySelectorAll('.copy-btn').forEach(function(btn) {
220
- btn.addEventListener('click', function() {
221
- const targetId = this.dataset.target;
222
- const targetEl = document.getElementById(targetId);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
223
 
224
- let textToCopy;
225
- if (targetEl.tagName === 'INPUT') {
226
- textToCopy = targetEl.value;
227
  } else {
228
- // For base64, we have the full code in the data attribute
229
- textToCopy = targetEl.dataset.fullCode || targetEl.textContent;
230
  }
231
-
232
- navigator.clipboard.writeText(textToCopy).then(() => {
233
- // Change button text temporarily
234
- const originalText = this.textContent;
235
- this.textContent = 'Copied!';
236
- setTimeout(() => {
237
- this.textContent = originalText;
238
- }, 1500);
239
- }).catch(err => {
240
- console.error('Failed to copy text: ', err);
241
- });
242
  });
 
 
 
 
 
 
 
 
243
  });
244
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
245
  // Handle delete buttons
246
  document.querySelectorAll('.delete-btn').forEach(function(btn) {
247
  btn.addEventListener('click', function() {
248
  const filename = this.dataset.filename;
249
- if (confirm(`Are you sure you want to delete ${filename}?`)) {
250
- fetch(`/delete/${filename}`, {
251
- method: 'DELETE'
252
- })
253
- .then(response => response.json())
254
- .then(data => {
255
- if (data.success) {
256
- window.location.reload();
257
- } else {
258
- alert('Failed to delete the image.');
259
- }
260
- })
261
- .catch(error => {
262
- console.error('Error:', error);
263
- alert('An error occurred while deleting the image.');
264
- });
 
 
 
 
 
 
 
 
 
265
  }
 
 
 
 
266
  });
267
  });
268
  });
 
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
  <title>Image Uploader</title>
8
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
9
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
10
  <style>
11
+ :root {
12
+ --primary-color: #4361ee;
13
+ --secondary-color: #3f37c9;
14
+ --accent-color: #4cc9f0;
15
+ --success-color: #22cc88;
16
+ --light-bg: #f8f9fa;
17
+ --dark-text: #212529;
18
+ --card-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
19
+ --hover-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
20
+ }
21
+
22
+ body {
23
+ background-color: #f5f7fa;
24
+ color: var(--dark-text);
25
+ font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
26
+ padding-bottom: 40px;
27
+ }
28
+
29
+ .navbar {
30
+ background-color: white;
31
+ box-shadow: 0 2px 15px rgba(0, 0, 0, 0.05);
32
+ padding: 15px 0;
33
+ margin-bottom: 30px;
34
+ }
35
+
36
+ .navbar-brand {
37
+ font-weight: 600;
38
+ color: var(--primary-color);
39
+ display: flex;
40
+ align-items: center;
41
+ gap: 10px;
42
+ }
43
+
44
+ .navbar-brand i {
45
+ font-size: 1.5em;
46
+ }
47
+
48
+ .container {
49
+ max-width: 1200px;
50
+ }
51
+
52
+ .card {
53
+ border: none;
54
+ border-radius: 12px;
55
+ box-shadow: var(--card-shadow);
56
+ transition: all 0.3s ease;
57
+ }
58
+
59
+ .section-title {
60
+ margin-bottom: 20px;
61
+ font-weight: 600;
62
+ color: var(--dark-text);
63
+ border-left: 4px solid var(--primary-color);
64
+ padding-left: 12px;
65
+ }
66
+
67
  .upload-container {
68
+ background-color: white;
69
+ padding: 25px;
70
+ border-radius: 12px;
71
+ margin-bottom: 30px;
72
+ box-shadow: var(--card-shadow);
73
+ }
74
+
75
+ .gallery-container {
76
+ background-color: white;
77
+ padding: 25px;
78
+ border-radius: 12px;
79
+ margin-bottom: 30px;
80
+ box-shadow: var(--card-shadow);
81
+ }
82
+
83
+ .search-container {
84
+ background-color: #f5f7fa;
85
  padding: 20px;
86
  border-radius: 8px;
87
+ margin-bottom: 20px;
88
  }
89
+
90
  .image-card {
91
+ margin-bottom: 25px;
92
+ border-radius: 12px;
93
+ overflow: hidden;
94
  }
95
+
96
  .image-card:hover {
97
  transform: translateY(-5px);
98
+ box-shadow: var(--hover-shadow);
 
 
 
99
  }
100
+
101
  .image-preview {
102
  max-height: 200px;
103
  object-fit: cover;
104
  width: 100%;
105
+ height: 200px;
106
  }
107
+
108
+ .card-body {
109
+ padding: 20px;
110
+ }
111
+
112
+ .card-title {
113
+ font-weight: 600;
114
+ margin-bottom: 15px;
 
115
  }
116
+
117
  .hidden {
118
  display: none;
119
  }
120
+
121
  #uploadProgress {
 
 
 
 
 
 
 
122
  margin-top: 15px;
123
+ height: 10px;
124
+ border-radius: 5px;
125
+ }
126
+
127
+ .progress-bar {
128
+ background-color: var(--primary-color);
129
+ }
130
+
131
+ .form-control, .form-select {
132
+ border-radius: 8px;
133
+ padding: 10px 15px;
134
+ border: 1px solid #e0e0e0;
135
+ }
136
+
137
+ .form-control:focus, .form-select:focus {
138
+ box-shadow: 0 0 0 3px rgba(67, 97, 238, 0.15);
139
+ border-color: var(--primary-color);
140
+ }
141
+
142
+ .btn {
143
+ border-radius: 8px;
144
+ padding: 10px 20px;
145
+ font-weight: 500;
146
+ }
147
+
148
+ .btn-primary {
149
+ background-color: var(--primary-color);
150
+ border-color: var(--primary-color);
151
+ }
152
+
153
+ .btn-primary:hover, .btn-primary:focus {
154
+ background-color: var(--secondary-color);
155
+ border-color: var(--secondary-color);
156
+ }
157
+
158
+ .btn-outline-primary {
159
+ color: var(--primary-color);
160
+ border-color: var(--primary-color);
161
+ }
162
+
163
+ .btn-outline-primary:hover, .btn-outline-primary:focus,
164
+ .btn-check:checked + .btn-outline-primary {
165
+ background-color: var(--primary-color);
166
+ border-color: var(--primary-color);
167
+ color: white;
168
+ }
169
+
170
+ .btn-danger {
171
+ background-color: #e63946;
172
+ border-color: #e63946;
173
+ }
174
+
175
+ .btn-danger:hover, .btn-danger:focus {
176
+ background-color: #d00000;
177
+ border-color: #d00000;
178
+ }
179
+
180
+ .hashtag {
181
+ display: inline-block;
182
+ background-color: rgba(67, 97, 238, 0.1);
183
+ padding: 5px 12px;
184
+ border-radius: 20px;
185
+ font-size: 0.8rem;
186
+ margin-right: 5px;
187
+ margin-bottom: 5px;
188
+ color: var(--primary-color);
189
+ transition: all 0.2s ease;
190
+ font-weight: 500;
191
+ text-decoration: none;
192
+ }
193
+
194
+ .hashtag:hover {
195
+ background-color: rgba(67, 97, 238, 0.2);
196
+ color: var(--primary-color);
197
+ text-decoration: none;
198
+ }
199
+
200
+ .new-badge {
201
+ position: absolute;
202
+ top: 15px;
203
+ right: 15px;
204
+ background-color: var(--success-color);
205
+ color: white;
206
+ padding: 5px 10px;
207
+ border-radius: 20px;
208
+ font-size: 0.7rem;
209
+ font-weight: 600;
210
+ z-index: 2;
211
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
212
+ }
213
+
214
+ .layout-controls {
215
+ margin-bottom: 15px;
216
+ }
217
+
218
+ /* Custom 5-column layout */
219
+ .col-5-layout {
220
+ position: relative;
221
+ width: 100%;
222
+ padding-right: 15px;
223
+ padding-left: 15px;
224
+ }
225
+
226
+ @media (min-width: 992px) {
227
+ .col-5-layout {
228
+ flex: 0 0 20%;
229
+ max-width: 20%;
230
+ }
231
+ }
232
+
233
+ @media (min-width: 768px) and (max-width: 991.98px) {
234
+ .col-5-layout {
235
+ flex: 0 0 25%;
236
+ max-width: 25%;
237
+ }
238
+ }
239
+
240
+ @media (max-width: 767.98px) {
241
+ .col-5-layout {
242
+ flex: 0 0 50%;
243
+ max-width: 50%;
244
+ }
245
+ }
246
+
247
+ .card-img-overlay {
248
+ background: linear-gradient(to top, rgba(0,0,0,0.7) 0%, rgba(0,0,0,0) 50%);
249
+ transition: all 0.3s ease;
250
+ }
251
+
252
+ .toast-container {
253
+ position: fixed;
254
+ bottom: 20px;
255
+ right: 20px;
256
+ z-index: 9999;
257
+ }
258
+
259
+ .toast {
260
+ min-width: 250px;
261
+ background-color: white;
262
+ box-shadow: 0 5px 15px rgba(0, 0, 0, 0.15);
263
+ border: none;
264
+ border-radius: 8px;
265
+ }
266
+
267
+ .toast-header {
268
+ border-radius: 8px 8px 0 0;
269
+ }
270
+
271
+ .action-buttons {
272
+ display: flex;
273
+ gap: 8px;
274
+ }
275
+
276
+ .btn-sm {
277
+ padding: 5px 10px;
278
+ font-size: 0.8rem;
279
+ }
280
+
281
+ .empty-state {
282
+ text-align: center;
283
+ padding: 60px 0;
284
+ }
285
+
286
+ .empty-state i {
287
+ font-size: 3rem;
288
+ color: #dee2e6;
289
+ margin-bottom: 20px;
290
+ }
291
+
292
+ .empty-state p {
293
+ color: #6c757d;
294
+ font-size: 1.1rem;
295
+ }
296
+
297
+ .gallery-header {
298
+ display: flex;
299
+ justify-content: space-between;
300
+ align-items: center;
301
+ margin-bottom: 20px;
302
+ }
303
+
304
+ .site-footer {
305
+ background-color: white;
306
+ padding: 20px 0;
307
+ text-align: center;
308
+ margin-top: 40px;
309
+ border-top: 1px solid #eaeaea;
310
+ color: #6c757d;
311
+ font-size: 0.9rem;
312
  }
313
  </style>
314
  </head>
315
  <body>
316
+ <nav class="navbar navbar-expand-lg navbar-light">
317
+ <div class="container">
318
+ <a class="navbar-brand" href="/">
319
+ <i class="fas fa-photo-film"></i>
320
+ Image Uploader
321
+ </a>
322
+ <div class="ms-auto">
323
+ <a href="/logout" class="btn btn-outline-danger">
324
+ <i class="fas fa-sign-out-alt me-2"></i>Logout
325
+ </a>
326
+ </div>
327
  </div>
328
+ </nav>
329
+
330
+ <div class="container">
331
+ <!-- Upload Section (Top) -->
332
  <div class="upload-container">
333
+ <h3 class="section-title">Upload Images</h3>
334
  <form id="uploadForm" enctype="multipart/form-data">
335
+ <div class="row">
336
+ <div class="col-md-8">
337
+ <div class="mb-3">
338
+ <label for="files" class="form-label">
339
+ <i class="fas fa-images me-2"></i>Select images
340
+ </label>
341
+ <input class="form-control" type="file" id="files" name="files" accept="image/*" multiple>
342
+ <small class="text-muted">You can select multiple images</small>
343
+ </div>
344
+ </div>
345
+ <div class="col-md-4">
346
+ <div class="mb-3">
347
+ <label for="hashtags" class="form-label">
348
+ <i class="fas fa-hashtag me-2"></i>Add hashtags
349
+ </label>
350
+ <input type="text" class="form-control" id="hashtags" name="hashtags" placeholder="nature travel photography">
351
+ <small class="text-muted">Separate with spaces or commas</small>
352
+ </div>
353
+ </div>
354
  </div>
355
+ <button type="submit" class="btn btn-primary">
356
+ <i class="fas fa-cloud-upload-alt me-2"></i>Upload Images
357
+ </button>
358
  </form>
359
 
360
  <div id="uploadProgress" class="progress hidden">
361
  <div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 0%"></div>
362
  </div>
363
+ </div>
364
+
365
+ <!-- Gallery Section (Bottom with integrated search) -->
366
+ <div class="gallery-container">
367
+ <div class="gallery-header">
368
+ <h3 class="section-title mb-0">Your Gallery</h3>
369
+ <div class="layout-controls btn-group" role="group">
370
+ <input type="radio" class="btn-check" name="layout" id="layout3" autocomplete="off" checked>
371
+ <label class="btn btn-outline-primary" for="layout3"><i class="fas fa-th-large"></i></label>
372
+
373
+ <input type="radio" class="btn-check" name="layout" id="layout4" autocomplete="off">
374
+ <label class="btn btn-outline-primary" for="layout4"><i class="fas fa-th"></i></label>
375
+
376
+ <input type="radio" class="btn-check" name="layout" id="layout5" autocomplete="off">
377
+ <label class="btn btn-outline-primary" for="layout5"><i class="fas fa-grip-horizontal"></i></label>
378
  </div>
379
+ </div>
380
+
381
+ <!-- Search and Filter (Inside Gallery) -->
382
+ <div class="search-container">
383
+ <form id="searchForm" method="get" action="/" class="row g-3">
384
+ <div class="col-md-6">
385
+ <label for="search" class="form-label">
386
+ <i class="fas fa-search me-2"></i>Search by name or hashtag
387
+ </label>
388
+ <input type="text" class="form-control" id="search" name="search" value="{{ current_search or '' }}" placeholder="Search...">
389
  </div>
390
+ <div class="col-md-6">
391
+ <label for="tag" class="form-label">
392
+ <i class="fas fa-filter me-2"></i>Filter by hashtag
393
+ </label>
394
+ <select class="form-select" id="tag" name="tag">
395
+ <option value="">All hashtags</option>
396
+ {% for tag in all_hashtags %}
397
+ <option value="{{ tag }}" {% if current_tag == tag %}selected{% endif %}>{{ tag }}</option>
398
+ {% endfor %}
399
+ </select>
400
+ </div>
401
+ </form>
 
 
 
 
 
402
  </div>
403
+
404
+ <!-- Image Gallery -->
405
+ <div id="gallery">
 
 
 
406
  {% if not uploaded_images %}
407
+ <div class="empty-state">
408
+ <i class="fas fa-images"></i>
409
+ <h4>No images yet</h4>
410
+ <p>Upload your first image to get started!</p>
411
  </div>
412
  {% else %}
413
+ <div class="row" id="imageGrid">
414
+ {# First, render new images #}
415
  {% for image in uploaded_images %}
416
+ {% if image.is_new %}
417
+ <div class="image-item col-md-4">
418
+ <div class="card image-card">
419
+ <span class="new-badge">NEW</span>
420
+ <img src="{{ image.url }}" class="card-img-top image-preview" alt="{{ image.original_filename }}">
421
+ <div class="card-body">
422
+ <h5 class="card-title text-truncate" title="{{ image.original_filename }}">{{ image.original_filename }}</h5>
423
+ <div class="hashtags mb-3">
424
+ {% for tag in image.hashtags %}
425
+ <a href="/?tag={{ tag }}" class="hashtag">#{{ tag }}</a>
426
+ {% endfor %}
427
+ </div>
428
+ <div class="action-buttons d-flex justify-content-between mt-3">
429
+ <a href="/view/{{ image.name }}" class="btn btn-sm btn-primary">
430
+ <i class="fas fa-eye me-1"></i> View
431
+ </a>
432
+ <button class="btn btn-sm btn-danger delete-btn" data-filename="{{ image.name }}">
433
+ <i class="fas fa-trash-alt me-1"></i> Delete
434
+ </button>
435
+ </div>
436
  </div>
437
  </div>
438
  </div>
439
+ {% endif %}
440
+ {% endfor %}
441
+
442
+ {# Then, render viewed images #}
443
+ {% for image in uploaded_images %}
444
+ {% if not image.is_new %}
445
+ <div class="image-item col-md-4">
446
+ <div class="card image-card">
447
+ <img src="{{ image.url }}" class="card-img-top image-preview" alt="{{ image.original_filename }}">
448
+ <div class="card-body">
449
+ <h5 class="card-title text-truncate" title="{{ image.original_filename }}">{{ image.original_filename }}</h5>
450
+ <div class="hashtags mb-3">
451
+ {% for tag in image.hashtags %}
452
+ <a href="/?tag={{ tag }}" class="hashtag">#{{ tag }}</a>
453
+ {% endfor %}
454
+ </div>
455
+ <div class="action-buttons d-flex justify-content-between mt-3">
456
+ <a href="/view/{{ image.name }}" class="btn btn-sm btn-primary">
457
+ <i class="fas fa-eye me-1"></i> View
458
+ </a>
459
+ <button class="btn btn-sm btn-danger delete-btn" data-filename="{{ image.name }}">
460
+ <i class="fas fa-trash-alt me-1"></i> Delete
461
+ </button>
462
+ </div>
463
+ </div>
464
+ </div>
465
+ </div>
466
+ {% endif %}
467
  {% endfor %}
468
  </div>
469
  {% endif %}
 
471
  </div>
472
  </div>
473
 
474
+ <!-- Footer -->
475
+ <footer class="site-footer">
476
+ <div class="container">
477
+ <p class="mb-0">©2025 Detomo. All rights reserved</p>
478
+ </div>
479
+ </footer>
480
+
481
+ <!-- Toast container for notifications -->
482
+ <div class="toast-container">
483
+ <div id="uploadSuccessToast" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
484
+ <div class="toast-header bg-success text-white">
485
+ <i class="fas fa-check-circle me-2"></i>
486
+ <strong class="me-auto">Success</strong>
487
+ <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
488
+ </div>
489
+ <div class="toast-body" id="toastMessage">
490
+ Images uploaded successfully!
491
+ </div>
492
+ </div>
493
+ </div>
494
+
495
+ <!-- Modal for Delete Confirmation -->
496
+ <div class="modal fade" id="deleteConfirmModal" tabindex="-1" aria-labelledby="deleteConfirmModalLabel" aria-hidden="true">
497
+ <div class="modal-dialog modal-dialog-centered">
498
+ <div class="modal-content">
499
+ <div class="modal-header border-0">
500
+ <h5 class="modal-title" id="deleteConfirmModalLabel">Confirm Deletion</h5>
501
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
502
+ </div>
503
+ <div class="modal-body text-center py-4">
504
+ <div class="mb-4">
505
+ <i class="fas fa-exclamation-triangle text-danger" style="font-size: 3.5rem;"></i>
506
+ </div>
507
+ <h5 class="mb-3">Are you sure you want to delete this image?</h5>
508
+ <p class="text-muted mb-0">This action cannot be undone.</p>
509
+ </div>
510
+ <div class="modal-footer border-0 justify-content-center">
511
+ <button type="button" class="btn btn-light px-4" data-bs-dismiss="modal">
512
+ <i class="fas fa-times me-2"></i>Cancel
513
+ </button>
514
+ <button type="button" class="btn btn-danger px-4" id="confirmDeleteBtn">
515
+ <i class="fas fa-trash-alt me-2"></i>Delete
516
+ </button>
517
+ </div>
518
+ </div>
519
+ </div>
520
+ </div>
521
+
522
+ <!-- Modal for No Files Selected -->
523
+ <div class="modal fade" id="noFilesModal" tabindex="-1" aria-labelledby="noFilesModalLabel" aria-hidden="true">
524
+ <div class="modal-dialog modal-dialog-centered">
525
+ <div class="modal-content">
526
+ <div class="modal-header border-0">
527
+ <h5 class="modal-title" id="noFilesModalLabel">No Files Selected</h5>
528
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
529
+ </div>
530
+ <div class="modal-body text-center py-4">
531
+ <div class="mb-4">
532
+ <i class="fas fa-images text-warning" style="font-size: 3.5rem;"></i>
533
+ </div>
534
+ <h5 class="mb-2">Please select at least one image file</h5>
535
+ <p class="text-muted">You need to browse and select images before uploading.</p>
536
+ </div>
537
+ <div class="modal-footer border-0 justify-content-center">
538
+ <button type="button" class="btn btn-primary px-4" data-bs-dismiss="modal">
539
+ <i class="fas fa-check me-2"></i>OK
540
+ </button>
541
+ </div>
542
+ </div>
543
+ </div>
544
+ </div>
545
+
546
+ <!-- Modal for Duplicate Filename Confirmation -->
547
+ <div class="modal fade" id="duplicateFilesModal" tabindex="-1" aria-labelledby="duplicateFilesModalLabel" aria-hidden="true">
548
+ <div class="modal-dialog modal-dialog-centered modal-lg">
549
+ <div class="modal-content">
550
+ <div class="modal-header border-0">
551
+ <h5 class="modal-title" id="duplicateFilesModalLabel">Duplicate Filenames Detected</h5>
552
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
553
+ </div>
554
+ <div class="modal-body py-4">
555
+ <div class="text-center mb-4">
556
+ <i class="fas fa-exclamation-circle text-warning" style="font-size: 3.5rem;"></i>
557
+ <h5 class="mt-3 mb-4">Some files have the same names as existing images</h5>
558
+ <p class="text-muted mb-4">Please confirm if you want to replace the existing images with the new ones</p>
559
+ </div>
560
+
561
+ <div class="table-responsive">
562
+ <table class="table table-borderless align-middle" id="duplicateFilesTable">
563
+ <thead class="table-light">
564
+ <tr>
565
+ <th>Replace</th>
566
+ <th>New Image</th>
567
+ <th>Will Replace</th>
568
+ </tr>
569
+ </thead>
570
+ <tbody>
571
+ <!-- Populated dynamically -->
572
+ </tbody>
573
+ </table>
574
+ </div>
575
+ </div>
576
+ <div class="modal-footer border-0 justify-content-center">
577
+ <button type="button" class="btn btn-light px-4" data-bs-dismiss="modal">
578
+ <i class="fas fa-times me-2"></i>Cancel Upload
579
+ </button>
580
+ <button type="button" class="btn btn-primary px-4" id="confirmReplaceBtn">
581
+ <i class="fas fa-check me-2"></i>Continue With Selected Replacements
582
+ </button>
583
+ </div>
584
+ </div>
585
+ </div>
586
+ </div>
587
+
588
+ <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
589
  <script>
590
  document.addEventListener('DOMContentLoaded', function() {
591
  const uploadForm = document.getElementById('uploadForm');
592
  const uploadProgress = document.getElementById('uploadProgress');
593
  const progressBar = uploadProgress.querySelector('.progress-bar');
594
+ const toast = new bootstrap.Toast(document.getElementById('uploadSuccessToast'), {
595
+ delay: 3000
596
+ });
597
+
598
+ // Store files and hashtags for potential reuse if duplicates are found
599
+ let currentFiles = null;
600
+ let currentHashtags = '';
601
+ let duplicateFiles = [];
602
+
603
+ // Instant search functionality
604
+ const searchInput = document.getElementById('search');
605
+ const tagSelect = document.getElementById('tag');
606
+ const searchForm = document.getElementById('searchForm');
607
+
608
+ // Handle search input changes
609
+ searchInput.addEventListener('input', function() {
610
+ // Add small delay to prevent too many requests while typing
611
+ clearTimeout(searchInput.timer);
612
+ searchInput.timer = setTimeout(() => {
613
+ searchForm.submit();
614
+ }, 500); // Wait 500ms after typing stops
615
+ });
616
+
617
+ // Handle tag selection changes
618
+ tagSelect.addEventListener('change', function() {
619
+ searchForm.submit();
620
+ });
621
+
622
+ // Handle layout controls
623
+ const layoutControls = document.querySelectorAll('input[name="layout"]');
624
+ const imageGrid = document.getElementById('imageGrid');
625
+ const imageItems = document.querySelectorAll('.image-item');
626
+
627
+ function updateLayout(columns) {
628
+ // First, remove all column classes from all items
629
+ imageItems.forEach(item => {
630
+ item.classList.remove('col-md-3', 'col-md-4', 'col-md-6', 'col-5-layout');
631
+ });
632
+
633
+ // Then apply the new column class
634
+ imageItems.forEach(item => {
635
+ if (columns === 3) {
636
+ item.classList.add('col-md-4'); // 3 per row (4/12 width)
637
+ } else if (columns === 4) {
638
+ item.classList.add('col-md-3'); // 4 per row (3/12 width)
639
+ } else if (columns === 5) {
640
+ item.classList.add('col-5-layout'); // Custom 5 per row
641
+ }
642
+ });
643
+
644
+ // Save the preference to localStorage
645
+ localStorage.setItem('preferredLayout', columns);
646
+ }
647
+
648
+ // Initialize layout based on localStorage or default to 3 columns
649
+ const savedLayout = localStorage.getItem('preferredLayout') || '3';
650
+ const initialLayout = parseInt(savedLayout);
651
+
652
+ // Make sure the correct radio button is checked
653
+ const layoutButton = document.getElementById(`layout${initialLayout}`);
654
+ if (layoutButton) {
655
+ layoutButton.checked = true;
656
+ updateLayout(initialLayout);
657
+ } else {
658
+ // Fallback to layout3 if saved layout is invalid
659
+ document.getElementById('layout3').checked = true;
660
+ updateLayout(3);
661
+ }
662
+
663
+ // Add event listeners to layout controls
664
+ layoutControls.forEach(control => {
665
+ control.addEventListener('change', function() {
666
+ let columns = 3; // Default
667
+
668
+ if (this.id === 'layout3') columns = 3;
669
+ else if (this.id === 'layout4') columns = 4;
670
+ else if (this.id === 'layout5') columns = 5;
671
+
672
+ updateLayout(columns);
673
+ });
674
+ });
675
 
676
  // Handle form submission
677
  uploadForm.addEventListener('submit', function(e) {
678
  e.preventDefault();
679
 
680
+ const filesInput = document.getElementById('files');
681
+ if (!filesInput.files.length) {
682
+ // Show the no files modal instead of alert
683
+ const noFilesModal = new bootstrap.Modal(document.getElementById('noFilesModal'));
684
+ noFilesModal.show();
685
  return;
686
  }
687
 
688
+ // Store current files and hashtags in case we need to handle duplicates
689
+ currentFiles = filesInput.files;
690
+ currentHashtags = document.getElementById('hashtags').value;
691
+
692
  const formData = new FormData();
693
+
694
+ // Add all files
695
+ for (let i = 0; i < filesInput.files.length; i++) {
696
+ formData.append('files', filesInput.files[i]);
697
+ }
698
+
699
+ // Add hashtags
700
+ formData.append('hashtags', currentHashtags);
701
 
702
  // Show progress
703
  uploadProgress.classList.remove('hidden');
 
713
  });
714
 
715
  xhr.addEventListener('load', function() {
716
+ // Hide progress bar
717
+ uploadProgress.classList.add('hidden');
718
+
719
  if (xhr.status === 200) {
720
  const response = JSON.parse(xhr.responseText);
721
 
722
+ // Check if we need to handle duplicates
723
+ if (response.success === false && response.action_required === 'confirm_replace') {
724
+ // Store the duplicates
725
+ duplicateFiles = response.duplicates;
726
+
727
+ // Populate the duplicate files table
728
+ const tableBody = document.querySelector('#duplicateFilesTable tbody');
729
+ tableBody.innerHTML = '';
730
+
731
+ duplicateFiles.forEach(function(file, index) {
732
+ const row = document.createElement('tr');
733
+ row.innerHTML = `
734
+ <td>
735
+ <div class="form-check">
736
+ <input class="form-check-input replace-checkbox" type="checkbox"
737
+ value="${file.existing_file}"
738
+ id="replace-check-${index}"
739
+ data-original="${file.original_name}" checked>
740
+ </div>
741
+ </td>
742
+ <td><strong>${file.new_file}</strong></td>
743
+ <td>${file.existing_file}</td>
744
+ `;
745
+ tableBody.appendChild(row);
746
+ });
747
+
748
+ // Show the duplicate files modal
749
+ const duplicateModal = new bootstrap.Modal(document.getElementById('duplicateFilesModal'));
750
+ duplicateModal.show();
751
+ return;
752
+ }
753
 
754
+ // Normal successful upload
755
+ handleSuccessfulUpload(response);
 
 
 
 
 
756
  } else {
757
  alert('Upload failed. Please try again.');
758
  }
 
 
759
  });
760
 
761
  xhr.addEventListener('error', function() {
 
767
  xhr.send(formData);
768
  });
769
 
770
+ // Handle confirmation of file replacements
771
+ document.getElementById('confirmReplaceBtn').addEventListener('click', function() {
772
+ // Get selected replacements
773
+ const checkboxes = document.querySelectorAll('.replace-checkbox:checked');
774
+ const filesToReplace = Array.from(checkboxes).map(function(checkbox) {
775
+ return {
776
+ existing_file: checkbox.value,
777
+ original_name: checkbox.dataset.original
778
+ };
779
+ });
780
+
781
+ // Hide the modal
782
+ const duplicateModal = bootstrap.Modal.getInstance(document.getElementById('duplicateFilesModal'));
783
+ duplicateModal.hide();
784
+
785
+ // Create a new FormData with the current files
786
+ const formData = new FormData();
787
+
788
+ // Add current files (those that we stored earlier)
789
+ for (let i = 0; i < currentFiles.length; i++) {
790
+ formData.append('files', currentFiles[i]);
791
+ }
792
+
793
+ // Add hashtags
794
+ formData.append('hashtags', currentHashtags);
795
+
796
+ // Add replacement information
797
+ formData.append('replace_files', JSON.stringify(filesToReplace));
798
+
799
+ // Show progress again
800
+ uploadProgress.classList.remove('hidden');
801
+ progressBar.style.width = '0%';
802
+
803
+ // Send the request to the replacement endpoint
804
+ const xhr = new XMLHttpRequest();
805
+
806
+ xhr.upload.addEventListener('progress', function(e) {
807
+ if (e.lengthComputable) {
808
+ const percentComplete = (e.loaded / e.total) * 100;
809
+ progressBar.style.width = percentComplete + '%';
810
+ }
811
+ });
812
+
813
+ xhr.addEventListener('load', function() {
814
+ // Hide progress bar
815
+ uploadProgress.classList.add('hidden');
816
 
817
+ if (xhr.status === 200) {
818
+ const response = JSON.parse(xhr.responseText);
819
+ handleSuccessfulUpload(response);
820
  } else {
821
+ alert('Upload failed. Please try again.');
 
822
  }
 
 
 
 
 
 
 
 
 
 
 
823
  });
824
+
825
+ xhr.addEventListener('error', function() {
826
+ alert('Upload failed. Please try again.');
827
+ uploadProgress.classList.add('hidden');
828
+ });
829
+
830
+ xhr.open('POST', '/upload-with-replace/');
831
+ xhr.send(formData);
832
  });
833
 
834
+ // Function to handle successful upload
835
+ function handleSuccessfulUpload(response) {
836
+ // Check if multiple files or single file
837
+ let uploadCount = 1;
838
+ if (response.files) {
839
+ uploadCount = response.uploaded_count;
840
+ }
841
+
842
+ // Show success toast
843
+ document.getElementById('toastMessage').textContent =
844
+ `Successfully uploaded ${uploadCount} image${uploadCount > 1 ? 's' : ''}!`;
845
+ toast.show();
846
+
847
+ // Reset form for next upload
848
+ uploadForm.reset();
849
+ currentFiles = null;
850
+ currentHashtags = '';
851
+
852
+ // Scroll to gallery section
853
+ document.getElementById('gallery').scrollIntoView({ behavior: 'smooth' });
854
+
855
+ // Refresh the page after a short delay to show the new images
856
+ setTimeout(() => {
857
+ window.location.reload();
858
+ }, 1000);
859
+ }
860
+
861
  // Handle delete buttons
862
  document.querySelectorAll('.delete-btn').forEach(function(btn) {
863
  btn.addEventListener('click', function() {
864
  const filename = this.dataset.filename;
865
+
866
+ // Store the filename for later use
867
+ document.getElementById('confirmDeleteBtn').dataset.filename = filename;
868
+
869
+ // Show the delete confirmation modal
870
+ const deleteModal = new bootstrap.Modal(document.getElementById('deleteConfirmModal'));
871
+ deleteModal.show();
872
+ });
873
+ });
874
+
875
+ // Handle delete confirmation
876
+ document.getElementById('confirmDeleteBtn').addEventListener('click', function() {
877
+ const filename = this.dataset.filename;
878
+ const deleteModal = bootstrap.Modal.getInstance(document.getElementById('deleteConfirmModal'));
879
+
880
+ fetch(`/delete/${filename}`, {
881
+ method: 'DELETE'
882
+ })
883
+ .then(response => response.json())
884
+ .then(data => {
885
+ if (data.success) {
886
+ deleteModal.hide();
887
+ window.location.reload();
888
+ } else {
889
+ alert('Failed to delete the image.');
890
  }
891
+ })
892
+ .catch(error => {
893
+ console.error('Error:', error);
894
+ alert('An error occurred while deleting the image.');
895
  });
896
  });
897
  });
templates/login.html CHANGED
@@ -6,70 +6,154 @@
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
  <title>Login - Image Uploader</title>
8
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
 
9
  <style>
 
 
 
 
 
 
 
 
 
 
 
10
  body {
11
- background-color: #f5f5f5;
12
- height: 100vh;
 
 
13
  display: flex;
14
  align-items: center;
15
  justify-content: center;
16
  }
 
17
  .login-container {
18
- max-width: 400px;
 
19
  padding: 40px;
20
- background: white;
21
- border-radius: 10px;
22
- box-shadow: 0 5px 15px rgba(0,0,0,0.1);
 
23
  }
24
- .login-title {
 
25
  text-align: center;
26
  margin-bottom: 30px;
27
- color: #1565C0;
28
  }
29
- .login-form {
 
30
  margin-bottom: 20px;
 
 
 
 
 
 
 
 
31
  }
 
 
 
 
 
32
  .error-message {
33
- background-color: #f8d7da;
34
- color: #721c24;
35
- padding: 10px;
36
- border-radius: 4px;
37
  margin-bottom: 20px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  }
 
39
  .login-footer {
40
  text-align: center;
41
- margin-top: 20px;
42
  color: #6c757d;
43
- font-size: 0.9rem;
 
44
  }
45
  </style>
46
  </head>
47
  <body>
48
  <div class="login-container">
49
- <h2 class="login-title">🖼️ Image Uploader</h2>
 
 
 
 
 
 
50
 
51
  {% if error %}
52
  <div class="error-message">
53
- {{ error }}
54
  </div>
55
  {% endif %}
56
 
57
  <form class="login-form" method="post" action="/login">
58
  <div class="mb-3">
59
  <label for="username" class="form-label">Username</label>
60
- <input type="text" class="form-control" id="username" name="username" required>
 
 
 
61
  </div>
62
- <div class="mb-3">
63
  <label for="password" class="form-label">Password</label>
64
- <input type="password" class="form-control" id="password" name="password" required>
65
- </div>
66
- <div class="d-grid">
67
- <button type="submit" class="btn btn-primary">Login</button>
68
  </div>
 
 
 
69
  </form>
70
 
71
  <div class="login-footer">
72
- Enter your credentials to access the Image Uploader
73
  </div>
74
  </div>
75
  </body>
 
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
  <title>Login - Image Uploader</title>
8
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
9
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
10
  <style>
11
+ :root {
12
+ --primary-color: #4361ee;
13
+ --secondary-color: #3f37c9;
14
+ --accent-color: #4cc9f0;
15
+ --success-color: #22cc88;
16
+ --light-bg: #f8f9fa;
17
+ --dark-text: #212529;
18
+ --card-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
19
+ --hover-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
20
+ }
21
+
22
  body {
23
+ background-color: #f5f7fa;
24
+ color: var(--dark-text);
25
+ font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
26
+ min-height: 100vh;
27
  display: flex;
28
  align-items: center;
29
  justify-content: center;
30
  }
31
+
32
  .login-container {
33
+ max-width: 420px;
34
+ width: 100%;
35
  padding: 40px;
36
+ background-color: white;
37
+ border-radius: 12px;
38
+ box-shadow: var(--card-shadow);
39
+ margin: 20px;
40
  }
41
+
42
+ .login-header {
43
  text-align: center;
44
  margin-bottom: 30px;
 
45
  }
46
+
47
+ .login-logo {
48
  margin-bottom: 20px;
49
+ font-size: 3rem;
50
+ color: var(--primary-color);
51
+ }
52
+
53
+ .login-title {
54
+ font-weight: 600;
55
+ color: var(--dark-text);
56
+ margin-bottom: 10px;
57
  }
58
+
59
+ .login-form {
60
+ margin-bottom: 25px;
61
+ }
62
+
63
  .error-message {
64
+ color: #e63946;
 
 
 
65
  margin-bottom: 20px;
66
+ padding: 10px;
67
+ background-color: rgba(230, 57, 70, 0.1);
68
+ border-radius: 8px;
69
+ font-size: 0.9rem;
70
+ }
71
+
72
+ .form-control {
73
+ border-radius: 8px;
74
+ padding: 12px 15px;
75
+ border: 1px solid #e0e0e0;
76
+ background-color: #f8f9fa;
77
+ }
78
+
79
+ .form-control:focus {
80
+ box-shadow: 0 0 0 3px rgba(67, 97, 238, 0.15);
81
+ border-color: var(--primary-color);
82
+ }
83
+
84
+ .btn {
85
+ border-radius: 8px;
86
+ padding: 12px 20px;
87
+ font-weight: 500;
88
+ }
89
+
90
+ .btn-primary {
91
+ background-color: var(--primary-color);
92
+ border-color: var(--primary-color);
93
+ }
94
+
95
+ .btn-primary:hover, .btn-primary:focus {
96
+ background-color: var(--secondary-color);
97
+ border-color: var(--secondary-color);
98
+ }
99
+
100
+ .form-label {
101
+ font-weight: 500;
102
+ margin-bottom: 8px;
103
+ }
104
+
105
+ .input-group-text {
106
+ background-color: #f5f7fa;
107
+ border-color: #e0e0e0;
108
+ color: #6c757d;
109
  }
110
+
111
  .login-footer {
112
  text-align: center;
 
113
  color: #6c757d;
114
+ font-size: 0.85rem;
115
+ margin-top: 20px;
116
  }
117
  </style>
118
  </head>
119
  <body>
120
  <div class="login-container">
121
+ <div class="login-header">
122
+ <div class="login-logo">
123
+ <i class="fas fa-photo-film"></i>
124
+ </div>
125
+ <h1 class="login-title">Image Uploader</h1>
126
+ <p class="text-muted">Sign in to continue</p>
127
+ </div>
128
 
129
  {% if error %}
130
  <div class="error-message">
131
+ <i class="fas fa-exclamation-circle me-2"></i>{{ error }}
132
  </div>
133
  {% endif %}
134
 
135
  <form class="login-form" method="post" action="/login">
136
  <div class="mb-3">
137
  <label for="username" class="form-label">Username</label>
138
+ <div class="input-group">
139
+ <span class="input-group-text"><i class="fas fa-user"></i></span>
140
+ <input type="text" class="form-control" id="username" name="username" required>
141
+ </div>
142
  </div>
143
+ <div class="mb-4">
144
  <label for="password" class="form-label">Password</label>
145
+ <div class="input-group">
146
+ <span class="input-group-text"><i class="fas fa-lock"></i></span>
147
+ <input type="password" class="form-control" id="password" name="password" required>
148
+ </div>
149
  </div>
150
+ <button type="submit" class="btn btn-primary w-100">
151
+ <i class="fas fa-sign-in-alt me-2"></i>Login
152
+ </button>
153
  </form>
154
 
155
  <div class="login-footer">
156
+ <small>©2025 Detomo. All rights reserved</small>
157
  </div>
158
  </div>
159
  </body>
templates/view.html CHANGED
@@ -3,84 +3,327 @@
3
  <head>
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>View Image: {{ file_name }}</title>
7
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
 
8
  <style>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  .image-container {
10
- background-color: #f8f9fa;
11
- padding: 20px;
12
- border-radius: 8px;
13
  margin-bottom: 30px;
 
14
  }
 
15
  .embed-options {
16
- background-color: #fff;
17
- padding: 20px;
18
- border-radius: 8px;
19
- box-shadow: 0 2px 10px rgba(0,0,0,0.1);
20
  }
 
 
 
 
 
 
 
 
 
21
  .code-container {
22
- background-color: #f5f5f5;
23
- padding: 10px;
24
- border-radius: 4px;
25
  margin-top: 10px;
26
  font-family: monospace;
27
- font-size: 0.8rem;
28
  overflow-x: auto;
29
  white-space: nowrap;
 
30
  }
 
31
  .copy-btn {
32
  cursor: pointer;
33
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  </style>
35
  </head>
36
  <body>
37
- <div class="container py-5">
38
- <div class="d-flex justify-content-between align-items-center mb-4">
39
- <h1>Image Details: {{ file_name }}</h1>
40
- <a href="/" class="btn btn-outline-primary">Back to Gallery</a>
 
 
 
 
 
 
 
 
 
 
41
  </div>
 
 
 
 
42
 
43
  <div class="row">
44
- <div class="col-md-8">
45
  <div class="image-container">
46
- <img src="{{ image_url }}" class="img-fluid" alt="{{ file_name }}">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  </div>
48
  </div>
49
 
50
- <div class="col-md-4">
51
  <div class="embed-options">
52
- <h3>Embed Options</h3>
53
 
54
- <div class="mb-3">
55
- <h5>1. Direct URL</h5>
56
  <div class="input-group">
57
  <input type="text" id="directUrl" class="form-control" value="{{ embed_url }}" readonly>
58
- <button class="btn btn-outline-secondary copy-btn" data-target="directUrl">Copy</button>
 
 
59
  </div>
60
  <small class="text-muted">Use this URL to link directly to the image.</small>
61
  </div>
62
 
63
- <div class="mb-3">
64
- <h5>2. HTML Embed Code</h5>
65
- <div class="code-container" id="htmlEmbed">&lt;img src="{{ embed_url }}" alt="{{ file_name }}" /&gt;</div>
66
- <button class="btn btn-sm btn-outline-secondary mt-2 copy-btn" data-target="htmlEmbed">Copy HTML</button>
67
- <small class="text-muted d-block mt-2">Use this code to embed the image in an HTML page.</small>
 
68
  </div>
69
 
70
- <div class="mb-3">
71
- <h5>3. Markdown Embed</h5>
72
- <div class="code-container" id="markdownEmbed">![{{ file_name }}]({{ embed_url }})</div>
73
- <button class="btn btn-sm btn-outline-secondary mt-2 copy-btn" data-target="markdownEmbed">Copy Markdown</button>
 
 
74
  </div>
75
 
76
- <div class="mt-4">
77
- <button class="btn btn-danger delete-btn" data-filename="{{ file_name }}">Delete Image</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  </div>
 
 
 
 
 
 
 
 
 
 
79
  </div>
80
  </div>
81
  </div>
82
  </div>
83
 
 
84
  <script>
85
  document.addEventListener('DOMContentLoaded', function() {
86
  // Handle copy buttons
@@ -98,10 +341,10 @@
98
 
99
  navigator.clipboard.writeText(textToCopy).then(() => {
100
  // Change button text temporarily
101
- const originalText = this.textContent;
102
- this.textContent = 'Copied!';
103
  setTimeout(() => {
104
- this.textContent = originalText;
105
  }, 1500);
106
  }).catch(err => {
107
  console.error('Failed to copy text: ', err);
@@ -112,23 +355,36 @@
112
  // Handle delete button
113
  document.querySelector('.delete-btn').addEventListener('click', function() {
114
  const filename = this.dataset.filename;
115
- if (confirm(`Are you sure you want to delete ${filename}?`)) {
116
- fetch(`/delete/${filename}`, {
117
- method: 'DELETE'
118
- })
119
- .then(response => response.json())
120
- .then(data => {
121
- if (data.success) {
122
- window.location.href = '/';
123
- } else {
124
- alert('Failed to delete the image.');
125
- }
126
- })
127
- .catch(error => {
128
- console.error('Error:', error);
129
- alert('An error occurred while deleting the image.');
130
- });
131
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  });
133
  });
134
  </script>
 
3
  <head>
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Image Details: {{ original_filename }}</title>
7
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
8
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
9
  <style>
10
+ :root {
11
+ --primary-color: #4361ee;
12
+ --secondary-color: #3f37c9;
13
+ --accent-color: #4cc9f0;
14
+ --success-color: #22cc88;
15
+ --light-bg: #f8f9fa;
16
+ --dark-text: #212529;
17
+ --card-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
18
+ --hover-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
19
+ }
20
+
21
+ body {
22
+ background-color: #f5f7fa;
23
+ color: var(--dark-text);
24
+ font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
25
+ padding-bottom: 40px;
26
+ }
27
+
28
+ .navbar {
29
+ background-color: white;
30
+ box-shadow: 0 2px 15px rgba(0, 0, 0, 0.05);
31
+ padding: 15px 0;
32
+ margin-bottom: 30px;
33
+ }
34
+
35
+ .navbar-brand {
36
+ font-weight: 600;
37
+ color: var(--primary-color);
38
+ display: flex;
39
+ align-items: center;
40
+ gap: 10px;
41
+ }
42
+
43
+ .navbar-brand i {
44
+ font-size: 1.5em;
45
+ }
46
+
47
+ .container {
48
+ max-width: 1200px;
49
+ }
50
+
51
+ .card {
52
+ border: none;
53
+ border-radius: 12px;
54
+ box-shadow: var(--card-shadow);
55
+ transition: all 0.3s ease;
56
+ }
57
+
58
+ .section-title {
59
+ margin-bottom: 20px;
60
+ font-weight: 600;
61
+ color: var(--dark-text);
62
+ border-left: 4px solid var(--primary-color);
63
+ padding-left: 12px;
64
+ }
65
+
66
  .image-container {
67
+ background-color: white;
68
+ padding: 25px;
69
+ border-radius: 12px;
70
  margin-bottom: 30px;
71
+ box-shadow: var(--card-shadow);
72
  }
73
+
74
  .embed-options {
75
+ background-color: white;
76
+ padding: 25px;
77
+ border-radius: 12px;
78
+ box-shadow: var(--card-shadow);
79
  }
80
+
81
+ .hashtag-edit-container {
82
+ background-color: white;
83
+ padding: 25px;
84
+ border-radius: 12px;
85
+ box-shadow: var(--card-shadow);
86
+ margin-bottom: 30px;
87
+ }
88
+
89
  .code-container {
90
+ background-color: #f5f7fa;
91
+ padding: 15px;
92
+ border-radius: 8px;
93
  margin-top: 10px;
94
  font-family: monospace;
95
+ font-size: 0.9rem;
96
  overflow-x: auto;
97
  white-space: nowrap;
98
+ border: 1px solid #e0e0e0;
99
  }
100
+
101
  .copy-btn {
102
  cursor: pointer;
103
  }
104
+
105
+ .hashtag {
106
+ display: inline-block;
107
+ background-color: rgba(67, 97, 238, 0.1);
108
+ padding: 5px 12px;
109
+ border-radius: 20px;
110
+ font-size: 0.8rem;
111
+ margin-right: 5px;
112
+ margin-bottom: 5px;
113
+ color: var(--primary-color);
114
+ transition: all 0.2s ease;
115
+ font-weight: 500;
116
+ text-decoration: none;
117
+ }
118
+
119
+ .hashtag:hover {
120
+ background-color: rgba(67, 97, 238, 0.2);
121
+ color: var(--primary-color);
122
+ text-decoration: none;
123
+ }
124
+
125
+ .hashtags-container {
126
+ margin: 15px 0;
127
+ }
128
+
129
+ .btn {
130
+ border-radius: 8px;
131
+ padding: 10px 20px;
132
+ font-weight: 500;
133
+ }
134
+
135
+ .btn-primary {
136
+ background-color: var(--primary-color);
137
+ border-color: var(--primary-color);
138
+ }
139
+
140
+ .btn-primary:hover, .btn-primary:focus {
141
+ background-color: var(--secondary-color);
142
+ border-color: var(--secondary-color);
143
+ }
144
+
145
+ .btn-outline-primary {
146
+ color: var(--primary-color);
147
+ border-color: var(--primary-color);
148
+ }
149
+
150
+ .btn-outline-primary:hover, .btn-outline-primary:focus {
151
+ background-color: var(--primary-color);
152
+ border-color: var(--primary-color);
153
+ color: white;
154
+ }
155
+
156
+ .btn-danger {
157
+ background-color: #e63946;
158
+ border-color: #e63946;
159
+ }
160
+
161
+ .btn-danger:hover, .btn-danger:focus {
162
+ background-color: #d00000;
163
+ border-color: #d00000;
164
+ }
165
+
166
+ .form-control, .form-select {
167
+ border-radius: 8px;
168
+ padding: 10px 15px;
169
+ border: 1px solid #e0e0e0;
170
+ }
171
+
172
+ .form-control:focus, .form-select:focus {
173
+ box-shadow: 0 0 0 3px rgba(67, 97, 238, 0.15);
174
+ border-color: var(--primary-color);
175
+ }
176
+
177
+ .btn-sm {
178
+ padding: 5px 10px;
179
+ font-size: 0.8rem;
180
+ }
181
+
182
+ .site-footer {
183
+ background-color: white;
184
+ padding: 20px 0;
185
+ text-align: center;
186
+ margin-top: 40px;
187
+ border-top: 1px solid #eaeaea;
188
+ color: #6c757d;
189
+ font-size: 0.9rem;
190
+ }
191
  </style>
192
  </head>
193
  <body>
194
+ <nav class="navbar navbar-expand-lg navbar-light">
195
+ <div class="container">
196
+ <a class="navbar-brand" href="/">
197
+ <i class="fas fa-photo-film"></i>
198
+ Image Uploader
199
+ </a>
200
+ <div class="ms-auto">
201
+ <a href="/" class="btn btn-outline-primary me-2">
202
+ <i class="fas fa-th me-2"></i>Gallery
203
+ </a>
204
+ <a href="/logout" class="btn btn-outline-danger">
205
+ <i class="fas fa-sign-out-alt me-2"></i>Logout
206
+ </a>
207
+ </div>
208
  </div>
209
+ </nav>
210
+
211
+ <div class="container">
212
+ <h2 class="section-title mb-4">Image Details</h2>
213
 
214
  <div class="row">
215
+ <div class="col-lg-8">
216
  <div class="image-container">
217
+ <img src="{{ image_url }}" class="img-fluid rounded" alt="{{ original_filename }}">
218
+ </div>
219
+
220
+ <div class="hashtag-edit-container">
221
+ <h3 class="section-title">Filename</h3>
222
+ <p class="text-muted">{{ original_filename }}</p>
223
+ <h3 class="section-title">Hashtags</h3>
224
+
225
+ <div class="hashtags-container">
226
+ {% if hashtags %}
227
+ {% for tag in hashtags %}
228
+ <a href="/?tag={{ tag }}" class="hashtag">#{{ tag }}</a>
229
+ {% endfor %}
230
+ {% else %}
231
+ <p class="text-muted">No hashtags added yet.</p>
232
+ {% endif %}
233
+ </div>
234
+
235
+ <form method="post" action="/update-hashtags/{{ file_name }}">
236
+ <div class="mb-3">
237
+ <label for="hashtags" class="form-label">
238
+ <i class="fas fa-hashtag me-2"></i>Edit Hashtags
239
+ </label>
240
+ <input type="text" class="form-control" id="hashtags" name="hashtags"
241
+ value="{{ hashtags|join(' ') }}" placeholder="nature photography art">
242
+ <small class="text-muted">Separate hashtags with spaces or commas</small>
243
+ </div>
244
+ <button type="submit" class="btn btn-primary">
245
+ <i class="fas fa-save me-2"></i>Save Hashtags
246
+ </button>
247
+ </form>
248
  </div>
249
  </div>
250
 
251
+ <div class="col-lg-4">
252
  <div class="embed-options">
253
+ <h3 class="section-title">Embed Options</h3>
254
 
255
+ <div class="mb-4">
256
+ <h5><i class="fas fa-link me-2"></i>Direct URL</h5>
257
  <div class="input-group">
258
  <input type="text" id="directUrl" class="form-control" value="{{ embed_url }}" readonly>
259
+ <button class="btn btn-outline-primary copy-btn" data-target="directUrl">
260
+ <i class="fas fa-copy"></i>
261
+ </button>
262
  </div>
263
  <small class="text-muted">Use this URL to link directly to the image.</small>
264
  </div>
265
 
266
+ <div class="mb-4">
267
+ <h5><i class="fas fa-code me-2"></i>HTML Embed Code</h5>
268
+ <div class="code-container" id="htmlEmbed">&lt;img src="{{ embed_url }}" alt="{{ original_filename }}" /&gt;</div>
269
+ <button class="btn btn-sm btn-outline-primary mt-2 copy-btn" data-target="htmlEmbed">
270
+ <i class="fas fa-copy me-1"></i>Copy HTML
271
+ </button>
272
  </div>
273
 
274
+ <div class="mb-4">
275
+ <h5><i class="fab fa-markdown me-2"></i>Markdown Embed</h5>
276
+ <div class="code-container" id="markdownEmbed">![{{ original_filename }}]({{ embed_url }})</div>
277
+ <button class="btn btn-sm btn-outline-primary mt-2 copy-btn" data-target="markdownEmbed">
278
+ <i class="fas fa-copy me-1"></i>Copy Markdown
279
+ </button>
280
  </div>
281
 
282
+ <div class="mt-4 text-center">
283
+ <button class="btn btn-danger delete-btn" data-filename="{{ file_name }}">
284
+ <i class="fas fa-trash-alt me-2"></i>Delete Image
285
+ </button>
286
+ </div>
287
+ </div>
288
+ </div>
289
+ </div>
290
+ </div>
291
+
292
+ <!-- Footer -->
293
+ <footer class="site-footer">
294
+ <div class="container">
295
+ <p class="mb-0">©2025 Detomo. All rights reserved</p>
296
+ </div>
297
+ </footer>
298
+
299
+ <!-- Modal for Delete Confirmation -->
300
+ <div class="modal fade" id="deleteConfirmModal" tabindex="-1" aria-labelledby="deleteConfirmModalLabel" aria-hidden="true">
301
+ <div class="modal-dialog modal-dialog-centered">
302
+ <div class="modal-content">
303
+ <div class="modal-header border-0">
304
+ <h5 class="modal-title" id="deleteConfirmModalLabel">Confirm Deletion</h5>
305
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
306
+ </div>
307
+ <div class="modal-body text-center py-4">
308
+ <div class="mb-4">
309
+ <i class="fas fa-exclamation-triangle text-danger" style="font-size: 3.5rem;"></i>
310
  </div>
311
+ <h5 class="mb-3">Are you sure you want to delete this image?</h5>
312
+ <p class="text-muted mb-0">This action cannot be undone.</p>
313
+ </div>
314
+ <div class="modal-footer border-0 justify-content-center">
315
+ <button type="button" class="btn btn-light px-4" data-bs-dismiss="modal">
316
+ <i class="fas fa-times me-2"></i>Cancel
317
+ </button>
318
+ <button type="button" class="btn btn-danger px-4" id="confirmDeleteBtn">
319
+ <i class="fas fa-trash-alt me-2"></i>Delete
320
+ </button>
321
  </div>
322
  </div>
323
  </div>
324
  </div>
325
 
326
+ <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
327
  <script>
328
  document.addEventListener('DOMContentLoaded', function() {
329
  // Handle copy buttons
 
341
 
342
  navigator.clipboard.writeText(textToCopy).then(() => {
343
  // Change button text temporarily
344
+ const originalText = this.innerHTML;
345
+ this.innerHTML = '<i class="fas fa-check me-1"></i> Copied!';
346
  setTimeout(() => {
347
+ this.innerHTML = originalText;
348
  }, 1500);
349
  }).catch(err => {
350
  console.error('Failed to copy text: ', err);
 
355
  // Handle delete button
356
  document.querySelector('.delete-btn').addEventListener('click', function() {
357
  const filename = this.dataset.filename;
358
+
359
+ // Store the filename for later use
360
+ document.getElementById('confirmDeleteBtn').dataset.filename = filename;
361
+
362
+ // Show the delete confirmation modal
363
+ const deleteModal = new bootstrap.Modal(document.getElementById('deleteConfirmModal'));
364
+ deleteModal.show();
365
+ });
366
+
367
+ // Handle delete confirmation
368
+ document.getElementById('confirmDeleteBtn').addEventListener('click', function() {
369
+ const filename = this.dataset.filename;
370
+ const deleteModal = bootstrap.Modal.getInstance(document.getElementById('deleteConfirmModal'));
371
+
372
+ fetch(`/delete/${filename}`, {
373
+ method: 'DELETE'
374
+ })
375
+ .then(response => response.json())
376
+ .then(data => {
377
+ if (data.success) {
378
+ deleteModal.hide();
379
+ window.location.href = '/';
380
+ } else {
381
+ alert('Failed to delete the image.');
382
+ }
383
+ })
384
+ .catch(error => {
385
+ console.error('Error:', error);
386
+ alert('An error occurred while deleting the image.');
387
+ });
388
  });
389
  });
390
  </script>