sushilideaclan01 commited on
Commit
776be74
·
1 Parent(s): 5dedd73

Implement pagination for gallery creatives: Updated the gallery loading function to return total count and entries, modified the API endpoint to support pagination, and enhanced the frontend to display pagination controls. Adjusted styles for pagination buttons and information display.

Browse files
backend/app/gallery.py CHANGED
@@ -56,8 +56,8 @@ def _save_to_r2(username: str, entries: list[dict[str, Any]]) -> bool:
56
  return r2_put_object(_r2_gallery_key(username), body)
57
 
58
 
59
- def load_entries(username: str, limit: int = DEFAULT_LIMIT, offset: int = 0) -> list[dict[str, Any]]:
60
- """Load gallery entries for user, newest first. Uses R2 and local; prefers whichever has more entries (never write on read)."""
61
  entries: list[dict[str, Any]] = []
62
  path = _user_path(username)
63
  from_r2 = _load_from_r2(username)
@@ -82,7 +82,9 @@ def load_entries(username: str, limit: int = DEFAULT_LIMIT, offset: int = 0) ->
82
  else:
83
  entries = local_list
84
  entries = sorted(entries, key=lambda e: e.get("created_at", ""), reverse=True)
85
- return entries[offset : offset + min(limit, MAX_LIMIT)]
 
 
86
 
87
 
88
  def append_entry(
 
56
  return r2_put_object(_r2_gallery_key(username), body)
57
 
58
 
59
+ def load_entries(username: str, limit: int = DEFAULT_LIMIT, offset: int = 0) -> tuple[list[dict[str, Any]], int]:
60
+ """Load gallery entries for user, newest first. Returns (page_slice, total_count). Uses R2 and local; prefers whichever has more entries (never write on read)."""
61
  entries: list[dict[str, Any]] = []
62
  path = _user_path(username)
63
  from_r2 = _load_from_r2(username)
 
82
  else:
83
  entries = local_list
84
  entries = sorted(entries, key=lambda e: e.get("created_at", ""), reverse=True)
85
+ total = len(entries)
86
+ page = entries[offset : offset + min(limit, MAX_LIMIT)]
87
+ return (page, total)
88
 
89
 
90
  def append_entry(
backend/app/main.py CHANGED
@@ -470,13 +470,13 @@ async def generate_ads_stream(
470
 
471
  @app.get("/api/gallery/creatives")
472
  def gallery_list(
473
- limit: int = 100,
474
  offset: int = 0,
475
  _user: dict = Depends(get_current_user),
476
  ):
477
- """List current user's gallery creatives with fresh presigned URLs. Newest first."""
478
  username = (_user or {}).get("username", "")
479
- entries = gallery_load_entries(username, limit=limit, offset=offset)
480
  out = []
481
  for e in entries:
482
  image_url = r2_get_presigned_url(e.get("r2_key", "")) if e.get("r2_key") else None
@@ -488,7 +488,7 @@ def gallery_list(
488
  "created_at": e.get("created_at", ""),
489
  "image_url": image_url,
490
  })
491
- return {"creatives": out}
492
 
493
 
494
  @app.delete("/api/gallery/creatives/{entry_id}")
 
470
 
471
  @app.get("/api/gallery/creatives")
472
  def gallery_list(
473
+ limit: int = 24,
474
  offset: int = 0,
475
  _user: dict = Depends(get_current_user),
476
  ):
477
+ """List current user's gallery creatives with fresh presigned URLs. Newest first. Paginated."""
478
  username = (_user or {}).get("username", "")
479
+ entries, total = gallery_load_entries(username, limit=limit, offset=offset)
480
  out = []
481
  for e in entries:
482
  image_url = r2_get_presigned_url(e.get("r2_key", "")) if e.get("r2_key") else None
 
488
  "created_at": e.get("created_at", ""),
489
  "image_url": image_url,
490
  })
491
+ return {"creatives": out, "total": total}
492
 
493
 
494
  @app.delete("/api/gallery/creatives/{entry_id}")
frontend/src/Gallery.jsx CHANGED
@@ -5,6 +5,7 @@ import ImageLightbox from './ImageLightbox'
5
 
6
  const API_BASE = '/api'
7
  const AUTH_TOKEN_KEY = 'amalfa_auth_token'
 
8
 
9
  /** Use backend proxy for image URLs to avoid CORS when loading from R2. */
10
  function proxyImageUrl(url) {
@@ -19,18 +20,24 @@ function authHeaders() {
19
 
20
  export default function Gallery({ onLogout }) {
21
  const [creatives, setCreatives] = useState([])
 
 
22
  const [loading, setLoading] = useState(true)
23
  const [error, setError] = useState(null)
24
  const [selected, setSelected] = useState([])
25
  const [downloading, setDownloading] = useState(false)
26
  const [deletingId, setDeletingId] = useState(null)
27
  const [lightboxImage, setLightboxImage] = useState(null)
 
 
 
28
 
29
  useEffect(() => {
30
  let cancelled = false
31
  setLoading(true)
32
  setError(null)
33
- fetch(`${API_BASE}/gallery/creatives?limit=200`, { headers: authHeaders() })
 
34
  .then((r) => {
35
  if (r.status === 401) {
36
  localStorage.removeItem(AUTH_TOKEN_KEY)
@@ -41,12 +48,18 @@ export default function Gallery({ onLogout }) {
41
  return r.json()
42
  })
43
  .then((data) => {
44
- if (!cancelled && data) setCreatives(data.creatives || [])
 
 
 
 
 
 
45
  })
46
  .catch((e) => { if (!cancelled) setError(e.message) })
47
  .finally(() => { if (!cancelled) setLoading(false) })
48
  return () => { cancelled = true }
49
- }, [onLogout])
50
 
51
  function toggleSelect(id) {
52
  setSelected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]))
@@ -67,6 +80,7 @@ export default function Gallery({ onLogout }) {
67
  if (!res.ok) throw new Error('Delete failed')
68
  setCreatives((prev) => prev.filter((c) => c.id !== id))
69
  setSelected((prev) => prev.filter((x) => x !== id))
 
70
  } catch (_) {
71
  setError('Failed to delete')
72
  } finally {
@@ -129,6 +143,7 @@ export default function Gallery({ onLogout }) {
129
  if (res.ok) setCreatives((prev) => prev.filter((c) => c.id !== id))
130
  } catch (_) {}
131
  }
 
132
  }
133
 
134
  function formatDate(iso) {
@@ -251,6 +266,32 @@ export default function Gallery({ onLogout }) {
251
  </div>
252
  ))}
253
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
254
  </>
255
  )}
256
  </section>
 
5
 
6
  const API_BASE = '/api'
7
  const AUTH_TOKEN_KEY = 'amalfa_auth_token'
8
+ const GALLERY_PAGE_SIZE = 24
9
 
10
  /** Use backend proxy for image URLs to avoid CORS when loading from R2. */
11
  function proxyImageUrl(url) {
 
20
 
21
  export default function Gallery({ onLogout }) {
22
  const [creatives, setCreatives] = useState([])
23
+ const [total, setTotal] = useState(0)
24
+ const [page, setPage] = useState(0)
25
  const [loading, setLoading] = useState(true)
26
  const [error, setError] = useState(null)
27
  const [selected, setSelected] = useState([])
28
  const [downloading, setDownloading] = useState(false)
29
  const [deletingId, setDeletingId] = useState(null)
30
  const [lightboxImage, setLightboxImage] = useState(null)
31
+ const [refresh, setRefresh] = useState(0)
32
+
33
+ const totalPages = Math.max(1, Math.ceil(total / GALLERY_PAGE_SIZE))
34
 
35
  useEffect(() => {
36
  let cancelled = false
37
  setLoading(true)
38
  setError(null)
39
+ const offset = page * GALLERY_PAGE_SIZE
40
+ fetch(`${API_BASE}/gallery/creatives?limit=${GALLERY_PAGE_SIZE}&offset=${offset}`, { headers: authHeaders() })
41
  .then((r) => {
42
  if (r.status === 401) {
43
  localStorage.removeItem(AUTH_TOKEN_KEY)
 
48
  return r.json()
49
  })
50
  .then((data) => {
51
+ if (!cancelled && data) {
52
+ const list = data.creatives || []
53
+ const totalCount = data.total ?? 0
54
+ setCreatives(list)
55
+ setTotal(totalCount)
56
+ if (list.length === 0 && totalCount > 0 && page > 0) setPage(0)
57
+ }
58
  })
59
  .catch((e) => { if (!cancelled) setError(e.message) })
60
  .finally(() => { if (!cancelled) setLoading(false) })
61
  return () => { cancelled = true }
62
+ }, [onLogout, page, refresh])
63
 
64
  function toggleSelect(id) {
65
  setSelected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]))
 
80
  if (!res.ok) throw new Error('Delete failed')
81
  setCreatives((prev) => prev.filter((c) => c.id !== id))
82
  setSelected((prev) => prev.filter((x) => x !== id))
83
+ setRefresh((r) => r + 1)
84
  } catch (_) {
85
  setError('Failed to delete')
86
  } finally {
 
143
  if (res.ok) setCreatives((prev) => prev.filter((c) => c.id !== id))
144
  } catch (_) {}
145
  }
146
+ setRefresh((r) => r + 1)
147
  }
148
 
149
  function formatDate(iso) {
 
266
  </div>
267
  ))}
268
  </div>
269
+ {totalPages > 1 && (
270
+ <div className="gallery-pagination">
271
+ <button
272
+ type="button"
273
+ className="btn-gallery-page"
274
+ disabled={page === 0 || loading}
275
+ onClick={() => { setPage((p) => Math.max(0, p - 1)); setSelected([]); }}
276
+ aria-label="Previous page"
277
+ >
278
+ Previous
279
+ </button>
280
+ <span className="gallery-pagination-info">
281
+ Page {page + 1} of {totalPages}
282
+ {total > 0 && ` (${total} total)`}
283
+ </span>
284
+ <button
285
+ type="button"
286
+ className="btn-gallery-page"
287
+ disabled={page >= totalPages - 1 || loading}
288
+ onClick={() => { setPage((p) => Math.min(totalPages - 1, p + 1)); setSelected([]); }}
289
+ aria-label="Next page"
290
+ >
291
+ Next
292
+ </button>
293
+ </div>
294
+ )}
295
  </>
296
  )}
297
  </section>
frontend/src/index.css CHANGED
@@ -1330,6 +1330,40 @@ a:hover {
1330
  cursor: not-allowed;
1331
  }
1332
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1333
  .gallery-grid {
1334
  display: grid;
1335
  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
 
1330
  cursor: not-allowed;
1331
  }
1332
 
1333
+ .gallery-pagination {
1334
+ display: flex;
1335
+ align-items: center;
1336
+ justify-content: center;
1337
+ gap: 1rem;
1338
+ margin-top: 1.5rem;
1339
+ padding-top: 1rem;
1340
+ border-top: 1px solid var(--border);
1341
+ }
1342
+
1343
+ .gallery-pagination-info {
1344
+ font-size: 0.9rem;
1345
+ color: var(--text-soft);
1346
+ }
1347
+
1348
+ .btn-gallery-page {
1349
+ padding: 0.5rem 1rem;
1350
+ font-size: 0.9rem;
1351
+ border-radius: 8px;
1352
+ border: 1px solid var(--border);
1353
+ background: var(--surface-soft);
1354
+ color: var(--text);
1355
+ cursor: pointer;
1356
+ }
1357
+
1358
+ .btn-gallery-page:hover:not(:disabled) {
1359
+ background: var(--border-soft);
1360
+ }
1361
+
1362
+ .btn-gallery-page:disabled {
1363
+ opacity: 0.5;
1364
+ cursor: not-allowed;
1365
+ }
1366
+
1367
  .gallery-grid {
1368
  display: grid;
1369
  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));