Spaces:
Sleeping
Sleeping
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 +5 -3
- backend/app/main.py +4 -4
- frontend/src/Gallery.jsx +44 -3
- frontend/src/index.css +34 -0
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 |
-
|
|
|
|
|
|
|
| 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 =
|
| 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 |
-
|
|
|
|
| 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)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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));
|