duanfangqin commited on
Commit
6d5abf1
·
1 Parent(s): f9a5860

feat: add image thumbnails in manager and logs

Browse files
api/system.py CHANGED
@@ -6,7 +6,7 @@ from pydantic import BaseModel, ConfigDict
6
 
7
  from api.support import require_admin, require_identity, resolve_image_base_url
8
  from services.config import config
9
- from services.image_service import delete_images, list_images
10
  from services.log_service import log_service
11
  from services.proxy_service import test_proxy
12
 
@@ -59,6 +59,10 @@ def create_router(app_version: str) -> APIRouter:
59
  require_admin(authorization)
60
  return list_images(resolve_image_base_url(request), start_date=start_date.strip(), end_date=end_date.strip())
61
 
 
 
 
 
62
  @router.post("/api/images/delete")
63
  async def delete_images_endpoint(body: ImageDeleteRequest, authorization: str | None = Header(default=None)):
64
  require_admin(authorization)
 
6
 
7
  from api.support import require_admin, require_identity, resolve_image_base_url
8
  from services.config import config
9
+ from services.image_service import delete_images, get_thumbnail_response, list_images
10
  from services.log_service import log_service
11
  from services.proxy_service import test_proxy
12
 
 
59
  require_admin(authorization)
60
  return list_images(resolve_image_base_url(request), start_date=start_date.strip(), end_date=end_date.strip())
61
 
62
+ @router.get("/image-thumbnails/{image_path:path}", include_in_schema=False)
63
+ async def get_image_thumbnail(image_path: str):
64
+ return get_thumbnail_response(image_path)
65
+
66
  @router.post("/api/images/delete")
67
  async def delete_images_endpoint(body: ImageDeleteRequest, authorization: str | None = Header(default=None)):
68
  require_admin(authorization)
services/config.py CHANGED
@@ -138,6 +138,12 @@ class ConfigStore:
138
  path.mkdir(parents=True, exist_ok=True)
139
  return path
140
 
 
 
 
 
 
 
141
  def cleanup_old_images(self) -> int:
142
  cutoff = time.time() - self.image_retention_days * 86400
143
  removed = 0
 
138
  path.mkdir(parents=True, exist_ok=True)
139
  return path
140
 
141
+ @property
142
+ def image_thumbnails_dir(self) -> Path:
143
+ path = DATA_DIR / "image_thumbnails"
144
+ path.mkdir(parents=True, exist_ok=True)
145
+ return path
146
+
147
  def cleanup_old_images(self) -> int:
148
  cutoff = time.time() - self.image_retention_days * 86400
149
  removed = 0
services/image_service.py CHANGED
@@ -1,9 +1,105 @@
1
  from __future__ import annotations
2
 
3
  from datetime import datetime
 
 
 
 
 
4
 
5
  from services.config import config
6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
 
8
  def _image_items(start_date: str = "", end_date: str = "") -> list[dict[str, object]]:
9
  items = []
@@ -18,14 +114,30 @@ def _image_items(start_date: str = "", end_date: str = "") -> list[dict[str, obj
18
  continue
19
  if end_date and day > end_date:
20
  continue
21
- items.append({"path": rel, "name": path.name, "date": day, "size": path.stat().st_size, "created_at": datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S")})
 
 
 
 
 
 
 
 
22
  items.sort(key=lambda item: str(item["created_at"]), reverse=True)
23
  return items
24
 
25
 
26
  def list_images(base_url: str, start_date: str = "", end_date: str = "") -> dict[str, object]:
27
  config.cleanup_old_images()
28
- items = [{**item, "url": f"{base_url.rstrip('/')}/images/{item['path']}"} for item in _image_items(start_date, end_date)]
 
 
 
 
 
 
 
 
29
  groups: dict[str, list[dict[str, object]]] = {}
30
  for item in items:
31
  groups.setdefault(str(item["date"]), []).append(item)
@@ -44,8 +156,10 @@ def delete_images(paths: list[str] | None = None, start_date: str = "", end_date
44
  continue
45
  if path.is_file():
46
  path.unlink()
 
 
 
47
  removed += 1
48
- for path in sorted((p for p in root.rglob("*") if p.is_dir()), key=lambda p: len(p.parts), reverse=True):
49
- if not any(path.iterdir()):
50
- path.rmdir()
51
  return {"removed": removed}
 
1
  from __future__ import annotations
2
 
3
  from datetime import datetime
4
+ from pathlib import Path
5
+
6
+ from fastapi import HTTPException
7
+ from fastapi.responses import FileResponse
8
+ from PIL import Image, ImageOps
9
 
10
  from services.config import config
11
 
12
+ THUMBNAIL_SIZE = (320, 320)
13
+
14
+
15
+ def _cleanup_empty_dirs(root: Path) -> None:
16
+ for path in sorted((p for p in root.rglob("*") if p.is_dir()), key=lambda p: len(p.parts), reverse=True):
17
+ try:
18
+ path.rmdir()
19
+ except OSError:
20
+ pass
21
+
22
+
23
+ def _safe_relative_path(path: str) -> str:
24
+ value = str(path or "").strip().replace("\\", "/").lstrip("/")
25
+ if not value:
26
+ raise HTTPException(status_code=404, detail="image not found")
27
+ parts = Path(value).parts
28
+ if any(part in {"", ".", ".."} for part in parts):
29
+ raise HTTPException(status_code=404, detail="image not found")
30
+ return Path(*parts).as_posix()
31
+
32
+
33
+ def _safe_image_path(relative_path: str) -> Path:
34
+ rel = _safe_relative_path(relative_path)
35
+ root = config.images_dir.resolve()
36
+ path = (root / rel).resolve()
37
+ try:
38
+ path.relative_to(root)
39
+ except ValueError as exc:
40
+ raise HTTPException(status_code=404, detail="image not found") from exc
41
+ if not path.is_file():
42
+ raise HTTPException(status_code=404, detail="image not found")
43
+ return path
44
+
45
+
46
+ def _thumbnail_path(relative_path: str) -> Path:
47
+ rel = _safe_relative_path(relative_path)
48
+ return config.image_thumbnails_dir / rel
49
+
50
+
51
+ def thumbnail_url(base_url: str, relative_path: str) -> str:
52
+ return f"{base_url.rstrip('/')}/image-thumbnails/{_safe_relative_path(relative_path)}"
53
+
54
+
55
+ def _image_dimensions(path: Path) -> tuple[int, int] | None:
56
+ try:
57
+ with Image.open(path) as image:
58
+ return image.size
59
+ except Exception:
60
+ return None
61
+
62
+
63
+ def ensure_thumbnail(relative_path: str) -> Path:
64
+ source = _safe_image_path(relative_path)
65
+ target = _thumbnail_path(relative_path)
66
+ source_mtime = source.stat().st_mtime
67
+ if target.exists() and target.stat().st_mtime >= source_mtime:
68
+ return target
69
+
70
+ target.parent.mkdir(parents=True, exist_ok=True)
71
+ try:
72
+ with Image.open(source) as image:
73
+ image = ImageOps.exif_transpose(image)
74
+ if image.mode not in {"RGB", "RGBA"}:
75
+ image = image.convert("RGBA" if "A" in image.getbands() else "RGB")
76
+ image.thumbnail(THUMBNAIL_SIZE, Image.Resampling.LANCZOS)
77
+ image.save(target, format="PNG", optimize=True)
78
+ except HTTPException:
79
+ raise
80
+ except Exception as exc:
81
+ raise HTTPException(status_code=422, detail="failed to create thumbnail") from exc
82
+ return target
83
+
84
+
85
+ def get_thumbnail_response(relative_path: str) -> FileResponse:
86
+ return FileResponse(ensure_thumbnail(relative_path))
87
+
88
+
89
+ def cleanup_image_thumbnails() -> int:
90
+ thumbnails_root = config.image_thumbnails_dir
91
+ images_root = config.images_dir
92
+ removed = 0
93
+ for path in thumbnails_root.rglob("*"):
94
+ if not path.is_file():
95
+ continue
96
+ rel = path.relative_to(thumbnails_root)
97
+ if not (images_root / rel).exists():
98
+ path.unlink()
99
+ removed += 1
100
+ _cleanup_empty_dirs(thumbnails_root)
101
+ return removed
102
+
103
 
104
  def _image_items(start_date: str = "", end_date: str = "") -> list[dict[str, object]]:
105
  items = []
 
114
  continue
115
  if end_date and day > end_date:
116
  continue
117
+ dimensions = _image_dimensions(path)
118
+ items.append({
119
+ "path": rel,
120
+ "name": path.name,
121
+ "date": day,
122
+ "size": path.stat().st_size,
123
+ "created_at": datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S"),
124
+ **({"width": dimensions[0], "height": dimensions[1]} if dimensions else {}),
125
+ })
126
  items.sort(key=lambda item: str(item["created_at"]), reverse=True)
127
  return items
128
 
129
 
130
  def list_images(base_url: str, start_date: str = "", end_date: str = "") -> dict[str, object]:
131
  config.cleanup_old_images()
132
+ cleanup_image_thumbnails()
133
+ items = [
134
+ {
135
+ **item,
136
+ "url": f"{base_url.rstrip('/')}/images/{item['path']}",
137
+ "thumbnail_url": thumbnail_url(base_url, str(item["path"])),
138
+ }
139
+ for item in _image_items(start_date, end_date)
140
+ ]
141
  groups: dict[str, list[dict[str, object]]] = {}
142
  for item in items:
143
  groups.setdefault(str(item["date"]), []).append(item)
 
156
  continue
157
  if path.is_file():
158
  path.unlink()
159
+ thumbnail = _thumbnail_path(item)
160
+ if thumbnail.is_file():
161
+ thumbnail.unlink()
162
  removed += 1
163
+ _cleanup_empty_dirs(root)
164
+ _cleanup_empty_dirs(config.image_thumbnails_dir)
 
165
  return {"removed": removed}
web/src/app/image-manager/page.tsx CHANGED
@@ -6,6 +6,7 @@ import { toast } from "sonner";
6
 
7
  import { DateRangeFilter } from "@/components/date-range-filter";
8
  import { ImageLightbox } from "@/components/image-lightbox";
 
9
  import { Button } from "@/components/ui/button";
10
  import { Card, CardContent } from "@/components/ui/card";
11
  import { Checkbox } from "@/components/ui/checkbox";
@@ -21,6 +22,10 @@ function imageKey(item: ManagedImage) {
21
  return item.path || item.url;
22
  }
23
 
 
 
 
 
24
  function ImageManagerContent() {
25
  const [items, setItems] = useState<ManagedImage[]>([]);
26
  const [startDate, setStartDate] = useState("");
@@ -28,7 +33,6 @@ function ImageManagerContent() {
28
  const [lightboxIndex, setLightboxIndex] = useState(0);
29
  const [lightboxOpen, setLightboxOpen] = useState(false);
30
  const [page, setPage] = useState(1);
31
- const [dimensions, setDimensions] = useState<Record<string, string>>({});
32
  const [isLoading, setIsLoading] = useState(true);
33
  const [isDeleting, setIsDeleting] = useState(false);
34
  const [selectedPaths, setSelectedPaths] = useState<string[]>([]);
@@ -37,7 +41,7 @@ function ImageManagerContent() {
37
  id: imageKey(item),
38
  src: item.url,
39
  sizeLabel: formatSize(item.size),
40
- dimensions: dimensions[item.url],
41
  }));
42
  const pageSize = 12;
43
  const pageCount = Math.max(1, Math.ceil(items.length / pageSize));
@@ -145,7 +149,7 @@ function ImageManagerContent() {
145
  </div>
146
  </div>
147
  <div className="grid gap-0 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
148
- {currentRows.map((item, index) => {
149
  const imageIndex = items.findIndex((row) => row.url === item.url);
150
  return (
151
  <div key={item.url} className="group border-r border-b border-stone-100 p-4 transition hover:bg-stone-50">
@@ -157,17 +161,12 @@ function ImageManagerContent() {
157
  setLightboxOpen(true);
158
  }}
159
  >
160
- <img
161
  src={item.url}
 
162
  alt={item.name}
163
- className="h-full w-full object-cover transition group-hover:scale-[1.02]"
164
- onLoad={(event) => {
165
- const image = event.currentTarget;
166
- setDimensions((current) => ({
167
- ...current,
168
- [item.url]: `${image.naturalWidth} x ${image.naturalHeight}`,
169
- }));
170
- }}
171
  />
172
  <span className="absolute right-2 bottom-2 rounded-full bg-black/50 p-2 text-white opacity-0 transition group-hover:opacity-100">
173
  <Maximize2 className="size-4" />
@@ -196,7 +195,7 @@ function ImageManagerContent() {
196
  </div>
197
  <div className="flex items-center justify-between gap-2">
198
  <span>{formatSize(item.size)}</span>
199
- <span>{dimensions[item.url] || "-"}</span>
200
  </div>
201
  </div>
202
  </div>
 
6
 
7
  import { DateRangeFilter } from "@/components/date-range-filter";
8
  import { ImageLightbox } from "@/components/image-lightbox";
9
+ import { ImageThumbnail } from "@/components/image-thumbnail";
10
  import { Button } from "@/components/ui/button";
11
  import { Card, CardContent } from "@/components/ui/card";
12
  import { Checkbox } from "@/components/ui/checkbox";
 
22
  return item.path || item.url;
23
  }
24
 
25
+ function imageDimensions(item: ManagedImage) {
26
+ return item.width && item.height ? `${item.width} x ${item.height}` : "-";
27
+ }
28
+
29
  function ImageManagerContent() {
30
  const [items, setItems] = useState<ManagedImage[]>([]);
31
  const [startDate, setStartDate] = useState("");
 
33
  const [lightboxIndex, setLightboxIndex] = useState(0);
34
  const [lightboxOpen, setLightboxOpen] = useState(false);
35
  const [page, setPage] = useState(1);
 
36
  const [isLoading, setIsLoading] = useState(true);
37
  const [isDeleting, setIsDeleting] = useState(false);
38
  const [selectedPaths, setSelectedPaths] = useState<string[]>([]);
 
41
  id: imageKey(item),
42
  src: item.url,
43
  sizeLabel: formatSize(item.size),
44
+ dimensions: imageDimensions(item),
45
  }));
46
  const pageSize = 12;
47
  const pageCount = Math.max(1, Math.ceil(items.length / pageSize));
 
149
  </div>
150
  </div>
151
  <div className="grid gap-0 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
152
+ {currentRows.map((item) => {
153
  const imageIndex = items.findIndex((row) => row.url === item.url);
154
  return (
155
  <div key={item.url} className="group border-r border-b border-stone-100 p-4 transition hover:bg-stone-50">
 
161
  setLightboxOpen(true);
162
  }}
163
  >
164
+ <ImageThumbnail
165
  src={item.url}
166
+ thumbnailSrc={item.thumbnail_url}
167
  alt={item.name}
168
+ className="h-full w-full"
169
+ imageClassName="transition group-hover:scale-[1.02]"
 
 
 
 
 
 
170
  />
171
  <span className="absolute right-2 bottom-2 rounded-full bg-black/50 p-2 text-white opacity-0 transition group-hover:opacity-100">
172
  <Maximize2 className="size-4" />
 
195
  </div>
196
  <div className="flex items-center justify-between gap-2">
197
  <span>{formatSize(item.size)}</span>
198
+ <span>{imageDimensions(item)}</span>
199
  </div>
200
  </div>
201
  </div>
web/src/app/logs/page.tsx CHANGED
@@ -1,11 +1,12 @@
1
  "use client";
2
 
3
  import { useEffect, useState } from "react";
4
- import { ChevronLeft, ChevronRight, LoaderCircle, RefreshCw, Search } from "lucide-react";
5
  import { toast } from "sonner";
6
 
7
  import { DateRangeFilter } from "@/components/date-range-filter";
8
  import { ImageLightbox } from "@/components/image-lightbox";
 
9
  import { Badge } from "@/components/ui/badge";
10
  import { Button } from "@/components/ui/button";
11
  import { Card, CardContent } from "@/components/ui/card";
@@ -49,7 +50,7 @@ function getStatus(item: SystemLog) {
49
 
50
  function LogsContent() {
51
  const [items, setItems] = useState<SystemLog[]>([]);
52
- const [type, setType] = useState(LogType.Call);
53
  const [startDate, setStartDate] = useState("");
54
  const [endDate, setEndDate] = useState("");
55
  const [detailLog, setDetailLog] = useState<SystemLog | null>(null);
@@ -89,6 +90,12 @@ function LogsContent() {
89
  setDetailOpen(true);
90
  };
91
 
 
 
 
 
 
 
92
  useEffect(() => {
93
  void loadLogs();
94
  }, [type, startDate, endDate]);
@@ -137,32 +144,61 @@ function LogsContent() {
137
  {isCallLog ? <TableHead>令牌名称</TableHead> : null}
138
  {isCallLog ? <TableHead>调用耗时</TableHead> : null}
139
  {isCallLog ? <TableHead>状态</TableHead> : null}
 
140
  <TableHead>简述</TableHead>
141
  <TableHead className="w-28">详情</TableHead>
142
  </TableRow>
143
  </TableHeader>
144
  <TableBody>
145
- {currentRows.map((item, index) => (
146
- <TableRow key={`${item.time}-${index}`} className="text-stone-600">
147
- <TableCell className="whitespace-nowrap">{item.time}</TableCell>
148
- <TableCell><Badge variant="secondary" className="rounded-md">{typeLabels[item.type] || item.type}</Badge></TableCell>
149
- {isCallLog ? <TableCell>{getDetailText(item, "key_name")}</TableCell> : null}
150
- {isCallLog ? <TableCell>{formatDuration(item)}</TableCell> : null}
151
- {isCallLog ? (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  <TableCell>
153
- <Badge variant={item.detail?.status === "failed" ? "danger" : "success"} className="rounded-md">
154
- {getStatus(item)}
155
- </Badge>
156
  </TableCell>
157
- ) : null}
158
- <TableCell className="max-w-[420px] truncate text-stone-500">{item.summary || "-"}</TableCell>
159
- <TableCell>
160
- <Button variant="ghost" className="h-8 rounded-lg px-3 text-stone-600" onClick={() => openDetail(item)}>
161
- 查看详情
162
- </Button>
163
- </TableCell>
164
- </TableRow>
165
- ))}
166
  </TableBody>
167
  </Table>
168
  </div>
@@ -205,7 +241,7 @@ function LogsContent() {
205
  setLightboxOpen(true);
206
  }}
207
  >
208
- <img src={url} alt="" className="h-full w-full object-cover" />
209
  </button>
210
  ))}
211
  </div>
 
1
  "use client";
2
 
3
  import { useEffect, useState } from "react";
4
+ import { ChevronLeft, ChevronRight, ImageIcon, LoaderCircle, RefreshCw, Search } from "lucide-react";
5
  import { toast } from "sonner";
6
 
7
  import { DateRangeFilter } from "@/components/date-range-filter";
8
  import { ImageLightbox } from "@/components/image-lightbox";
9
+ import { ImageThumbnail, getImageThumbnailUrl } from "@/components/image-thumbnail";
10
  import { Badge } from "@/components/ui/badge";
11
  import { Button } from "@/components/ui/button";
12
  import { Card, CardContent } from "@/components/ui/card";
 
50
 
51
  function LogsContent() {
52
  const [items, setItems] = useState<SystemLog[]>([]);
53
+ const [type, setType] = useState<string>(LogType.Call);
54
  const [startDate, setStartDate] = useState("");
55
  const [endDate, setEndDate] = useState("");
56
  const [detailLog, setDetailLog] = useState<SystemLog | null>(null);
 
90
  setDetailOpen(true);
91
  };
92
 
93
+ const openLogImage = (item: SystemLog, index: number) => {
94
+ setDetailLog(item);
95
+ setLightboxIndex(index);
96
+ setLightboxOpen(true);
97
+ };
98
+
99
  useEffect(() => {
100
  void loadLogs();
101
  }, [type, startDate, endDate]);
 
144
  {isCallLog ? <TableHead>令牌名称</TableHead> : null}
145
  {isCallLog ? <TableHead>调用耗时</TableHead> : null}
146
  {isCallLog ? <TableHead>状态</TableHead> : null}
147
+ {isCallLog ? <TableHead className="w-36">图片</TableHead> : null}
148
  <TableHead>简述</TableHead>
149
  <TableHead className="w-28">详情</TableHead>
150
  </TableRow>
151
  </TableHeader>
152
  <TableBody>
153
+ {currentRows.map((item, index) => {
154
+ const urls = getUrls(item);
155
+ return (
156
+ <TableRow key={`${item.time}-${index}`} className="text-stone-600">
157
+ <TableCell className="whitespace-nowrap">{item.time}</TableCell>
158
+ <TableCell><Badge variant="secondary" className="rounded-md">{typeLabels[item.type] || item.type}</Badge></TableCell>
159
+ {isCallLog ? <TableCell>{getDetailText(item, "key_name")}</TableCell> : null}
160
+ {isCallLog ? <TableCell>{formatDuration(item)}</TableCell> : null}
161
+ {isCallLog ? (
162
+ <TableCell>
163
+ <Badge variant={item.detail?.status === "failed" ? "danger" : "success"} className="rounded-md">
164
+ {getStatus(item)}
165
+ </Badge>
166
+ </TableCell>
167
+ ) : null}
168
+ {isCallLog ? (
169
+ <TableCell>
170
+ {urls.length ? (
171
+ <div className="flex items-center gap-1.5">
172
+ {urls.slice(0, 3).map((url, imageIndex) => (
173
+ <button
174
+ key={`${url}-${imageIndex}`}
175
+ type="button"
176
+ className="relative size-9 overflow-hidden rounded-lg border border-stone-200 bg-stone-100"
177
+ onClick={() => openLogImage(item, imageIndex)}
178
+ title="预览图片"
179
+ >
180
+ <ImageThumbnail src={url} thumbnailSrc={getImageThumbnailUrl(url)} className="h-full w-full" />
181
+ </button>
182
+ ))}
183
+ {urls.length > 3 ? <span className="text-xs text-stone-400">+{urls.length - 3}</span> : null}
184
+ </div>
185
+ ) : (
186
+ <span className="inline-flex items-center gap-1 text-xs text-stone-400">
187
+ <ImageIcon className="size-3.5" />
188
+ -
189
+ </span>
190
+ )}
191
+ </TableCell>
192
+ ) : null}
193
+ <TableCell className="max-w-[420px] truncate text-stone-500">{item.summary || "-"}</TableCell>
194
  <TableCell>
195
+ <Button variant="ghost" className="h-8 rounded-lg px-3 text-stone-600" onClick={() => openDetail(item)}>
196
+ 查看详情
197
+ </Button>
198
  </TableCell>
199
+ </TableRow>
200
+ );
201
+ })}
 
 
 
 
 
 
202
  </TableBody>
203
  </Table>
204
  </div>
 
241
  setLightboxOpen(true);
242
  }}
243
  >
244
+ <ImageThumbnail src={url} thumbnailSrc={getImageThumbnailUrl(url)} className="h-full w-full" />
245
  </button>
246
  ))}
247
  </div>
web/src/components/image-thumbnail.tsx ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useEffect, useMemo, useState } from "react";
4
+
5
+ import { cn } from "@/lib/utils";
6
+
7
+ type ImageThumbnailProps = {
8
+ src: string;
9
+ thumbnailSrc?: string;
10
+ alt?: string;
11
+ className?: string;
12
+ imageClassName?: string;
13
+ };
14
+
15
+ export function getImageThumbnailUrl(src: string) {
16
+ const marker = "/images/";
17
+ const index = src.indexOf(marker);
18
+ if (index < 0) return src;
19
+ return `${src.slice(0, index)}/image-thumbnails/${src.slice(index + marker.length)}`;
20
+ }
21
+
22
+ export function ImageThumbnail({ src, thumbnailSrc, alt = "", className, imageClassName }: ImageThumbnailProps) {
23
+ const initialSrc = useMemo(() => thumbnailSrc || getImageThumbnailUrl(src), [src, thumbnailSrc]);
24
+ const [currentSrc, setCurrentSrc] = useState(initialSrc);
25
+
26
+ useEffect(() => {
27
+ setCurrentSrc(initialSrc);
28
+ }, [initialSrc]);
29
+
30
+ return (
31
+ <span className={cn("block overflow-hidden bg-stone-100", className)}>
32
+ <img
33
+ src={currentSrc}
34
+ alt={alt}
35
+ className={cn("h-full w-full object-cover", imageClassName)}
36
+ loading="lazy"
37
+ decoding="async"
38
+ onError={() => {
39
+ if (currentSrc !== src) {
40
+ setCurrentSrc(src);
41
+ }
42
+ }}
43
+ />
44
+ </span>
45
+ );
46
+ }
web/src/lib/api.ts CHANGED
@@ -67,7 +67,10 @@ export type ManagedImage = {
67
  date: string;
68
  size: number;
69
  url: string;
 
70
  created_at: string;
 
 
71
  };
72
 
73
  export type SystemLog = {
 
67
  date: string;
68
  size: number;
69
  url: string;
70
+ thumbnail_url?: string;
71
  created_at: string;
72
+ width?: number;
73
+ height?: number;
74
  };
75
 
76
  export type SystemLog = {