Compare commits
9 Commits
2d5e5a05a2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 412a06be2c | |||
| 8ddfe545d9 | |||
| 76d0984b06 | |||
| 4fca4b9278 | |||
| d9512248df | |||
| a070b9c499 | |||
| a30e83a724 | |||
| 7afca6ed04 | |||
| 2043a1b8b5 |
8
.gitignore
vendored
@@ -8,3 +8,11 @@ frontend/node_modules/
|
|||||||
frontend/dist/
|
frontend/dist/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
|
|
||||||
|
# Données runtime — ne pas versionner (BDD, uploads, cache météo)
|
||||||
|
data/jardin.db
|
||||||
|
data/jardin.db-shm
|
||||||
|
data/jardin.db-wal
|
||||||
|
data/meteo_cache.json
|
||||||
|
data/uploads/
|
||||||
|
data/skyfield/
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from sqlmodel import Session, select
|
|||||||
from app.config import UPLOAD_DIR
|
from app.config import UPLOAD_DIR
|
||||||
from app.database import get_session
|
from app.database import get_session
|
||||||
from app.models.media import Attachment, Media
|
from app.models.media import Attachment, Media
|
||||||
|
from app.models.settings import UserSettings
|
||||||
|
|
||||||
|
|
||||||
class MediaPatch(BaseModel):
|
class MediaPatch(BaseModel):
|
||||||
@@ -102,6 +103,20 @@ def _canonicalize_rows(rows: List[Media], session: Session) -> None:
|
|||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
import pillow_heif
|
||||||
|
pillow_heif.register_heif_opener()
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _is_heic(content_type: str, filename: str) -> bool:
|
||||||
|
if content_type.lower() in ("image/heic", "image/heif"):
|
||||||
|
return True
|
||||||
|
fn = (filename or "").lower()
|
||||||
|
return fn.endswith(".heic") or fn.endswith(".heif")
|
||||||
|
|
||||||
|
|
||||||
def _save_webp(data: bytes, max_px: int) -> str:
|
def _save_webp(data: bytes, max_px: int) -> str:
|
||||||
try:
|
try:
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
@@ -122,12 +137,28 @@ def _save_webp(data: bytes, max_px: int) -> str:
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/upload")
|
@router.post("/upload")
|
||||||
async def upload_file(file: UploadFile = File(...)):
|
async def upload_file(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||||||
data = await file.read()
|
data = await file.read()
|
||||||
ct = file.content_type or ""
|
ct = file.content_type or ""
|
||||||
if ct.startswith("image/"):
|
|
||||||
name = _save_webp(data, 1200)
|
# Lire la largeur max configurée (défaut 1200, 0 = taille originale)
|
||||||
|
setting = session.exec(select(UserSettings).where(UserSettings.cle == "image_max_width")).first()
|
||||||
|
max_px = 1200
|
||||||
|
if setting:
|
||||||
|
try:
|
||||||
|
max_px = int(setting.valeur)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
if max_px <= 0:
|
||||||
|
max_px = 99999
|
||||||
|
|
||||||
|
heic = _is_heic(ct, file.filename or "")
|
||||||
|
if heic or ct.startswith("image/"):
|
||||||
|
name = _save_webp(data, max_px)
|
||||||
thumb = _save_webp(data, 300)
|
thumb = _save_webp(data, 300)
|
||||||
return {"url": f"/uploads/{name}", "thumbnail_url": f"/uploads/{thumb}"}
|
return {"url": f"/uploads/{name}", "thumbnail_url": f"/uploads/{thumb}"}
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ from datetime import datetime, timezone
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from starlette.background import BackgroundTask
|
from starlette.background import BackgroundTask
|
||||||
|
from sqlalchemy import text
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
from app.database import get_session
|
from app.database import get_session
|
||||||
from app.models.settings import UserSettings
|
from app.models.settings import UserSettings
|
||||||
@@ -235,8 +236,8 @@ def get_debug_system_stats() -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/settings/backup/download")
|
def _create_backup_zip() -> tuple[Path, str]:
|
||||||
def download_backup_zip() -> FileResponse:
|
"""Crée l'archive ZIP de sauvegarde. Retourne (chemin_tmp, nom_fichier)."""
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
ts = now.strftime("%Y%m%d_%H%M%S")
|
ts = now.strftime("%Y%m%d_%H%M%S")
|
||||||
db_path = _resolve_sqlite_db_path()
|
db_path = _resolve_sqlite_db_path()
|
||||||
@@ -247,17 +248,12 @@ def download_backup_zip() -> FileResponse:
|
|||||||
os.close(fd)
|
os.close(fd)
|
||||||
tmp_zip = Path(tmp_zip_path)
|
tmp_zip = Path(tmp_zip_path)
|
||||||
|
|
||||||
stats = {
|
stats = {"database_files": 0, "upload_files": 0, "text_files": 0}
|
||||||
"database_files": 0,
|
|
||||||
"upload_files": 0,
|
|
||||||
"text_files": 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
with zipfile.ZipFile(tmp_zip, mode="w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as zipf:
|
with zipfile.ZipFile(tmp_zip, mode="w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as zipf:
|
||||||
if db_path and db_path.is_file():
|
if db_path and db_path.is_file():
|
||||||
zipf.write(db_path, arcname=f"db/{db_path.name}")
|
zipf.write(db_path, arcname=f"db/{db_path.name}")
|
||||||
stats["database_files"] = 1
|
stats["database_files"] = 1
|
||||||
|
|
||||||
stats["upload_files"] = _zip_directory(zipf, uploads_dir, "uploads")
|
stats["upload_files"] = _zip_directory(zipf, uploads_dir, "uploads")
|
||||||
stats["text_files"] = _zip_data_text_files(zipf, data_root, db_path, uploads_dir)
|
stats["text_files"] = _zip_data_text_files(zipf, data_root, db_path, uploads_dir)
|
||||||
|
|
||||||
@@ -274,10 +270,245 @@ def download_backup_zip() -> FileResponse:
|
|||||||
}
|
}
|
||||||
zipf.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2))
|
zipf.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
download_name = f"jardin_backup_{ts}.zip"
|
return tmp_zip, f"jardin_backup_{ts}.zip"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/settings/backup/download")
|
||||||
|
def download_backup_zip() -> FileResponse:
|
||||||
|
tmp_zip, download_name = _create_backup_zip()
|
||||||
return FileResponse(
|
return FileResponse(
|
||||||
path=str(tmp_zip),
|
path=str(tmp_zip),
|
||||||
media_type="application/zip",
|
media_type="application/zip",
|
||||||
filename=download_name,
|
filename=download_name,
|
||||||
background=BackgroundTask(_safe_remove, str(tmp_zip)),
|
background=BackgroundTask(_safe_remove, str(tmp_zip)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_db_add_only(backup_db_path: Path, current_db_path: Path) -> dict[str, int]:
|
||||||
|
"""Insère dans la BDD courante les lignes absentes de la BDD de sauvegarde (INSERT OR IGNORE)."""
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
stats = {"rows_added": 0, "rows_skipped": 0}
|
||||||
|
backup_conn = sqlite3.connect(str(backup_db_path))
|
||||||
|
current_conn = sqlite3.connect(str(current_db_path))
|
||||||
|
current_conn.execute("PRAGMA foreign_keys=OFF")
|
||||||
|
|
||||||
|
try:
|
||||||
|
tables = backup_conn.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
for (table,) in tables:
|
||||||
|
try:
|
||||||
|
cur = backup_conn.execute(f'SELECT * FROM "{table}"')
|
||||||
|
cols = [d[0] for d in cur.description]
|
||||||
|
rows = cur.fetchall()
|
||||||
|
if not rows:
|
||||||
|
continue
|
||||||
|
col_names = ", ".join(f'"{c}"' for c in cols)
|
||||||
|
placeholders = ", ".join(["?"] * len(cols))
|
||||||
|
before = current_conn.execute(f'SELECT COUNT(*) FROM "{table}"').fetchone()[0]
|
||||||
|
current_conn.executemany(
|
||||||
|
f'INSERT OR IGNORE INTO "{table}" ({col_names}) VALUES ({placeholders})',
|
||||||
|
rows,
|
||||||
|
)
|
||||||
|
after = current_conn.execute(f'SELECT COUNT(*) FROM "{table}"').fetchone()[0]
|
||||||
|
added = after - before
|
||||||
|
stats["rows_added"] += added
|
||||||
|
stats["rows_skipped"] += len(rows) - added
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
current_conn.commit()
|
||||||
|
finally:
|
||||||
|
backup_conn.close()
|
||||||
|
current_conn.close()
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/settings/backup/restore")
|
||||||
|
async def restore_backup(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
overwrite: bool = Form(default=True),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Restaure une sauvegarde ZIP (DB + uploads). overwrite=true écrase, false ajoute uniquement."""
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
db_path = _resolve_sqlite_db_path()
|
||||||
|
uploads_dir = Path(UPLOAD_DIR).resolve()
|
||||||
|
|
||||||
|
data = await file.read()
|
||||||
|
if len(data) < 4 or data[:2] != b'PK':
|
||||||
|
raise HTTPException(400, "Le fichier n'est pas une archive ZIP valide.")
|
||||||
|
|
||||||
|
fd, tmp_zip_path = tempfile.mkstemp(suffix=".zip")
|
||||||
|
os.close(fd)
|
||||||
|
tmp_zip = Path(tmp_zip_path)
|
||||||
|
tmp_extract = Path(tempfile.mkdtemp(prefix="jardin_restore_"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
tmp_zip.write_bytes(data)
|
||||||
|
|
||||||
|
with zipfile.ZipFile(tmp_zip, "r") as zipf:
|
||||||
|
zipf.extractall(str(tmp_extract))
|
||||||
|
|
||||||
|
stats: dict[str, Any] = {
|
||||||
|
"uploads_copies": 0,
|
||||||
|
"uploads_ignores": 0,
|
||||||
|
"db_restauree": False,
|
||||||
|
"db_lignes_ajoutees": 0,
|
||||||
|
"erreurs": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Uploads ---
|
||||||
|
backup_uploads = tmp_extract / "uploads"
|
||||||
|
if backup_uploads.is_dir():
|
||||||
|
uploads_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
for src in backup_uploads.rglob("*"):
|
||||||
|
if not src.is_file():
|
||||||
|
continue
|
||||||
|
dst = uploads_dir / src.relative_to(backup_uploads)
|
||||||
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if overwrite or not dst.exists():
|
||||||
|
try:
|
||||||
|
shutil.copy2(str(src), str(dst))
|
||||||
|
stats["uploads_copies"] += 1
|
||||||
|
except Exception:
|
||||||
|
stats["erreurs"] += 1
|
||||||
|
else:
|
||||||
|
stats["uploads_ignores"] += 1
|
||||||
|
|
||||||
|
# --- Base de données ---
|
||||||
|
backup_db_dir = tmp_extract / "db"
|
||||||
|
db_files = sorted(backup_db_dir.glob("*.db")) if backup_db_dir.is_dir() else []
|
||||||
|
|
||||||
|
if db_files and db_path:
|
||||||
|
backup_db_file = db_files[0]
|
||||||
|
|
||||||
|
if overwrite:
|
||||||
|
from app.database import engine
|
||||||
|
try:
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(text("PRAGMA wal_checkpoint(TRUNCATE)"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
engine.dispose()
|
||||||
|
shutil.copy2(str(backup_db_file), str(db_path))
|
||||||
|
stats["db_restauree"] = True
|
||||||
|
else:
|
||||||
|
merge = _merge_db_add_only(backup_db_file, db_path)
|
||||||
|
stats["db_lignes_ajoutees"] = merge["rows_added"]
|
||||||
|
stats["db_restauree"] = True
|
||||||
|
|
||||||
|
return {"ok": True, **stats}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(500, f"Erreur lors de la restauration : {exc}") from exc
|
||||||
|
finally:
|
||||||
|
_safe_remove(str(tmp_zip))
|
||||||
|
shutil.rmtree(str(tmp_extract), ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/settings/images/resize-all")
|
||||||
|
def resize_all_images(session: Session = Depends(get_session)) -> dict[str, Any]:
|
||||||
|
"""Redimensionne les images pleine taille de la bibliothèque dont la largeur dépasse le paramètre configuré."""
|
||||||
|
from PIL import Image
|
||||||
|
import io as _io
|
||||||
|
|
||||||
|
setting = session.exec(select(UserSettings).where(UserSettings.cle == "image_max_width")).first()
|
||||||
|
max_px = 1200
|
||||||
|
if setting:
|
||||||
|
try:
|
||||||
|
max_px = int(setting.valeur)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if max_px <= 0:
|
||||||
|
return {"ok": True, "redimensionnees": 0, "ignorees": 0, "erreurs": 0,
|
||||||
|
"message": "Taille originale configurée — aucune modification."}
|
||||||
|
|
||||||
|
from app.models.media import Media as MediaModel
|
||||||
|
urls = session.exec(select(MediaModel.url)).all()
|
||||||
|
|
||||||
|
uploads_dir = Path(UPLOAD_DIR).resolve()
|
||||||
|
redimensionnees = 0
|
||||||
|
ignorees = 0
|
||||||
|
erreurs = 0
|
||||||
|
|
||||||
|
for url in urls:
|
||||||
|
if not url:
|
||||||
|
continue
|
||||||
|
# /uploads/filename.webp → data/uploads/filename.webp
|
||||||
|
filename = url.lstrip("/").removeprefix("uploads/")
|
||||||
|
file_path = uploads_dir / filename
|
||||||
|
if not file_path.is_file():
|
||||||
|
ignorees += 1
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with Image.open(file_path) as img:
|
||||||
|
w, h = img.size
|
||||||
|
if w <= max_px and h <= max_px:
|
||||||
|
ignorees += 1
|
||||||
|
continue
|
||||||
|
img_copy = img.copy()
|
||||||
|
img_copy.thumbnail((max_px, max_px), Image.LANCZOS)
|
||||||
|
img_copy.save(file_path, "WEBP", quality=85)
|
||||||
|
redimensionnees += 1
|
||||||
|
except Exception:
|
||||||
|
erreurs += 1
|
||||||
|
|
||||||
|
return {"ok": True, "redimensionnees": redimensionnees, "ignorees": ignorees, "erreurs": erreurs}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/settings/backup/samba")
|
||||||
|
def backup_to_samba(session: Session = Depends(get_session)) -> dict[str, Any]:
|
||||||
|
"""Envoie une sauvegarde ZIP vers un partage Samba/CIFS."""
|
||||||
|
|
||||||
|
def _get(key: str, default: str = "") -> str:
|
||||||
|
row = session.exec(select(UserSettings).where(UserSettings.cle == key)).first()
|
||||||
|
return row.valeur if row else default
|
||||||
|
|
||||||
|
server = _get("samba_serveur").strip()
|
||||||
|
share = _get("samba_partage").strip()
|
||||||
|
username = _get("samba_utilisateur").strip()
|
||||||
|
password = _get("samba_motdepasse")
|
||||||
|
subfolder = _get("samba_sous_dossier").strip().strip("/\\")
|
||||||
|
|
||||||
|
if not server or not share:
|
||||||
|
raise HTTPException(400, "Configuration Samba incomplète : serveur et partage requis.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
import smbclient # type: ignore
|
||||||
|
except ImportError:
|
||||||
|
raise HTTPException(500, "Module smbprotocol non installé dans l'environnement.")
|
||||||
|
|
||||||
|
tmp_zip, filename = _create_backup_zip()
|
||||||
|
try:
|
||||||
|
smbclient.register_session(server, username=username or None, password=password or None)
|
||||||
|
|
||||||
|
remote_dir = f"\\\\{server}\\{share}"
|
||||||
|
if subfolder:
|
||||||
|
remote_dir = f"{remote_dir}\\{subfolder}"
|
||||||
|
try:
|
||||||
|
smbclient.makedirs(remote_dir, exist_ok=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
remote_path = f"{remote_dir}\\{filename}"
|
||||||
|
|
||||||
|
with open(tmp_zip, "rb") as local_f:
|
||||||
|
data = local_f.read()
|
||||||
|
with smbclient.open_file(remote_path, mode="wb") as smb_f:
|
||||||
|
smb_f.write(data)
|
||||||
|
|
||||||
|
return {"ok": True, "fichier": filename, "chemin": remote_path}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(500, f"Erreur Samba : {exc}") from exc
|
||||||
|
finally:
|
||||||
|
_safe_remove(str(tmp_zip))
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ aiofiles==24.1.0
|
|||||||
pytest==8.3.3
|
pytest==8.3.3
|
||||||
httpx==0.28.0
|
httpx==0.28.0
|
||||||
Pillow==11.1.0
|
Pillow==11.1.0
|
||||||
|
pillow-heif==0.21.0
|
||||||
|
smbprotocol==1.15.0
|
||||||
skyfield==1.49
|
skyfield==1.49
|
||||||
pytz==2025.1
|
pytz==2025.1
|
||||||
numpy==2.2.3
|
numpy==2.2.3
|
||||||
|
|||||||
BIN
data/jardin.db
@@ -1 +0,0 @@
|
|||||||
{"cached_at": "2026-02-22T12:59:49.373422+00:00", "days": [{"date": "2026-02-22", "t_max": 14.1, "t_min": 2.1, "pluie_mm": 0, "vent_kmh": 10.8, "code": 3, "label": "Couvert", "icone": "☁️"}, {"date": "2026-02-23", "t_max": 12.0, "t_min": 4.5, "pluie_mm": 0, "vent_kmh": 16.8, "code": 3, "label": "Couvert", "icone": "☁️"}, {"date": "2026-02-24", "t_max": 14.0, "t_min": 4.1, "pluie_mm": 0, "vent_kmh": 6.4, "code": 45, "label": "Brouillard", "icone": "🌫"}]}
|
|
||||||
|
Before Width: | Height: | Size: 254 KiB |
|
Before Width: | Height: | Size: 3.0 MiB |
|
Before Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 3.1 MiB |
|
Before Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 284 KiB |
|
Before Width: | Height: | Size: 44 B |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 3.2 MiB |
|
Before Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 3.8 MiB |
|
Before Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 174 KiB |
|
Before Width: | Height: | Size: 2.7 MiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 44 B |
|
Before Width: | Height: | Size: 282 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 44 B |
|
Before Width: | Height: | Size: 44 B |
|
Before Width: | Height: | Size: 3.4 MiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 3.2 MiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 3.4 MiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 247 KiB |
|
Before Width: | Height: | Size: 44 B |
|
Before Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 3.0 MiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 215 KiB |
|
Before Width: | Height: | Size: 4.8 MiB |
|
Before Width: | Height: | Size: 3.3 MiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 172 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 3.7 MiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 3.5 MiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 2.4 MiB |
|
Before Width: | Height: | Size: 2.8 MiB |
|
Before Width: | Height: | Size: 3.1 MiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 2.9 MiB |
|
Before Width: | Height: | Size: 3.3 MiB |
|
Before Width: | Height: | Size: 4.3 MiB |
|
Before Width: | Height: | Size: 3.2 MiB |
|
Before Width: | Height: | Size: 3.2 MiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 44 B |
|
Before Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 44 B |
|
Before Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 3.8 MiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 191 KiB |